diff --git a/.eslintrc.json b/.eslintrc.json index c6592adcd..399a8d164 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,42 +1,51 @@ { "root": true, "env": { "es6": true }, "extends": [ "eslint:recommended", "plugin:react/recommended", "plugin:flowtype/recommended", "plugin:import/errors", "plugin:import/warnings", "plugin:prettier/recommended", "prettier/react", "prettier/flowtype" ], "parser": "babel-eslint", "plugins": ["react", "react-hooks", "flowtype", "monorepo", "import"], "rules": { "linebreak-style": "error", "semi": "error", "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn", "monorepo/no-relative-import": "error", "no-empty": ["error", { "allowEmptyCatch": true }], "import/no-unresolved": 0, "no-unused-vars": ["error", { "ignoreRestSiblings": true }], "react/prop-types": ["error", { "skipUndeclared": true }], "no-shadow": 1, - "import/order": ["warn", { "newlines-between": "always", + "import/order": [ + "warn", + { + "newlines-between": "always", "alphabetize": { "order": "asc", "caseInsensitive": true - } - }] + }, + "groups": [ + ["builtin", "external"], + "internal" + ] + } + ] }, "settings": { "react": { "version": "detect" }, - "import/ignore": ["react-native"] + "import/ignore": ["react-native"], + "import/internal-regex": "^(lib|native|server|web)/" } } diff --git a/lib/media/image-utils.js b/lib/media/image-utils.js index deaa7cb11..e878ad78a 100644 --- a/lib/media/image-utils.js +++ b/lib/media/image-utils.js @@ -1,64 +1,63 @@ // @flow import type { Dimensions } from '../types/media-types'; - import { getTargetMIME } from './file-utils'; import { maxDimensions } from './media-utils'; const { height: maxHeight, width: maxWidth } = maxDimensions; type Input = {| inputMIME: string, inputDimensions: Dimensions, // post EXIF orientation inputFileSize: number, // in bytes inputOrientation: ?number, |}; type ProcessPlan = {| action: 'process', targetMIME: 'image/png' | 'image/jpeg', compressionRatio: number, fitInside: ?Dimensions, shouldRotate: boolean, |}; type Plan = {| action: 'none' |} | ProcessPlan; function getImageProcessingPlan(input: Input): Plan { const { inputMIME, inputDimensions: { width: inputWidth, height: inputHeight }, inputFileSize, inputOrientation, } = input; const unsupportedMIME = inputMIME !== 'image/png' && inputMIME !== 'image/jpeg'; const needsRotation = !!inputOrientation && inputOrientation > 1; const needsCompression = inputFileSize > 5e6 || (unsupportedMIME && inputFileSize > 3e6); const needsResize = inputFileSize > 5e5 && (inputWidth > maxWidth || inputHeight > maxHeight); if (!unsupportedMIME && !needsRotation && !needsCompression && !needsResize) { return { action: 'none' }; } const targetMIME = getTargetMIME(inputMIME); let compressionRatio = 1; if (targetMIME === 'image/jpeg') { if (needsCompression) { compressionRatio = 0.83; } else { compressionRatio = 0.92; } } return { action: 'process', targetMIME, compressionRatio, fitInside: needsResize ? maxDimensions : null, shouldRotate: needsRotation, }; } export { getImageProcessingPlan }; diff --git a/lib/media/video-utils.js b/lib/media/video-utils.js index 58c59054b..0d8260537 100644 --- a/lib/media/video-utils.js +++ b/lib/media/video-utils.js @@ -1,146 +1,145 @@ // @flow import invariant from 'invariant'; import type { Dimensions, MediaMissionFailure } from '../types/media-types'; import { getUUID } from '../utils/uuid'; - import { replaceExtension } from './file-utils'; import { maxDimensions } from './media-utils'; const { height: maxHeight, width: maxWidth } = maxDimensions; const estimatedResultBitrate = 0.35; // in MiB/s type Input = {| inputPath: string, inputHasCorrectContainerAndCodec: boolean, inputFileSize: number, // in bytes inputFilename: string, inputDuration: number, inputDimensions: Dimensions, outputDirectory: string, outputCodec: string, clientConnectionInfo?: {| hasWiFi: boolean, speed: number, // in kilobytes per second |}, clientTranscodeSpeed?: number, // in input video seconds per second |}; type ProcessPlan = {| action: 'process', outputPath: string, ffmpegCommand: string, |}; type Plan = | {| action: 'none' |} | {| action: 'reject', failure: MediaMissionFailure |} | ProcessPlan; function getVideoProcessingPlan(input: Input): Plan { const { inputPath, inputHasCorrectContainerAndCodec, inputFileSize, inputFilename, inputDuration, inputDimensions, outputDirectory, outputCodec, clientConnectionInfo, clientTranscodeSpeed, } = input; if (inputDuration > videoDurationLimit * 60) { return { action: 'reject', failure: { success: false, reason: 'video_too_long', duration: inputDuration, }, }; } if (inputHasCorrectContainerAndCodec) { if (inputFileSize < 1e7) { return { action: 'none' }; } if (clientConnectionInfo && clientTranscodeSpeed) { const rawUploadTime = inputFileSize / 1024 / clientConnectionInfo.speed; // in seconds const transcodeTime = inputDuration / clientTranscodeSpeed; // in seconds const estimatedResultFileSize = inputDuration * estimatedResultBitrate * 1024; // in KiB const transcodedUploadTime = estimatedResultFileSize / clientConnectionInfo.speed; // in seconds const fullProcessTime = transcodeTime + transcodedUploadTime; if ( (clientConnectionInfo.hasWiFi && rawUploadTime < fullProcessTime) || (inputFileSize < 1e8 && rawUploadTime * 2 < fullProcessTime) ) { return { action: 'none' }; } } } const outputFilename = replaceExtension( `transcode.${getUUID()}.${inputFilename}`, 'mp4', ); const outputPath = `${outputDirectory}${outputFilename}`; let quality, speed, scale; if (outputCodec === 'h264') { const { floor, min, max, log2 } = Math; const crf = floor(min(5, max(0, log2(inputDuration / 5)))) + 23; quality = `-crf ${crf}`; speed = '-preset ultrafast'; scale = `-vf scale=${maxWidth}:${maxHeight}:force_original_aspect_ratio=decrease`; } else if (outputCodec === 'h264_videotoolbox') { quality = '-profile:v baseline'; speed = '-realtime 1'; const { width, height } = inputDimensions; scale = ''; const exceedsDimensions = width > maxWidth || height > maxHeight; if (exceedsDimensions && width / height > maxWidth / maxHeight) { scale = `-vf scale=${maxWidth}:-1`; } else if (exceedsDimensions) { scale = `-vf scale=-1:${maxHeight}`; } } else { invariant(false, `unrecognized outputCodec ${outputCodec}`); } const ffmpegCommand = `-i ${inputPath} ` + `-c:a copy -c:v ${outputCodec} ` + `${quality} ` + '-vsync 2 -r 30 ' + `${scale} ` + `${speed} ` + '-movflags +faststart ' + '-pix_fmt yuv420p ' + '-v quiet ' + outputPath; return { action: 'process', outputPath, ffmpegCommand }; } function getHasMultipleFramesProbeCommand(path: string) { const ffprobeCommand = '-v error ' + '-count_frames ' + '-select_streams v:0 ' + '-show_entries stream=nb_read_frames ' + '-of default=nokey=1:noprint_wrappers=1 ' + '-read_intervals "%+#2" ' + path; return ffprobeCommand; } const videoDurationLimit = 3; // in minutes export { getVideoProcessingPlan, getHasMultipleFramesProbeCommand, videoDurationLimit, }; diff --git a/lib/reducers/connection-reducer.js b/lib/reducers/connection-reducer.js index 4552e16de..8686fb198 100644 --- a/lib/reducers/connection-reducer.js +++ b/lib/reducers/connection-reducer.js @@ -1,120 +1,119 @@ // @flow import { updateActivityActionTypes } from '../actions/activity-actions'; import { updateCalendarQueryActionTypes } from '../actions/entry-actions'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, resetPasswordActionTypes, registerActionTypes, } from '../actions/user-actions'; import { queueActivityUpdatesActionType } from '../types/activity-types'; import { defaultCalendarQuery } from '../types/entry-types'; import { type BaseAction, rehydrateActionType } from '../types/redux-types'; import { type ConnectionInfo, updateConnectionStatusActionType, fullStateSyncActionType, incrementalStateSyncActionType, setLateResponseActionType, updateDisconnectedBarActionType, } from '../types/socket-types'; import { setNewSessionActionType } from '../utils/action-utils'; import { getConfig } from '../utils/config'; - import { unsupervisedBackgroundActionType } from './foreground-reducer'; export default function reduceConnectionInfo( state: ConnectionInfo, action: BaseAction, ): ConnectionInfo { if (action.type === updateConnectionStatusActionType) { return { ...state, status: action.payload.status, lateResponses: [] }; } else if (action.type === unsupervisedBackgroundActionType) { return { ...state, status: 'disconnected', lateResponses: [] }; } else if (action.type === queueActivityUpdatesActionType) { const { activityUpdates } = action.payload; return { ...state, queuedActivityUpdates: [ ...state.queuedActivityUpdates.filter((existingUpdate) => { for (let activityUpdate of activityUpdates) { if ( ((existingUpdate.focus && activityUpdate.focus) || (existingUpdate.focus === false && activityUpdate.focus !== undefined)) && existingUpdate.threadID === activityUpdate.threadID ) { return false; } } return true; }), ...activityUpdates, ], }; } else if (action.type === updateActivityActionTypes.success) { const { payload } = action; return { ...state, queuedActivityUpdates: state.queuedActivityUpdates.filter( (activityUpdate) => !payload.activityUpdates.includes(activityUpdate), ), }; } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { return { ...state, queuedActivityUpdates: [], actualizedCalendarQuery: defaultCalendarQuery( getConfig().platformDetails.platform, ), }; } else if ( action.type === logInActionTypes.success || action.type === resetPasswordActionTypes.success ) { return { ...state, actualizedCalendarQuery: action.payload.calendarResult.calendarQuery, }; } else if ( action.type === registerActionTypes.success || action.type === updateCalendarQueryActionTypes.success || action.type === fullStateSyncActionType || action.type === incrementalStateSyncActionType ) { return { ...state, actualizedCalendarQuery: action.payload.calendarQuery, }; } else if (action.type === rehydrateActionType) { if (!action.payload || !action.payload.connection) { return state; } return { ...action.payload.connection, status: 'connecting', queuedActivityUpdates: [], lateResponses: [], showDisconnectedBar: false, }; } else if (action.type === setLateResponseActionType) { const { messageID, isLate } = action.payload; const lateResponsesSet = new Set(state.lateResponses); if (isLate) { lateResponsesSet.add(messageID); } else { lateResponsesSet.delete(messageID); } return { ...state, lateResponses: [...lateResponsesSet] }; } else if (action.type === updateDisconnectedBarActionType) { return { ...state, showDisconnectedBar: action.payload.visible }; } return state; } diff --git a/lib/reducers/master-reducer.js b/lib/reducers/master-reducer.js index aac3e45db..8f5a34e60 100644 --- a/lib/reducers/master-reducer.js +++ b/lib/reducers/master-reducer.js @@ -1,78 +1,77 @@ // @flow import type { BaseNavInfo } from '../types/nav-types'; import type { BaseAppState, BaseAction } from '../types/redux-types'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types'; - import reduceCalendarFilters from './calendar-filters-reducer'; import reduceConnectionInfo from './connection-reducer'; import reduceDataLoaded from './data-loaded-reducer'; import { reduceEntryInfos } from './entry-reducer'; import reduceForeground from './foreground-reducer'; import { reduceLoadingStatuses } from './loading-reducer'; import reduceNextLocalID from './local-id-reducer'; import { reduceMessageStore } from './message-reducer'; import reduceBaseNavInfo from './nav-reducer'; import reduceQueuedReports from './report-reducer'; import reduceThreadInfos from './thread-reducer'; import reduceUpdatesCurrentAsOf from './updates-reducer'; import reduceURLPrefix from './url-prefix-reducer'; import { reduceCurrentUserInfo, reduceUserInfos } from './user-reducer'; export default function baseReducer>( state: T, action: BaseAction, ): T { const threadStore = reduceThreadInfos(state.threadStore, action); const { threadInfos } = threadStore; // Only allow checkpoints to increase if we are connected // or if the action is a STATE_SYNC let messageStore = reduceMessageStore( state.messageStore, action, threadInfos, ); let updatesCurrentAsOf = reduceUpdatesCurrentAsOf( state.updatesCurrentAsOf, action, ); const connection = reduceConnectionInfo(state.connection, action); if ( connection.status !== 'connected' && action.type !== incrementalStateSyncActionType && action.type !== fullStateSyncActionType ) { if (messageStore.currentAsOf !== state.messageStore.currentAsOf) { messageStore = { ...messageStore, currentAsOf: state.messageStore.currentAsOf, }; } if (updatesCurrentAsOf !== state.updatesCurrentAsOf) { updatesCurrentAsOf = state.updatesCurrentAsOf; } } return { ...state, navInfo: reduceBaseNavInfo(state.navInfo, action), entryStore: reduceEntryInfos(state.entryStore, action, threadInfos), loadingStatuses: reduceLoadingStatuses(state.loadingStatuses, action), currentUserInfo: reduceCurrentUserInfo(state.currentUserInfo, action), threadStore, userStore: reduceUserInfos(state.userStore, action), messageStore: reduceMessageStore(state.messageStore, action, threadInfos), updatesCurrentAsOf, urlPrefix: reduceURLPrefix(state.urlPrefix, action), calendarFilters: reduceCalendarFilters(state.calendarFilters, action), connection, foreground: reduceForeground(state.foreground, action), nextLocalID: reduceNextLocalID(state.nextLocalID, action), queuedReports: reduceQueuedReports(state.queuedReports, action), dataLoaded: reduceDataLoaded(state.dataLoaded, action), }; } diff --git a/lib/selectors/account-selectors.js b/lib/selectors/account-selectors.js index 2ec0cbe3f..a8ebeee85 100644 --- a/lib/selectors/account-selectors.js +++ b/lib/selectors/account-selectors.js @@ -1,50 +1,49 @@ // @flow import { createSelector } from 'reselect'; import type { LogInExtraInfo } from '../types/account-types'; import { isDeviceType } from '../types/device-types'; import type { CalendarQuery } from '../types/entry-types'; import type { AppState } from '../types/redux-types'; import type { PreRequestUserState } from '../types/session-types'; import type { CurrentUserInfo } from '../types/user-types'; import { getConfig } from '../utils/config'; - import { currentCalendarQuery } from './nav-selectors'; const logInExtraInfoSelector: ( state: AppState, ) => (calendarActive: boolean) => LogInExtraInfo = createSelector( (state: AppState) => state.deviceToken, currentCalendarQuery, ( deviceToken: ?string, calendarQuery: (calendarActive: boolean) => CalendarQuery, ) => { let deviceTokenUpdateRequest = null; const platform = getConfig().platformDetails.platform; if (deviceToken && isDeviceType(platform)) { deviceTokenUpdateRequest = { deviceToken }; } // Return a function since we depend on the time of evaluation return (calendarActive: boolean): LogInExtraInfo => ({ calendarQuery: calendarQuery(calendarActive), deviceTokenUpdateRequest, }); }, ); const preRequestUserStateSelector: ( state: AppState, ) => PreRequestUserState = createSelector( (state: AppState) => state.currentUserInfo, (state: AppState) => state.cookie, (state: AppState) => state.sessionID, (currentUserInfo: ?CurrentUserInfo, cookie: ?string, sessionID: ?string) => ({ currentUserInfo, cookie, sessionID, }), ); export { logInExtraInfoSelector, preRequestUserStateSelector }; diff --git a/lib/selectors/calendar-selectors.js b/lib/selectors/calendar-selectors.js index 991aae6ab..831815c29 100644 --- a/lib/selectors/calendar-selectors.js +++ b/lib/selectors/calendar-selectors.js @@ -1,86 +1,85 @@ // @flow import { createSelector } from 'reselect'; import { rawEntryInfoWithinActiveRange } from '../shared/entry-utils'; import SearchIndex from '../shared/search-index'; import { threadInFilterList } from '../shared/thread-utils'; import type { RawEntryInfo, CalendarQuery } from '../types/entry-types'; import { type FilterThreadInfo } from '../types/filter-types'; import type { BaseAppState } from '../types/redux-types'; import type { ThreadInfo } from '../types/thread-types'; import { values } from '../utils/objects'; - import { currentCalendarQuery } from './nav-selectors'; import { threadInfoSelector } from './thread-selectors'; const filterThreadInfos: ( state: BaseAppState<*>, ) => ( calendarActive: boolean, ) => $ReadOnlyArray = createSelector( threadInfoSelector, currentCalendarQuery, (state: BaseAppState<*>) => state.entryStore.entryInfos, ( threadInfos: { [id: string]: ThreadInfo }, calendarQueryFunc: (calendarActive: boolean) => CalendarQuery, rawEntryInfos: { [id: string]: RawEntryInfo }, ) => (calendarActive: boolean) => { const calendarQuery = calendarQueryFunc(calendarActive); const result: { [threadID: string]: FilterThreadInfo } = {}; for (let entryID in rawEntryInfos) { const rawEntryInfo = rawEntryInfos[entryID]; if (!rawEntryInfoWithinActiveRange(rawEntryInfo, calendarQuery)) { continue; } const threadID = rawEntryInfo.threadID; const threadInfo = threadInfos[rawEntryInfo.threadID]; if (!threadInFilterList(threadInfo)) { continue; } if (result[threadID]) { result[threadID].numVisibleEntries++; } else { result[threadID] = { threadInfo, numVisibleEntries: 1, }; } } for (let threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (!result[threadID] && threadInFilterList(threadInfo)) { result[threadID] = { threadInfo, numVisibleEntries: 0, }; } } return values(result).sort( (first: FilterThreadInfo, second: FilterThreadInfo) => second.numVisibleEntries - first.numVisibleEntries, ); }, ); const filterThreadSearchIndex: ( state: BaseAppState<*>, ) => (calendarActive: boolean) => SearchIndex = createSelector( filterThreadInfos, ( threadInfoFunc: ( calendarActive: boolean, ) => $ReadOnlyArray, ) => (calendarActive: boolean) => { const threadInfos = threadInfoFunc(calendarActive); const searchIndex = new SearchIndex(); for (const filterThreadInfo of threadInfos) { const { threadInfo } = filterThreadInfo; searchIndex.addEntry(threadInfo.id, threadInfo.uiName); } return searchIndex; }, ); export { filterThreadInfos, filterThreadSearchIndex }; diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js index 455965102..29f8f9952 100644 --- a/lib/selectors/chat-selectors.js +++ b/lib/selectors/chat-selectors.js @@ -1,357 +1,356 @@ // @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 { createSelector } from 'reselect'; import { createObjectSelector } from 'reselect-map'; import { messageKey, robotextForMessageInfo, createMessageInfo, getMostRecentNonLocalMessageID, } from '../shared/message-utils'; import { threadIsTopLevel } 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 SidebarInfo, maxReadSidebars, maxUnreadSidebars, } from '../types/thread-types'; - import { threadInfoSelector, sidebarInfoSelector } from './thread-selectors'; type SidebarItem = | {| ...SidebarInfo, +type: 'sidebar', |} | {| +type: 'seeMore', +unread: boolean, |}; export type ChatThreadItem = {| +type: 'chatThreadItem', +threadInfo: ThreadInfo, +mostRecentMessageInfo: ?MessageInfo, +mostRecentNonLocalMessage: ?string, +lastUpdatedTime: number, +lastUpdatedTimeIncludingSidebars: number, +sidebars: $ReadOnlyArray, |}; 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, }), ]), ).isRequired, }); 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 sidebarItems = []; for (const sidebar of allSidebarItems) { if (sidebarItems.length >= maxUnreadSidebars) { break; } else if (sidebar.threadInfo.currentUser.unread) { sidebarItems.push(sidebar); } else if (numReadSidebarsToShow > 0) { sidebarItems.push(sidebar); numReadSidebarsToShow--; } } if (sidebarItems.length < allSidebarItems.length) { sidebarItems.push({ type: 'seeMore', unread: numUnreadSidebars > maxUnreadSidebars, }); } 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[] => _flow( _filter(threadIsTopLevel), _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; } if (isComposableMessageType(messageInfo.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( messageInfo.type === messageTypes.TEXT || messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const localMessageInfo = messageStore.local[messageKey(messageInfo)]; chatMessageItems.push({ itemType: 'message', messageInfo, localMessageInfo, startsConversation, startsCluster, endsCluster: false, }); } else { invariant( messageInfo.type !== messageTypes.TEXT && messageInfo.type !== messageTypes.IMAGES && messageInfo.type !== messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const robotext = robotextForMessageInfo( messageInfo, threadInfos[threadID], ); chatMessageItems.push({ itemType: 'message', messageInfo, 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, }; diff --git a/lib/selectors/socket-selectors.js b/lib/selectors/socket-selectors.js index c05a67f02..92f04d0e3 100644 --- a/lib/selectors/socket-selectors.js +++ b/lib/selectors/socket-selectors.js @@ -1,180 +1,179 @@ // @flow import { createSelector } from 'reselect'; import { serverEntryInfo, serverEntryInfosObject, filterRawEntryInfosByCalendarQuery, } from '../shared/entry-utils'; import threadWatcher from '../shared/thread-watcher'; import type { RawEntryInfo, CalendarQuery } from '../types/entry-types'; import type { AppState } from '../types/redux-types'; import type { ClientThreadInconsistencyReportCreationRequest, ClientEntryInconsistencyReportCreationRequest, ClientReportCreationRequest, } from '../types/report-types'; import { serverRequestTypes, type ServerRequest, type ClientClientResponse, } from '../types/request-types'; import type { SessionState } from '../types/session-types'; import type { RawThreadInfo } from '../types/thread-types'; import type { CurrentUserInfo, UserInfos } from '../types/user-types'; import { getConfig } from '../utils/config'; import { values, hash } from '../utils/objects'; - import { currentCalendarQuery } from './nav-selectors'; const queuedReports: ( state: AppState, ) => $ReadOnlyArray = createSelector( (state: AppState) => state.threadStore.inconsistencyReports, (state: AppState) => state.entryStore.inconsistencyReports, (state: AppState) => state.queuedReports, ( threadInconsistencyReports: $ReadOnlyArray, entryInconsistencyReports: $ReadOnlyArray, mainQueuedReports: $ReadOnlyArray, ): $ReadOnlyArray => [ ...threadInconsistencyReports, ...entryInconsistencyReports, ...mainQueuedReports, ], ); const getClientResponsesSelector: ( state: AppState, ) => ( calendarActive: boolean, serverRequests: $ReadOnlyArray, ) => $ReadOnlyArray = createSelector( (state: AppState) => state.threadStore.threadInfos, (state: AppState) => state.entryStore.entryInfos, (state: AppState) => state.userStore.userInfos, (state: AppState) => state.currentUserInfo, currentCalendarQuery, ( threadInfos: { [id: string]: RawThreadInfo }, entryInfos: { [id: string]: RawEntryInfo }, userInfos: UserInfos, currentUserInfo: ?CurrentUserInfo, calendarQuery: (calendarActive: boolean) => CalendarQuery, ) => ( calendarActive: boolean, serverRequests: $ReadOnlyArray, ): $ReadOnlyArray => { const clientResponses = []; const serverRequestedPlatformDetails = serverRequests.some( (request) => request.type === serverRequestTypes.PLATFORM_DETAILS, ); for (let serverRequest of serverRequests) { if ( serverRequest.type === serverRequestTypes.PLATFORM && !serverRequestedPlatformDetails ) { clientResponses.push({ type: serverRequestTypes.PLATFORM, platform: getConfig().platformDetails.platform, }); } else if (serverRequest.type === serverRequestTypes.PLATFORM_DETAILS) { clientResponses.push({ type: serverRequestTypes.PLATFORM_DETAILS, platformDetails: getConfig().platformDetails, }); } else if (serverRequest.type === serverRequestTypes.CHECK_STATE) { const filteredEntryInfos = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(values(entryInfos)), calendarQuery(calendarActive), ); const hashResults = {}; for (let key in serverRequest.hashesToCheck) { const expectedHashValue = serverRequest.hashesToCheck[key]; let hashValue; if (key === 'threadInfos') { hashValue = hash(threadInfos); } else if (key === 'entryInfos') { hashValue = hash(filteredEntryInfos); } else if (key === 'userInfos') { hashValue = hash(userInfos); } else if (key === 'currentUserInfo') { hashValue = hash(currentUserInfo); } else if (key.startsWith('threadInfo|')) { const [, threadID] = key.split('|'); hashValue = hash(threadInfos[threadID]); } else if (key.startsWith('entryInfo|')) { const [, entryID] = key.split('|'); let rawEntryInfo = filteredEntryInfos[entryID]; if (rawEntryInfo) { rawEntryInfo = serverEntryInfo(rawEntryInfo); } hashValue = hash(rawEntryInfo); } else if (key.startsWith('userInfo|')) { const [, userID] = key.split('|'); hashValue = hash(userInfos[userID]); } else { continue; } hashResults[key] = expectedHashValue === hashValue; } const { failUnmentioned } = serverRequest; if (failUnmentioned && failUnmentioned.threadInfos) { for (let threadID in threadInfos) { const key = `threadInfo|${threadID}`; const hashResult = hashResults[key]; if (hashResult === null || hashResult === undefined) { hashResults[key] = false; } } } if (failUnmentioned && failUnmentioned.entryInfos) { for (let entryID in filteredEntryInfos) { const key = `entryInfo|${entryID}`; const hashResult = hashResults[key]; if (hashResult === null || hashResult === undefined) { hashResults[key] = false; } } } if (failUnmentioned && failUnmentioned.userInfos) { for (let userID in userInfos) { const key = `userInfo|${userID}`; const hashResult = hashResults[key]; if (hashResult === null || hashResult === undefined) { hashResults[key] = false; } } } clientResponses.push({ type: serverRequestTypes.CHECK_STATE, hashResults, }); } } return clientResponses; }, ); const sessionStateFuncSelector: ( state: AppState, ) => (calendarActive: boolean) => SessionState = createSelector( (state: AppState) => state.messageStore.currentAsOf, (state: AppState) => state.updatesCurrentAsOf, currentCalendarQuery, ( messagesCurrentAsOf: number, updatesCurrentAsOf: number, calendarQuery: (calendarActive: boolean) => CalendarQuery, ) => (calendarActive: boolean): SessionState => ({ calendarQuery: calendarQuery(calendarActive), messagesCurrentAsOf, updatesCurrentAsOf, watchedIDs: threadWatcher.getWatchedIDs(), }), ); export { queuedReports, getClientResponsesSelector, sessionStateFuncSelector }; diff --git a/lib/selectors/thread-selectors.js b/lib/selectors/thread-selectors.js index 800d253d9..670587ac3 100644 --- a/lib/selectors/thread-selectors.js +++ b/lib/selectors/thread-selectors.js @@ -1,379 +1,378 @@ // @flow import invariant from 'invariant'; import _compact from 'lodash/fp/compact'; import _filter from 'lodash/fp/filter'; import _flow from 'lodash/fp/flow'; import _map from 'lodash/fp/map'; import _mapValues from 'lodash/fp/mapValues'; import _orderBy from 'lodash/fp/orderBy'; import _some from 'lodash/fp/some'; import _sortBy from 'lodash/fp/sortBy'; import _memoize from 'lodash/memoize'; import { createSelector } from 'reselect'; import { createObjectSelector } from 'reselect-map'; import { createEntryInfo } from '../shared/entry-utils'; import { getMostRecentNonLocalMessageID } from '../shared/message-utils'; import { threadInHomeChatList, threadInBackgroundChatList, threadInFilterList, threadInfoFromRawThreadInfo, threadHasPermission, threadInChatList, threadHasAdminRole, roleIsAdminRole, getOtherMemberID, } from '../shared/thread-utils'; import type { EntryInfo } from '../types/entry-types'; import type { MessageStore, RawMessageInfo } from '../types/message-types'; import type { BaseAppState } from '../types/redux-types'; import { type ThreadInfo, type RawThreadInfo, type RelativeMemberInfo, threadPermissions, threadTypes, type SidebarInfo, } from '../types/thread-types'; import { dateString, dateFromString } from '../utils/date-utils'; import { values } from '../utils/objects'; - import { filteredThreadIDsSelector, includeDeletedSelector, } from './calendar-filter-selectors'; import { relativeMemberInfoSelectorForMembersOfThread } from './user-selectors'; const _mapValuesWithKeys = _mapValues.convert({ cap: false }); type ThreadInfoSelectorType = ( state: BaseAppState<*>, ) => { [id: string]: ThreadInfo }; const threadInfoSelector: ThreadInfoSelectorType = createObjectSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<*>) => state.userStore.userInfos, threadInfoFromRawThreadInfo, ); type ThreadSelectorType = ( possiblyPendingThread: ThreadInfo, ) => (state: BaseAppState<*>) => ?ThreadInfo; const possiblyPendingThreadInfoSelector: ThreadSelectorType = ( possiblyPendingThread: ThreadInfo, ) => createSelector( threadInfoSelector, (state: BaseAppState<*>) => state.currentUserInfo?.id, (threadInfos: { [id: string]: ThreadInfo }, currentUserID: ?string) => { const threadInfoFromState = threadInfos[possiblyPendingThread.id]; if (threadInfoFromState) { return threadInfoFromState; } if (possiblyPendingThread.type !== threadTypes.PERSONAL) { return undefined; } if (!currentUserID) { return possiblyPendingThread; } const otherMemberID = getOtherMemberID(possiblyPendingThread); if (!otherMemberID) { return possiblyPendingThread; } for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (threadInfo.type !== threadTypes.PERSONAL) { continue; } invariant( threadInfo.members.length === 2, 'Personal thread should have exactly two members', ); const members = new Set(threadInfo.members.map((member) => member.id)); if (members.has(currentUserID) && members.has(otherMemberID)) { return threadInfo; } } return possiblyPendingThread; }, ); const canBeOnScreenThreadInfos: ( state: BaseAppState<*>, ) => ThreadInfo[] = createSelector( threadInfoSelector, (threadInfos: { [id: string]: ThreadInfo }) => { const result = []; for (let threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (!threadInFilterList(threadInfo)) { continue; } result.push(threadInfo); } return result; }, ); const onScreenThreadInfos: ( state: BaseAppState<*>, ) => ThreadInfo[] = createSelector( filteredThreadIDsSelector, canBeOnScreenThreadInfos, (inputThreadIDs: ?Set, threadInfos: ThreadInfo[]) => { const threadIDs = inputThreadIDs; if (!threadIDs) { return threadInfos; } return threadInfos.filter((threadInfo) => threadIDs.has(threadInfo.id)); }, ); const onScreenEntryEditableThreadInfos: ( state: BaseAppState<*>, ) => ThreadInfo[] = createSelector( onScreenThreadInfos, (threadInfos: ThreadInfo[]) => threadInfos.filter((threadInfo) => threadHasPermission(threadInfo, threadPermissions.EDIT_ENTRIES), ), ); const entryInfoSelector: ( state: BaseAppState<*>, ) => { [id: string]: EntryInfo } = createObjectSelector( (state: BaseAppState<*>) => state.entryStore.entryInfos, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<*>) => state.userStore.userInfos, createEntryInfo, ); // "current" means within startDate/endDate range, not deleted, and in // onScreenThreadInfos const currentDaysToEntries: ( state: BaseAppState<*>, ) => { [dayString: string]: EntryInfo[] } = createSelector( entryInfoSelector, (state: BaseAppState<*>) => state.entryStore.daysToEntries, (state: BaseAppState<*>) => state.navInfo.startDate, (state: BaseAppState<*>) => state.navInfo.endDate, onScreenThreadInfos, includeDeletedSelector, ( entryInfos: { [id: string]: EntryInfo }, daysToEntries: { [day: string]: string[] }, startDateString: string, endDateString: string, onScreen: ThreadInfo[], includeDeleted: boolean, ) => { const allDaysWithinRange = {}, startDate = dateFromString(startDateString), endDate = dateFromString(endDateString); for ( const curDate = startDate; curDate <= endDate; curDate.setDate(curDate.getDate() + 1) ) { allDaysWithinRange[dateString(curDate)] = []; } return _mapValuesWithKeys((_: string[], dayString: string) => _flow( _map((entryID: string) => entryInfos[entryID]), _compact, _filter( (entryInfo: EntryInfo) => (includeDeleted || !entryInfo.deleted) && _some(['id', entryInfo.threadID])(onScreen), ), _sortBy('creationTime'), )(daysToEntries[dayString] ? daysToEntries[dayString] : []), )(allDaysWithinRange); }, ); const childThreadInfos: ( state: BaseAppState<*>, ) => { [id: string]: $ReadOnlyArray } = createSelector( threadInfoSelector, (threadInfos: { [id: string]: ThreadInfo }) => { const result = {}; for (let id in threadInfos) { const threadInfo = threadInfos[id]; const parentThreadID = threadInfo.parentThreadID; if (parentThreadID === null || parentThreadID === undefined) { continue; } if (result[parentThreadID] === undefined) { result[parentThreadID] = []; } result[parentThreadID].push(threadInfo); } return result; }, ); function getMostRecentRawMessageInfo( threadInfo: ThreadInfo, messageStore: MessageStore, ): ?RawMessageInfo { const thread = messageStore.threads[threadInfo.id]; if (!thread) { return null; } for (let messageID of thread.messageIDs) { return messageStore.messages[messageID]; } return null; } const sidebarInfoSelector: ( state: BaseAppState<*>, ) => { [id: string]: $ReadOnlyArray } = createObjectSelector( childThreadInfos, (state: BaseAppState<*>) => state.messageStore, (childThreads: $ReadOnlyArray, messageStore: MessageStore) => { const sidebarInfos = []; for (const childThreadInfo of childThreads) { if ( !threadInChatList(childThreadInfo) || childThreadInfo.type !== threadTypes.SIDEBAR ) { continue; } const mostRecentRawMessageInfo = getMostRecentRawMessageInfo( childThreadInfo, messageStore, ); const lastUpdatedTime = mostRecentRawMessageInfo?.time ?? childThreadInfo.creationTime; const mostRecentNonLocalMessage = getMostRecentNonLocalMessageID( childThreadInfo, messageStore, ); sidebarInfos.push({ threadInfo: childThreadInfo, lastUpdatedTime, mostRecentNonLocalMessage, }); } return _orderBy('lastUpdatedTime')('desc')(sidebarInfos); }, ); const unreadCount: (state: BaseAppState<*>) => number = createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos, (threadInfos: { [id: string]: RawThreadInfo }): number => values(threadInfos).filter( (threadInfo) => threadInHomeChatList(threadInfo) && threadInfo.currentUser.unread, ).length, ); const unreadBackgroundCount: ( state: BaseAppState<*>, ) => number = createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos, (threadInfos: { [id: string]: RawThreadInfo }): number => values(threadInfos).filter( (threadInfo) => threadInBackgroundChatList(threadInfo) && threadInfo.currentUser.unread, ).length, ); const baseOtherUsersButNoOtherAdmins = (threadID: string) => createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos[threadID], relativeMemberInfoSelectorForMembersOfThread(threadID), ( threadInfo: ?RawThreadInfo, members: $ReadOnlyArray, ): boolean => { if (!threadInfo) { return false; } if (!threadHasAdminRole(threadInfo)) { return false; } let otherUsersExist = false; let otherAdminsExist = false; for (let member of members) { const role = member.role; if (role === undefined || role === null || member.isViewer) { continue; } otherUsersExist = true; if (roleIsAdminRole(threadInfo?.roles[role])) { otherAdminsExist = true; break; } } return otherUsersExist && !otherAdminsExist; }, ); const otherUsersButNoOtherAdmins: ( threadID: string, ) => (state: BaseAppState<*>) => boolean = _memoize( baseOtherUsersButNoOtherAdmins, ); function mostRecentReadThread( messageStore: MessageStore, threadInfos: { [id: string]: RawThreadInfo }, ): ?string { let mostRecent = null; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (threadInfo.currentUser.unread) { continue; } const threadMessageInfo = messageStore.threads[threadID]; if (!threadMessageInfo) { continue; } const mostRecentMessageTime = threadMessageInfo.messageIDs.length === 0 ? threadInfo.creationTime : messageStore.messages[threadMessageInfo.messageIDs[0]].time; if (mostRecent && mostRecent.time >= mostRecentMessageTime) { continue; } const topLevelThreadID = threadInfo.type === threadTypes.SIDEBAR ? threadInfo.parentThreadID : threadID; mostRecent = { threadID: topLevelThreadID, time: mostRecentMessageTime }; } return mostRecent ? mostRecent.threadID : null; } const mostRecentReadThreadSelector: ( state: BaseAppState<*>, ) => ?string = createSelector( (state: BaseAppState<*>) => state.messageStore, (state: BaseAppState<*>) => state.threadStore.threadInfos, mostRecentReadThread, ); export { threadInfoSelector, onScreenThreadInfos, onScreenEntryEditableThreadInfos, entryInfoSelector, currentDaysToEntries, childThreadInfos, unreadCount, unreadBackgroundCount, otherUsersButNoOtherAdmins, mostRecentReadThread, mostRecentReadThreadSelector, possiblyPendingThreadInfoSelector, sidebarInfoSelector, }; diff --git a/lib/shared/markdown.js b/lib/shared/markdown.js index c19f3c978..341cdfeba 100644 --- a/lib/shared/markdown.js +++ b/lib/shared/markdown.js @@ -1,195 +1,194 @@ // @flow import invariant from 'invariant'; import type { RelativeMemberInfo } from '../types/thread-types'; - import { oldValidUsernameRegexString } from './account-utils'; // simple-markdown types type State = {| key?: string | number | void, inline?: ?boolean, [string]: any, |}; type Parser = (source: string, state?: ?State) => Array; type Capture = | (Array & { index: number }) | (Array & { index?: number }); type SingleASTNode = {| type: string, [string]: any, |}; type UnTypedASTNode = { [string]: any, ... }; const paragraphRegex = /^((?:[^\n]*)(?:\n|$))/; const paragraphStripTrailingNewlineRegex = /^([^\n]*)(?:\n|$)/; const headingRegex = /^ *(#{1,6}) ([^\n]+?)#* *(?![^\n])/; const headingStripFollowingNewlineRegex = /^ *(#{1,6}) ([^\n]+?)#* *(?:\n|$)/; const fenceRegex = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?\n)\1(?:\n|$)/; const fenceStripTrailingNewlineRegex = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?)\n\1(?:\n|$)/; const codeBlockRegex = /^(?: {4}[^\n]*\n*?)+(?!\n* {4}[^\n])(?:\n|$)/; const codeBlockStripTrailingNewlineRegex = /^((?: {4}[^\n]*\n*?)+)(?!\n* {4}[^\n])(?:\n|$)/; const blockQuoteRegex = /^( *>[^\n]+(?:\n[^\n]+)*)(?:\n|$)/; const blockQuoteStripFollowingNewlineRegex = /^( *>[^\n]+(?:\n[^\n]+)*)(?:\n|$){2}/; const urlRegex = /^(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/i; const mentionRegex = new RegExp(`^(@(${oldValidUsernameRegexString}))\\b`); type JSONCapture = {| +[0]: string, +json: Object, |}; function jsonMatch(source: string): ?JSONCapture { if (!source.startsWith('{')) { return null; } let jsonString = ''; let counter = 0; for (let i = 0; i < source.length; i++) { const char = source[i]; jsonString += char; if (char === '{') { counter++; } else if (char === '}') { counter--; } if (counter === 0) { break; } } if (counter !== 0) { return null; } let json; try { json = JSON.parse(jsonString); } catch { return null; } if (!json || typeof json !== 'object') { return null; } return { [0]: jsonString, json, }; } function jsonPrint(capture: JSONCapture): string { return JSON.stringify(capture.json, null, ' '); } const listRegex = /^( *)([*+-]|\d+\.) ([\s\S]+?)(?:\n{2}|\s*\n*$)/; const listItemRegex = /^( *)([*+-]|\d+\.) [^\n]*(?:\n(?!\1(?:[*+-]|\d+\.) )[^\n]*)*(\n|$)/gm; const listItemPrefixRegex = /^( *)([*+-]|\d+\.) /; const listLookBehindRegex = /(?:^|\n)( *)$/; function matchList(source: string, state: State) { if (state.inline) { return null; } const prevCaptureStr = state.prevCapture ? state.prevCapture[0] : ''; const isStartOfLineCapture = listLookBehindRegex.exec(prevCaptureStr); if (!isStartOfLineCapture) { return null; } const fullSource = isStartOfLineCapture[1] + source; return listRegex.exec(fullSource); } // We've defined our own parse function for lists because simple-markdown // handles newlines differently. Outside of that our implementation is fairly // similar. For more details about list parsing works, take a look at the // comments in the simple-markdown package function parseList( capture: Capture, parse: Parser, state: State, ): UnTypedASTNode { const bullet = capture[2]; const ordered = bullet.length > 1; const start = ordered ? Number(bullet) : undefined; const items = capture[0].match(listItemRegex); let itemContent = null; if (items) { itemContent = items.map((item: string) => { const prefixCapture = listItemPrefixRegex.exec(item); const space = prefixCapture ? prefixCapture[0].length : 0; const spaceRegex = new RegExp('^ {1,' + space + '}', 'gm'); const content: string = item .replace(spaceRegex, '') .replace(listItemPrefixRegex, ''); // We're handling this different than simple-markdown - // each item is a paragraph return parse(content, state); }); } return { ordered: ordered, start: start, items: itemContent, }; } function matchMentions(members: $ReadOnlyArray) { const memberSet = new Set( members .filter(({ role }) => role) .map(({ username }) => username?.toLowerCase()) .filter(Boolean), ); const match = (source: string, state: State) => { if (!state.inline) { return null; } const result = mentionRegex.exec(source); if (!result) { return null; } const username = result[2]; invariant(username, 'mentionRegex should match two capture groups'); if (!memberSet.has(username.toLowerCase())) { return null; } return result; }; match.regex = mentionRegex; return match; } export { paragraphRegex, paragraphStripTrailingNewlineRegex, urlRegex, blockQuoteRegex, blockQuoteStripFollowingNewlineRegex, headingRegex, headingStripFollowingNewlineRegex, codeBlockRegex, codeBlockStripTrailingNewlineRegex, fenceRegex, fenceStripTrailingNewlineRegex, jsonMatch, jsonPrint, matchList, parseList, matchMentions, }; diff --git a/lib/shared/message-utils.js b/lib/shared/message-utils.js index 2636536c8..f50586425 100644 --- a/lib/shared/message-utils.js +++ b/lib/shared/message-utils.js @@ -1,960 +1,959 @@ // @flow import invariant from 'invariant'; import _maxBy from 'lodash/fp/maxBy'; import { shimUploadURI, multimediaMessagePreview } from '../media/media-utils'; import { userIDsToRelativeUserInfos } from '../selectors/user-selectors'; import type { PlatformDetails } from '../types/device-types'; import type { Media, Image, Video } from '../types/media-types'; import { type MessageInfo, type RawMessageInfo, type RobotextMessageInfo, type PreviewableMessageInfo, type TextMessageInfo, type MediaMessageInfo, type ImagesMessageInfo, type RawMultimediaMessageInfo, type MessageData, type MessageType, type MessageTruncationStatus, type RawImagesMessageInfo, type RawMediaMessageInfo, type MultimediaMessageData, type MediaMessageData, type ImagesMessageData, type MessageStore, messageTypes, messageTruncationStatus, } from '../types/message-types'; import type { ThreadInfo } from '../types/thread-types'; import type { RelativeUserInfo, UserInfos } from '../types/user-types'; import { prettyDate } from '../utils/date-utils'; - import { codeBlockRegex } from './markdown'; import { stringForUser } from './user-utils'; import { hasMinCodeVersion } from './version-utils'; // Prefers localID function messageKey(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.localID) { return messageInfo.localID; } invariant(messageInfo.id, 'localID should exist if ID does not'); return messageInfo.id; } // Prefers serverID function messageID(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.id) { return messageInfo.id; } invariant(messageInfo.localID, 'localID should exist if ID does not'); return messageInfo.localID; } function robotextForUser(user: RelativeUserInfo): string { if (user.isViewer) { return 'you'; } else if (user.username) { return `<${encodeURI(user.username)}|u${user.id}>`; } else { return 'anonymous'; } } function robotextForUsers(users: RelativeUserInfo[]): string { if (users.length === 1) { return robotextForUser(users[0]); } else if (users.length === 2) { return `${robotextForUser(users[0])} and ${robotextForUser(users[1])}`; } else if (users.length === 3) { return ( `${robotextForUser(users[0])}, ${robotextForUser(users[1])}, ` + `and ${robotextForUser(users[2])}` ); } else { return ( `${robotextForUser(users[0])}, ${robotextForUser(users[1])}, ` + `and ${users.length - 2} others` ); } } function encodedThreadEntity(threadID: string, text: string): string { return `<${text}|t${threadID}>`; } function robotextForMessageInfo( messageInfo: RobotextMessageInfo, threadInfo: ThreadInfo, ): string { const creator = robotextForUser(messageInfo.creator); if (messageInfo.type === messageTypes.CREATE_THREAD) { let text = `created ${encodedThreadEntity( messageInfo.threadID, 'this thread', )}`; const parentThread = messageInfo.initialThreadState.parentThreadInfo; if (parentThread) { text += ' as a child of ' + `<${encodeURI(parentThread.uiName)}|t${parentThread.id}>`; } if (messageInfo.initialThreadState.name) { text += ' with the name ' + `"${encodeURI(messageInfo.initialThreadState.name)}"`; } const users = messageInfo.initialThreadState.otherMembers; if (users.length !== 0) { const initialUsersString = robotextForUsers(users); text += ` and added ${initialUsersString}`; } return `${creator} ${text}`; } else if (messageInfo.type === messageTypes.ADD_MEMBERS) { const users = messageInfo.addedMembers; invariant(users.length !== 0, 'added who??'); const addedUsersString = robotextForUsers(users); return `${creator} added ${addedUsersString}`; } else if (messageInfo.type === messageTypes.CREATE_SUB_THREAD) { const childName = messageInfo.childThreadInfo.name; if (childName) { return ( `${creator} created a child thread` + ` named "<${encodeURI(childName)}|t${messageInfo.childThreadInfo.id}>"` ); } else { return ( `${creator} created a ` + `` ); } } else if (messageInfo.type === messageTypes.CHANGE_SETTINGS) { let value; if (messageInfo.field === 'color') { value = `<#${messageInfo.value}|c${messageInfo.threadID}>`; } else { value = messageInfo.value; } return ( `${creator} updated ` + `${encodedThreadEntity(messageInfo.threadID, 'the thread')}'s ` + `${messageInfo.field} to "${value}"` ); } else if (messageInfo.type === messageTypes.REMOVE_MEMBERS) { const users = messageInfo.removedMembers; invariant(users.length !== 0, 'removed who??'); const removedUsersString = robotextForUsers(users); return `${creator} removed ${removedUsersString}`; } else if (messageInfo.type === messageTypes.CHANGE_ROLE) { const users = messageInfo.members; invariant(users.length !== 0, 'changed whose role??'); const usersString = robotextForUsers(users); const verb = threadInfo.roles[messageInfo.newRole].isDefault ? 'removed' : 'added'; const noun = users.length === 1 ? 'an admin' : 'admins'; return `${creator} ${verb} ${usersString} as ${noun}`; } else if (messageInfo.type === messageTypes.LEAVE_THREAD) { return ( `${creator} left ` + encodedThreadEntity(messageInfo.threadID, 'this thread') ); } else if (messageInfo.type === messageTypes.JOIN_THREAD) { return ( `${creator} joined ` + encodedThreadEntity(messageInfo.threadID, 'this thread') ); } else if (messageInfo.type === messageTypes.CREATE_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} created an event scheduled for ${date}: ` + `"${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.EDIT_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} updated the text of an event scheduled for ` + `${date}: "${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.DELETE_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} deleted an event scheduled for ${date}: ` + `"${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.RESTORE_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} restored an event scheduled for ${date}: ` + `"${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.UPDATE_RELATIONSHIP) { const target = robotextForUser(messageInfo.target); if (messageInfo.operation === 'request_sent') { return `${creator} sent ${target} a friend request`; } else if (messageInfo.operation === 'request_accepted') { const targetPossessive = messageInfo.target.isViewer ? 'your' : `${target}'s`; return `${creator} accepted ${targetPossessive} friend request`; } invariant( false, `Invalid operation ${messageInfo.operation} ` + `of message with type ${messageInfo.type}`, ); } else if (messageInfo.type === messageTypes.UNSUPPORTED) { return `${creator} ${messageInfo.robotext}`; } invariant(false, `we're not aware of messageType ${messageInfo.type}`); } function robotextToRawString(robotext: string): string { return decodeURI(robotext.replace(/<([^<>|]+)\|[^<>|]+>/g, '$1')); } function createMessageInfo( rawMessageInfo: RawMessageInfo, viewerID: ?string, userInfos: UserInfos, threadInfos: { [id: string]: ThreadInfo }, ): ?MessageInfo { const creatorInfo = userInfos[rawMessageInfo.creatorID]; if (!creatorInfo) { return null; } if (rawMessageInfo.type === messageTypes.TEXT) { const messageInfo: TextMessageInfo = { type: messageTypes.TEXT, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, text: rawMessageInfo.text, }; if (rawMessageInfo.id) { messageInfo.id = rawMessageInfo.id; } if (rawMessageInfo.localID) { messageInfo.localID = rawMessageInfo.localID; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.CREATE_THREAD) { const initialParentThreadID = rawMessageInfo.initialThreadState.parentThreadID; let parentThreadInfo = null; if (initialParentThreadID) { parentThreadInfo = threadInfos[initialParentThreadID]; } return { type: messageTypes.CREATE_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, initialThreadState: { name: rawMessageInfo.initialThreadState.name, parentThreadInfo, type: rawMessageInfo.initialThreadState.type, color: rawMessageInfo.initialThreadState.color, otherMembers: userIDsToRelativeUserInfos( rawMessageInfo.initialThreadState.memberIDs.filter( (userID: string) => userID !== rawMessageInfo.creatorID, ), viewerID, userInfos, ), }, }; } else if (rawMessageInfo.type === messageTypes.ADD_MEMBERS) { const addedMembers = userIDsToRelativeUserInfos( rawMessageInfo.addedUserIDs, viewerID, userInfos, ); return { type: messageTypes.ADD_MEMBERS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, addedMembers, }; } else if (rawMessageInfo.type === messageTypes.CREATE_SUB_THREAD) { const childThreadInfo = threadInfos[rawMessageInfo.childThreadID]; if (!childThreadInfo) { return null; } return { type: messageTypes.CREATE_SUB_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, childThreadInfo, }; } else if (rawMessageInfo.type === messageTypes.CHANGE_SETTINGS) { return { type: messageTypes.CHANGE_SETTINGS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, field: rawMessageInfo.field, value: rawMessageInfo.value, }; } else if (rawMessageInfo.type === messageTypes.REMOVE_MEMBERS) { const removedMembers = userIDsToRelativeUserInfos( rawMessageInfo.removedUserIDs, viewerID, userInfos, ); return { type: messageTypes.REMOVE_MEMBERS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, removedMembers, }; } else if (rawMessageInfo.type === messageTypes.CHANGE_ROLE) { const members = userIDsToRelativeUserInfos( rawMessageInfo.userIDs, viewerID, userInfos, ); return { type: messageTypes.CHANGE_ROLE, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, members, newRole: rawMessageInfo.newRole, }; } else if (rawMessageInfo.type === messageTypes.LEAVE_THREAD) { return { type: messageTypes.LEAVE_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, }; } else if (rawMessageInfo.type === messageTypes.JOIN_THREAD) { return { type: messageTypes.JOIN_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, }; } else if (rawMessageInfo.type === messageTypes.CREATE_ENTRY) { return { type: messageTypes.CREATE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.EDIT_ENTRY) { return { type: messageTypes.EDIT_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.DELETE_ENTRY) { return { type: messageTypes.DELETE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.RESTORE_ENTRY) { return { type: messageTypes.RESTORE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.UNSUPPORTED) { return { type: messageTypes.UNSUPPORTED, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, robotext: rawMessageInfo.robotext, unsupportedMessageInfo: rawMessageInfo.unsupportedMessageInfo, }; } else if (rawMessageInfo.type === messageTypes.IMAGES) { const messageInfo: ImagesMessageInfo = { type: messageTypes.IMAGES, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo.id = rawMessageInfo.id; } if (rawMessageInfo.localID) { messageInfo.localID = rawMessageInfo.localID; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { const messageInfo: MediaMessageInfo = { type: messageTypes.MULTIMEDIA, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo.id = rawMessageInfo.id; } if (rawMessageInfo.localID) { messageInfo.localID = rawMessageInfo.localID; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.UPDATE_RELATIONSHIP) { const target = userInfos[rawMessageInfo.targetID]; if (!target) { return null; } return { type: messageTypes.UPDATE_RELATIONSHIP, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, target: { id: target.id, username: target.username, isViewer: target.id === viewerID, }, time: rawMessageInfo.time, operation: rawMessageInfo.operation, }; } invariant(false, `we're not aware of messageType ${rawMessageInfo.type}`); } function sortMessageInfoList( messageInfos: T[], ): T[] { return messageInfos.sort((a: T, b: T) => b.time - a.time); } function rawMessageInfoFromMessageData( messageData: MessageData, id: string, ): RawMessageInfo { if (messageData.type === messageTypes.TEXT) { return { ...messageData, id }; } else if (messageData.type === messageTypes.CREATE_THREAD) { return { ...messageData, id }; } else if (messageData.type === messageTypes.ADD_MEMBERS) { return { ...messageData, id }; } else if (messageData.type === messageTypes.CREATE_SUB_THREAD) { return { ...messageData, id }; } else if (messageData.type === messageTypes.CHANGE_SETTINGS) { return { ...messageData, id }; } else if (messageData.type === messageTypes.REMOVE_MEMBERS) { return { ...messageData, id }; } else if (messageData.type === messageTypes.CHANGE_ROLE) { return { ...messageData, id }; } else if (messageData.type === messageTypes.LEAVE_THREAD) { return { type: messageTypes.LEAVE_THREAD, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, }; } else if (messageData.type === messageTypes.JOIN_THREAD) { return { type: messageTypes.JOIN_THREAD, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, }; } else if (messageData.type === messageTypes.CREATE_ENTRY) { return { type: messageTypes.CREATE_ENTRY, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, entryID: messageData.entryID, date: messageData.date, text: messageData.text, }; } else if (messageData.type === messageTypes.EDIT_ENTRY) { return { type: messageTypes.EDIT_ENTRY, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, entryID: messageData.entryID, date: messageData.date, text: messageData.text, }; } else if (messageData.type === messageTypes.DELETE_ENTRY) { return { type: messageTypes.DELETE_ENTRY, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, entryID: messageData.entryID, date: messageData.date, text: messageData.text, }; } else if (messageData.type === messageTypes.RESTORE_ENTRY) { return { type: messageTypes.RESTORE_ENTRY, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, entryID: messageData.entryID, date: messageData.date, text: messageData.text, }; } else if (messageData.type === messageTypes.IMAGES) { return ({ ...messageData, id }: RawImagesMessageInfo); } else if (messageData.type === messageTypes.MULTIMEDIA) { return ({ ...messageData, id }: RawMediaMessageInfo); } else if (messageData.type === messageTypes.UPDATE_RELATIONSHIP) { return { ...messageData, id }; } else { invariant(false, `we're not aware of messageType ${messageData.type}`); } } function mostRecentMessageTimestamp( messageInfos: RawMessageInfo[], previousTimestamp: number, ): number { if (messageInfos.length === 0) { return previousTimestamp; } return _maxBy('time')(messageInfos).time; } function messageTypeGeneratesNotifs(type: MessageType) { return ( type !== messageTypes.JOIN_THREAD && type !== messageTypes.LEAVE_THREAD && type !== messageTypes.ADD_MEMBERS && type !== messageTypes.REMOVE_MEMBERS ); } function splitRobotext(robotext: string) { return robotext.split(/(<[^<>|]+\|[^<>|]+>)/g); } const robotextEntityRegex = /<([^<>|]+)\|([^<>|]+)>/; function parseRobotextEntity(robotextPart: string) { const entityParts = robotextPart.match(robotextEntityRegex); invariant(entityParts && entityParts[1], 'malformed robotext'); const rawText = decodeURI(entityParts[1]); const entityType = entityParts[2].charAt(0); const id = entityParts[2].substr(1); return { rawText, entityType, id }; } function usersInMessageInfos( messageInfos: $ReadOnlyArray, ): string[] { const userIDs = new Set(); for (let messageInfo of messageInfos) { if (messageInfo.creatorID) { userIDs.add(messageInfo.creatorID); } else if (messageInfo.creator) { userIDs.add(messageInfo.creator.id); } } return [...userIDs]; } function combineTruncationStatuses( first: MessageTruncationStatus, second: ?MessageTruncationStatus, ): MessageTruncationStatus { if ( first === messageTruncationStatus.EXHAUSTIVE || second === messageTruncationStatus.EXHAUSTIVE ) { return messageTruncationStatus.EXHAUSTIVE; } else if ( first === messageTruncationStatus.UNCHANGED && second !== null && second !== undefined ) { return second; } else { return first; } } function shimUnsupportedRawMessageInfos( rawMessageInfos: $ReadOnlyArray, platformDetails: ?PlatformDetails, ): RawMessageInfo[] { if (platformDetails && platformDetails.platform === 'web') { return [...rawMessageInfos]; } return rawMessageInfos.map((rawMessageInfo) => { if (rawMessageInfo.type === messageTypes.IMAGES) { const shimmedRawMessageInfo = shimMediaMessageInfo( rawMessageInfo, platformDetails, ); if (hasMinCodeVersion(platformDetails, 30)) { return shimmedRawMessageInfo; } const { id } = shimmedRawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: shimmedRawMessageInfo.threadID, creatorID: shimmedRawMessageInfo.creatorID, time: shimmedRawMessageInfo.time, robotext: multimediaMessagePreview(shimmedRawMessageInfo), unsupportedMessageInfo: shimmedRawMessageInfo, }; } else if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { const shimmedRawMessageInfo = shimMediaMessageInfo( rawMessageInfo, platformDetails, ); // TODO figure out first native codeVersion supporting video playback if (hasMinCodeVersion(platformDetails, 62)) { return shimmedRawMessageInfo; } const { id } = shimmedRawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: shimmedRawMessageInfo.threadID, creatorID: shimmedRawMessageInfo.creatorID, time: shimmedRawMessageInfo.time, robotext: multimediaMessagePreview(shimmedRawMessageInfo), unsupportedMessageInfo: shimmedRawMessageInfo, }; } return rawMessageInfo; }); } function shimMediaMessageInfo( rawMessageInfo: RawMultimediaMessageInfo, platformDetails: ?PlatformDetails, ): RawMultimediaMessageInfo { if (rawMessageInfo.type === messageTypes.IMAGES) { let uriChanged = false; const newMedia: Image[] = []; for (let singleMedia of rawMessageInfo.media) { const shimmedURI = shimUploadURI(singleMedia.uri, platformDetails); if (shimmedURI === singleMedia.uri) { newMedia.push(singleMedia); } else { newMedia.push(({ ...singleMedia, uri: shimmedURI }: Image)); uriChanged = true; } } if (!uriChanged) { return rawMessageInfo; } return ({ ...rawMessageInfo, media: newMedia, }: RawImagesMessageInfo); } else { let uriChanged = false; const newMedia: Media[] = []; for (let singleMedia of rawMessageInfo.media) { const shimmedURI = shimUploadURI(singleMedia.uri, platformDetails); if (shimmedURI === singleMedia.uri) { newMedia.push(singleMedia); } else if (singleMedia.type === 'photo') { newMedia.push(({ ...singleMedia, uri: shimmedURI }: Image)); uriChanged = true; } else { newMedia.push(({ ...singleMedia, uri: shimmedURI }: Video)); uriChanged = true; } } if (!uriChanged) { return rawMessageInfo; } return ({ ...rawMessageInfo, media: newMedia, }: RawMediaMessageInfo); } } function messagePreviewText( messageInfo: PreviewableMessageInfo, threadInfo: ThreadInfo, ): string { if ( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA ) { const creator = stringForUser(messageInfo.creator); const preview = multimediaMessagePreview(messageInfo); return `${creator} ${preview}`; } return robotextToRawString(robotextForMessageInfo(messageInfo, threadInfo)); } type MediaMessageDataCreationInput = $ReadOnly<{ threadID: string, creatorID: string, media: $ReadOnlyArray, localID?: ?string, time?: ?number, ... }>; function createMediaMessageData( input: MediaMessageDataCreationInput, ): MultimediaMessageData { let allMediaArePhotos = true; const photoMedia = []; for (let singleMedia of input.media) { if (singleMedia.type === 'video') { allMediaArePhotos = false; break; } else { photoMedia.push(singleMedia); } } const { localID, threadID, creatorID } = input; const time = input.time ? input.time : Date.now(); let messageData; if (allMediaArePhotos) { messageData = ({ type: messageTypes.IMAGES, threadID, creatorID, time, media: photoMedia, }: ImagesMessageData); } else { messageData = ({ type: messageTypes.MULTIMEDIA, threadID, creatorID, time, media: input.media, }: MediaMessageData); } if (localID) { messageData.localID = localID; } return messageData; } type MediaMessageInfoCreationInput = $ReadOnly<{ ...$Exact, id?: ?string, }>; function createMediaMessageInfo( input: MediaMessageInfoCreationInput, ): RawMultimediaMessageInfo { const messageData = createMediaMessageData(input); // This conditional is for Flow let rawMessageInfo; if (messageData.type === messageTypes.IMAGES) { rawMessageInfo = ({ ...messageData, type: messageTypes.IMAGES, }: RawImagesMessageInfo); } else { rawMessageInfo = ({ ...messageData, type: messageTypes.MULTIMEDIA, }: RawMediaMessageInfo); } if (input.id) { rawMessageInfo.id = input.id; } return rawMessageInfo; } function stripLocalIDs( input: $ReadOnlyArray, ): RawMessageInfo[] { const output = []; for (let rawMessageInfo of input) { if ( rawMessageInfo.localID === null || rawMessageInfo.localID === undefined ) { output.push(rawMessageInfo); continue; } invariant( rawMessageInfo.id, 'serverID should be set if localID is being stripped', ); if (rawMessageInfo.type === messageTypes.TEXT) { const { localID, ...rest } = rawMessageInfo; output.push({ ...rest }); } else if (rawMessageInfo.type === messageTypes.IMAGES) { const { localID, ...rest } = rawMessageInfo; output.push(({ ...rest }: RawImagesMessageInfo)); } else if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { const { localID, ...rest } = rawMessageInfo; output.push(({ ...rest }: RawMediaMessageInfo)); } else { invariant( false, `message ${rawMessageInfo.id} of type ${rawMessageInfo.type} ` + `unexpectedly has localID`, ); } } return output; } // Normally we call trim() to remove whitespace at the beginning and end of each // message. However, our Markdown parser supports a "codeBlock" format where the // user can indent each line to indicate a code block. If we match the // corresponding RegEx, we'll only trim whitespace off the end. function trimMessage(message: string) { message = message.replace(/^\n*/, ''); return codeBlockRegex.exec(message) ? message.trimEnd() : message.trim(); } function createMessageReply(message: string) { // add `>` to each line to include empty lines in the quote const quotedMessage = message.replace(/^/gm, '> '); return quotedMessage + '\n\n'; } function getMostRecentNonLocalMessageID( threadInfo: ThreadInfo, messageStore: MessageStore, ): ?string { const thread = messageStore.threads[threadInfo.id]; return thread?.messageIDs.find((id) => !id.startsWith('local')); } export { messageKey, messageID, robotextForMessageInfo, robotextToRawString, createMessageInfo, sortMessageInfoList, rawMessageInfoFromMessageData, mostRecentMessageTimestamp, messageTypeGeneratesNotifs, splitRobotext, parseRobotextEntity, usersInMessageInfos, combineTruncationStatuses, shimUnsupportedRawMessageInfos, messagePreviewText, createMediaMessageData, createMediaMessageInfo, stripLocalIDs, trimMessage, createMessageReply, getMostRecentNonLocalMessageID, }; diff --git a/lib/shared/notif-utils.js b/lib/shared/notif-utils.js index ff4d203b2..d59bf539d 100644 --- a/lib/shared/notif-utils.js +++ b/lib/shared/notif-utils.js @@ -1,532 +1,531 @@ // @flow import invariant from 'invariant'; import { contentStringForMediaArray } from '../media/media-utils'; import { type MessageInfo, type RawMessageInfo, type RobotextMessageInfo, type MessageType, messageTypes, } from '../types/message-types'; import type { ThreadInfo } from '../types/thread-types'; import type { RelativeUserInfo } from '../types/user-types'; import { prettyDate } from '../utils/date-utils'; import { values } from '../utils/objects'; import { pluralize } from '../utils/text-utils'; - import { robotextForMessageInfo, robotextToRawString } from './message-utils'; import { threadIsGroupChat } from './thread-utils'; import { stringForUser } from './user-utils'; type NotifTexts = {| merged: string, body: string, title: string, prefix?: string, |}; function notifTextsForMessageInfo( messageInfos: MessageInfo[], threadInfo: ThreadInfo, ): NotifTexts { const fullNotifTexts = fullNotifTextsForMessageInfo(messageInfos, threadInfo); const result: NotifTexts = { merged: trimNotifText(fullNotifTexts.merged, 300), body: trimNotifText(fullNotifTexts.body, 300), title: trimNotifText(fullNotifTexts.title, 100), }; if (fullNotifTexts.prefix) { result.prefix = trimNotifText(fullNotifTexts.prefix, 50); } return result; } function trimNotifText(text: string, maxLength: number): string { if (text.length <= maxLength) { return text; } return text.substr(0, maxLength - 3) + '...'; } const notifTextForSubthreadCreation = ( creator: RelativeUserInfo, parentThreadInfo: ThreadInfo, childThreadName: ?string, childThreadUIName: string, ) => { const prefix = stringForUser(creator); let body = `created a new thread`; if (parentThreadInfo.name) { body += ` in ${parentThreadInfo.name}`; } let merged = `${prefix} ${body}`; if (childThreadName) { merged += ` called "${childThreadName}"`; } return { merged, body, title: childThreadUIName, prefix, }; }; function notifThreadName(threadInfo: ThreadInfo): string { if (threadInfo.name) { return threadInfo.name; } else { return 'your thread'; } } function assertSingleMessageInfo( messageInfos: $ReadOnlyArray, ): MessageInfo { if (messageInfos.length === 0) { throw new Error('expected single MessageInfo, but none present!'); } else if (messageInfos.length !== 1) { const messageIDs = messageInfos.map((messageInfo) => messageInfo.id); console.log( 'expected single MessageInfo, but there are multiple! ' + messageIDs.join(', '), ); } return messageInfos[0]; } function mostRecentMessageInfoType( messageInfos: $ReadOnlyArray, ): MessageType { if (messageInfos.length === 0) { throw new Error('expected MessageInfo, but none present!'); } return messageInfos[0].type; } function fullNotifTextsForMessageInfo( messageInfos: MessageInfo[], threadInfo: ThreadInfo, ): NotifTexts { const mostRecentType = mostRecentMessageInfoType(messageInfos); if (mostRecentType === messageTypes.TEXT) { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.TEXT, 'messageInfo should be messageTypes.TEXT!', ); if (!threadInfo.name && !threadIsGroupChat(threadInfo)) { return { merged: `${threadInfo.uiName}: ${messageInfo.text}`, body: messageInfo.text, title: threadInfo.uiName, }; } else { const userString = stringForUser(messageInfo.creator); const threadName = notifThreadName(threadInfo); return { merged: `${userString} to ${threadName}: ${messageInfo.text}`, body: messageInfo.text, title: threadInfo.uiName, prefix: `${userString}:`, }; } } else if (mostRecentType === messageTypes.CREATE_THREAD) { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.CREATE_THREAD, 'messageInfo should be messageTypes.CREATE_THREAD!', ); const parentThreadInfo = messageInfo.initialThreadState.parentThreadInfo; if (parentThreadInfo) { return notifTextForSubthreadCreation( messageInfo.creator, parentThreadInfo, messageInfo.initialThreadState.name, threadInfo.uiName, ); } const prefix = stringForUser(messageInfo.creator); const body = 'created a new thread'; let merged = `${prefix} ${body}`; if (messageInfo.initialThreadState.name) { merged += ` called "${messageInfo.initialThreadState.name}"`; } return { merged, body, title: threadInfo.uiName, prefix, }; } else if (mostRecentType === messageTypes.ADD_MEMBERS) { const addedMembersObject = {}; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.ADD_MEMBERS, 'messageInfo should be messageTypes.ADD_MEMBERS!', ); for (let member of messageInfo.addedMembers) { addedMembersObject[member.id] = member; } } const addedMembers = values(addedMembersObject); const mostRecentMessageInfo = messageInfos[0]; invariant( mostRecentMessageInfo.type === messageTypes.ADD_MEMBERS, 'messageInfo should be messageTypes.ADD_MEMBERS!', ); const mergedMessageInfo = { ...mostRecentMessageInfo, addedMembers }; const robotext = strippedRobotextForMessageInfo( mergedMessageInfo, threadInfo, ); const merged = `${robotext} to ${notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body: robotext, }; } else if (mostRecentType === messageTypes.CREATE_SUB_THREAD) { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.CREATE_SUB_THREAD, 'messageInfo should be messageTypes.CREATE_SUB_THREAD!', ); return notifTextForSubthreadCreation( messageInfo.creator, threadInfo, messageInfo.childThreadInfo.name, messageInfo.childThreadInfo.uiName, ); } else if (mostRecentType === messageTypes.REMOVE_MEMBERS) { const removedMembersObject = {}; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.REMOVE_MEMBERS, 'messageInfo should be messageTypes.REMOVE_MEMBERS!', ); for (let member of messageInfo.removedMembers) { removedMembersObject[member.id] = member; } } const removedMembers = values(removedMembersObject); const mostRecentMessageInfo = messageInfos[0]; invariant( mostRecentMessageInfo.type === messageTypes.REMOVE_MEMBERS, 'messageInfo should be messageTypes.REMOVE_MEMBERS!', ); const mergedMessageInfo = { ...mostRecentMessageInfo, removedMembers }; const robotext = strippedRobotextForMessageInfo( mergedMessageInfo, threadInfo, ); const merged = `${robotext} from ${notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body: robotext, }; } else if (mostRecentType === messageTypes.CHANGE_ROLE) { const membersObject = {}; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.CHANGE_ROLE, 'messageInfo should be messageTypes.CHANGE_ROLE!', ); for (let member of messageInfo.members) { membersObject[member.id] = member; } } const members = values(membersObject); const mostRecentMessageInfo = messageInfos[0]; invariant( mostRecentMessageInfo.type === messageTypes.CHANGE_ROLE, 'messageInfo should be messageTypes.CHANGE_ROLE!', ); const mergedMessageInfo = { ...mostRecentMessageInfo, members }; const robotext = strippedRobotextForMessageInfo( mergedMessageInfo, threadInfo, ); const merged = `${robotext} from ${notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body: robotext, }; } else if (mostRecentType === messageTypes.LEAVE_THREAD) { const leaverBeavers = {}; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.LEAVE_THREAD, 'messageInfo should be messageTypes.LEAVE_THREAD!', ); leaverBeavers[messageInfo.creator.id] = messageInfo.creator; } const leavers = values(leaverBeavers); const leaversString = pluralize(leavers.map(stringForUser)); const body = `${leaversString} left`; const merged = `${body} ${notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body, }; } else if (mostRecentType === messageTypes.JOIN_THREAD) { const joinerArray = {}; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.JOIN_THREAD, 'messageInfo should be messageTypes.JOIN_THREAD!', ); joinerArray[messageInfo.creator.id] = messageInfo.creator; } const joiners = values(joinerArray); const joinersString = pluralize(joiners.map(stringForUser)); const body = `${joinersString} joined`; const merged = `${body} ${notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body, }; } else if ( mostRecentType === messageTypes.CREATE_ENTRY || mostRecentType === messageTypes.EDIT_ENTRY ) { const hasCreateEntry = messageInfos.some( (messageInfo) => messageInfo.type === messageTypes.CREATE_ENTRY, ); const messageInfo = messageInfos[0]; if (!hasCreateEntry) { invariant( messageInfo.type === messageTypes.EDIT_ENTRY, 'messageInfo should be messageTypes.EDIT_ENTRY!', ); const body = `updated the text of an event in ` + `${notifThreadName(threadInfo)} scheduled for ` + `${prettyDate(messageInfo.date)}: "${messageInfo.text}"`; const prefix = stringForUser(messageInfo.creator); const merged = `${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; } invariant( messageInfo.type === messageTypes.CREATE_ENTRY || messageInfo.type === messageTypes.EDIT_ENTRY, 'messageInfo should be messageTypes.CREATE_ENTRY/EDIT_ENTRY!', ); const prefix = stringForUser(messageInfo.creator); const body = `created an event in ${notifThreadName(threadInfo)} ` + `scheduled for ${prettyDate(messageInfo.date)}: "${messageInfo.text}"`; const merged = `${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; } else if (mostRecentType === messageTypes.DELETE_ENTRY) { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.DELETE_ENTRY, 'messageInfo should be messageTypes.DELETE_ENTRY!', ); const prefix = stringForUser(messageInfo.creator); const body = `deleted an event in ${notifThreadName(threadInfo)} ` + `scheduled for ${prettyDate(messageInfo.date)}: "${messageInfo.text}"`; const merged = `${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; } else if (mostRecentType === messageTypes.RESTORE_ENTRY) { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.RESTORE_ENTRY, 'messageInfo should be messageTypes.RESTORE_ENTRY!', ); const prefix = stringForUser(messageInfo.creator); const body = `restored an event in ${notifThreadName(threadInfo)} ` + `scheduled for ${prettyDate(messageInfo.date)}: "${messageInfo.text}"`; const merged = `${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; } else if (mostRecentType === messageTypes.CHANGE_SETTINGS) { const mostRecentMessageInfo = messageInfos[0]; invariant( mostRecentMessageInfo.type === messageTypes.CHANGE_SETTINGS, 'messageInfo should be messageTypes.CHANGE_SETTINGS!', ); const body = strippedRobotextForMessageInfo( mostRecentMessageInfo, threadInfo, ); return { merged: body, title: threadInfo.uiName, body, }; } else if ( mostRecentType === messageTypes.IMAGES || mostRecentType === messageTypes.MULTIMEDIA ) { const media = []; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA, 'messageInfo should be multimedia type!', ); for (let singleMedia of messageInfo.media) { media.push(singleMedia); } } const contentString = contentStringForMediaArray(media); const userString = stringForUser(messageInfos[0].creator); let body, merged; if (!threadInfo.name && !threadIsGroupChat(threadInfo)) { body = `sent you ${contentString}`; merged = body; } else { body = `sent ${contentString}`; const threadName = notifThreadName(threadInfo); merged = `${body} to ${threadName}`; } merged = `${userString} ${merged}`; return { merged, body, title: threadInfo.uiName, prefix: userString, }; } else if (mostRecentType === messageTypes.UPDATE_RELATIONSHIP) { const messageInfo = assertSingleMessageInfo(messageInfos); const prefix = stringForUser(messageInfo.creator); const title = threadInfo.uiName; const body = messageInfo.operation === 'request_sent' ? 'sent you a friend request' : 'accepted your friend request'; const merged = `${prefix} ${body}`; return { merged, body, title, prefix, }; } else { invariant(false, `we're not aware of messageType ${mostRecentType}`); } } function strippedRobotextForMessageInfo( messageInfo: RobotextMessageInfo, threadInfo: ThreadInfo, ): string { const robotext = robotextForMessageInfo(messageInfo, threadInfo); const threadEntityRegex = new RegExp(`<[^<>\\|]+\\|t${threadInfo.id}>`); const threadMadeExplicit = robotext.replace( threadEntityRegex, notifThreadName(threadInfo), ); return robotextToRawString(threadMadeExplicit); } const joinResult = (...keys: (string | number)[]) => keys.join('|'); function notifCollapseKeyForRawMessageInfo( rawMessageInfo: RawMessageInfo, ): ?string { if ( rawMessageInfo.type === messageTypes.ADD_MEMBERS || rawMessageInfo.type === messageTypes.REMOVE_MEMBERS ) { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, ); } else if ( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA ) { // We use the legacy constant here to collapse both types into one return joinResult( messageTypes.IMAGES, rawMessageInfo.threadID, rawMessageInfo.creatorID, ); } else if (rawMessageInfo.type === messageTypes.CHANGE_SETTINGS) { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, rawMessageInfo.field, ); } else if (rawMessageInfo.type === messageTypes.CHANGE_ROLE) { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, rawMessageInfo.newRole, ); } else if ( rawMessageInfo.type === messageTypes.JOIN_THREAD || rawMessageInfo.type === messageTypes.LEAVE_THREAD ) { return joinResult(rawMessageInfo.type, rawMessageInfo.threadID); } else if ( rawMessageInfo.type === messageTypes.CREATE_ENTRY || rawMessageInfo.type === messageTypes.EDIT_ENTRY ) { return joinResult(rawMessageInfo.creatorID, rawMessageInfo.entryID); } else { return null; } } type Unmerged = $ReadOnly<{ body: string, title: string, prefix?: string, ... }>; type Merged = {| body: string, title: string, |}; function mergePrefixIntoBody(unmerged: Unmerged): Merged { const { body, title, prefix } = unmerged; const merged = prefix ? `${prefix} ${body}` : body; return { body: merged, title }; } export { notifTextsForMessageInfo, notifCollapseKeyForRawMessageInfo, mergePrefixIntoBody, }; diff --git a/lib/shared/search-utils.js b/lib/shared/search-utils.js index ee188c2d1..385543ccc 100644 --- a/lib/shared/search-utils.js +++ b/lib/shared/search-utils.js @@ -1,95 +1,94 @@ // @flow import { userRelationshipStatus } from '../types/relationship-types'; import type { ThreadInfo } from '../types/thread-types'; import type { AccountUserInfo, UserListItem } from '../types/user-types'; - import SearchIndex from './search-index'; import { userIsMember } from './thread-utils'; function getPotentialMemberItems( text: string, userInfos: { [id: string]: AccountUserInfo }, searchIndex: SearchIndex, excludeUserIDs: $ReadOnlyArray, parentThreadInfo: ?ThreadInfo, ): UserListItem[] { let results = []; const appendUserInfo = (userInfo: AccountUserInfo) => { if (!excludeUserIDs.includes(userInfo.id)) { results.push({ ...userInfo, isMemberOfParentThread: userIsMember(parentThreadInfo, userInfo.id), }); } }; if (text === '') { for (const id in userInfos) { appendUserInfo(userInfos[id]); } } else { const ids = searchIndex.getSearchResults(text); for (const id of ids) { appendUserInfo(userInfos[id]); } } if (text === '') { results = results.filter((userInfo) => parentThreadInfo ? userInfo.isMemberOfParentThread : userInfo.relationshipStatus === userRelationshipStatus.FRIEND, ); } const nonFriends = []; const blockedUsers = []; const friendsAndParentMembers = []; for (const userResult of results) { const relationshipStatus = userResult.relationshipStatus; if (userResult.isMemberOfParentThread) { friendsAndParentMembers.unshift(userResult); } else if (relationshipStatus === userRelationshipStatus.FRIEND) { friendsAndParentMembers.push(userResult); } else if ( relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ) { blockedUsers.push(userResult); } else { nonFriends.push(userResult); } } const sortedResults = friendsAndParentMembers .concat(nonFriends) .concat(blockedUsers); return sortedResults.map( ({ isMemberOfParentThread, relationshipStatus, ...result }) => { if (isMemberOfParentThread) { return { ...result }; } let notice, alertText; const userText = result.username; if (relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER) { notice = "you've blocked this user"; alertText = `Before you add ${userText} to this thread, ` + `you'll need to unblock them and send a friend request. ` + `You can do this from the Block List and Friend List in the More tab.`; } else if (relationshipStatus !== userRelationshipStatus.FRIEND) { notice = 'not friend'; alertText = `Before you add ${userText} to this thread, ` + `you'll need to send them a friend request. ` + `You can do this from the Friend List in the More tab.`; } else if (parentThreadInfo) { notice = 'not in parent thread'; } return { ...result, notice, alertText }; }, ); } export { getPotentialMemberItems }; diff --git a/lib/socket/api-request-handler.react.js b/lib/socket/api-request-handler.react.js index 7c8786553..11c3cdfc4 100644 --- a/lib/socket/api-request-handler.react.js +++ b/lib/socket/api-request-handler.react.js @@ -1,102 +1,101 @@ // @flow import invariant from 'invariant'; import PropTypes from 'prop-types'; import * as React from 'react'; import type { APIRequest } from '../types/endpoints'; import type { BaseAppState } from '../types/redux-types'; import { clientSocketMessageTypes, serverSocketMessageTypes, type ClientSocketMessageWithoutID, type ConnectionInfo, connectionInfoPropType, } from '../types/socket-types'; import { registerActiveSocket } from '../utils/action-utils'; import { connect } from '../utils/redux-utils'; - import { InflightRequests, SocketOffline } from './inflight-requests'; type Props = {| inflightRequests: ?InflightRequests, sendMessage: (message: ClientSocketMessageWithoutID) => number, // Redux state connection: ConnectionInfo, |}; class APIRequestHandler extends React.PureComponent { static propTypes = { inflightRequests: PropTypes.object, sendMessage: PropTypes.func.isRequired, connection: connectionInfoPropType.isRequired, }; static isConnected(props: Props, request?: APIRequest) { const { inflightRequests, connection } = props; if (!inflightRequests) { return false; } // This is a hack. We actually have a race condition between // ActivityHandler and Socket. Both of them respond to a backgrounding, but // we want ActivityHandler to go first. Once it sends its message, Socket // will wait for the response before shutting down. But if Socket starts // shutting down first, we'll have a problem. Note that this approach only // stops the race in fetchResponse below, and not in action-utils (which // happens earlier via the registerActiveSocket call below), but empircally // that hasn't been an issue. // The reason I didn't rewrite this to happen in a single component is // because I want to maintain separation of concerns. Upcoming React Hooks // will be a great way to rewrite them to be related but still separated. return ( connection.status === 'connected' || (request && request.endpoint === 'update_activity') ); } get registeredResponseFetcher() { return APIRequestHandler.isConnected(this.props) ? this.fetchResponse : null; } componentDidMount() { registerActiveSocket(this.registeredResponseFetcher); } componentWillUnmount() { registerActiveSocket(null); } componentDidUpdate(prevProps: Props) { const isConnected = APIRequestHandler.isConnected(this.props); const wasConnected = APIRequestHandler.isConnected(prevProps); if (isConnected !== wasConnected) { registerActiveSocket(this.registeredResponseFetcher); } } render() { return null; } fetchResponse = async (request: APIRequest): Promise => { if (!APIRequestHandler.isConnected(this.props, request)) { throw new SocketOffline('socket_offline'); } const { inflightRequests } = this.props; invariant(inflightRequests, 'inflightRequests falsey inside fetchResponse'); const messageID = this.props.sendMessage({ type: clientSocketMessageTypes.API_REQUEST, payload: request, }); const response = await inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.API_RESPONSE, ); return response.payload; }; } export default connect((state: BaseAppState<*>) => ({ connection: state.connection, }))(APIRequestHandler); diff --git a/lib/socket/request-response-handler.react.js b/lib/socket/request-response-handler.react.js index ad323a452..6d7a8da45 100644 --- a/lib/socket/request-response-handler.react.js +++ b/lib/socket/request-response-handler.react.js @@ -1,151 +1,150 @@ // @flow import invariant from 'invariant'; import PropTypes from 'prop-types'; import * as React from 'react'; import type { CalendarQuery } from '../types/entry-types'; import type { AppState } from '../types/redux-types'; import { processServerRequestsActionType, type ClientClientResponse, type ServerRequest, } from '../types/request-types'; import { type RequestsServerSocketMessage, type ServerSocketMessage, clientSocketMessageTypes, serverSocketMessageTypes, type ClientSocketMessageWithoutID, type SocketListener, type ConnectionInfo, connectionInfoPropType, } from '../types/socket-types'; import type { DispatchActionPayload } from '../utils/action-utils'; import { ServerError } from '../utils/errors'; import { connect } from '../utils/redux-utils'; - import { InflightRequests, SocketTimeout } from './inflight-requests'; type Props = {| inflightRequests: ?InflightRequests, sendMessage: (message: ClientSocketMessageWithoutID) => number, addListener: (listener: SocketListener) => void, removeListener: (listener: SocketListener) => void, getClientResponses: ( activeServerRequests: $ReadOnlyArray, ) => $ReadOnlyArray, currentCalendarQuery: () => CalendarQuery, // Redux state connection: ConnectionInfo, // Redux dispatch functions dispatchActionPayload: DispatchActionPayload, |}; class RequestResponseHandler extends React.PureComponent { static propTypes = { inflightRequests: PropTypes.object, sendMessage: PropTypes.func.isRequired, addListener: PropTypes.func.isRequired, removeListener: PropTypes.func.isRequired, getClientResponses: PropTypes.func.isRequired, currentCalendarQuery: PropTypes.func.isRequired, connection: connectionInfoPropType.isRequired, dispatchActionPayload: PropTypes.func.isRequired, }; componentDidMount() { this.props.addListener(this.onMessage); } componentWillUnmount() { this.props.removeListener(this.onMessage); } render() { return null; } onMessage = (message: ServerSocketMessage) => { if (message.type !== serverSocketMessageTypes.REQUESTS) { return; } const { serverRequests } = message.payload; if (serverRequests.length === 0) { return; } const calendarQuery = this.props.currentCalendarQuery(); this.props.dispatchActionPayload(processServerRequestsActionType, { serverRequests, calendarQuery, }); if (this.props.inflightRequests) { const clientResponses = this.props.getClientResponses(serverRequests); this.sendAndHandleClientResponsesToServerRequests(clientResponses); } }; sendClientResponses( clientResponses: $ReadOnlyArray, ): Promise { const { inflightRequests } = this.props; invariant( inflightRequests, 'inflightRequests falsey inside sendClientResponses', ); const messageID = this.props.sendMessage({ type: clientSocketMessageTypes.RESPONSES, payload: { clientResponses }, }); return inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.REQUESTS, ); } sendAndHandleClientResponsesToServerRequests( clientResponses: $ReadOnlyArray, ) { if (clientResponses.length === 0) { return; } const promise = this.sendClientResponses(clientResponses); this.handleClientResponsesToServerRequests(promise, clientResponses); } async handleClientResponsesToServerRequests( promise: Promise, clientResponses: $ReadOnlyArray, retriesLeft: number = 1, ): Promise { try { await promise; } catch (e) { console.log(e); if ( !(e instanceof SocketTimeout) && (!(e instanceof ServerError) || e.message === 'unknown_error') && retriesLeft > 0 && this.props.connection.status === 'connected' && this.props.inflightRequests ) { // We'll only retry if the connection is healthy and the error is either // an unknown_error ServerError or something is neither a ServerError // nor a SocketTimeout. const newPromise = this.sendClientResponses(clientResponses); await this.handleClientResponsesToServerRequests( newPromise, clientResponses, retriesLeft - 1, ); } } } } export default connect( (state: AppState) => ({ connection: state.connection, }), null, true, )(RequestResponseHandler); diff --git a/lib/socket/socket.react.js b/lib/socket/socket.react.js index 3744707fa..44e39033e 100644 --- a/lib/socket/socket.react.js +++ b/lib/socket/socket.react.js @@ -1,751 +1,750 @@ // @flow import invariant from 'invariant'; import _throttle from 'lodash/throttle'; import PropTypes from 'prop-types'; import * as React from 'react'; import { updateActivityActionTypes } from '../actions/activity-actions'; import { socketAuthErrorResolutionAttempt, logOutActionTypes, } from '../actions/user-actions'; import { unsupervisedBackgroundActionType } from '../reducers/foreground-reducer'; import { pingFrequency, serverRequestSocketTimeout, clientRequestVisualTimeout, clientRequestSocketTimeout, } from '../shared/timeouts'; import type { LogOutResult } from '../types/account-types'; import type { CalendarQuery } from '../types/entry-types'; import type { Dispatch } from '../types/redux-types'; import { serverRequestTypes, type ClientClientResponse, type ServerRequest, } from '../types/request-types'; import { type SessionState, type SessionIdentification, sessionIdentificationPropType, type PreRequestUserState, preRequestUserStatePropType, } from '../types/session-types'; import { clientSocketMessageTypes, type ClientClientSocketMessage, serverSocketMessageTypes, type ServerSocketMessage, stateSyncPayloadTypes, fullStateSyncActionType, incrementalStateSyncActionType, updateConnectionStatusActionType, connectionInfoPropType, type ConnectionInfo, type ClientInitialClientSocketMessage, type ClientResponsesClientSocketMessage, type PingClientSocketMessage, type AckUpdatesClientSocketMessage, type APIRequestClientSocketMessage, type ClientSocketMessageWithoutID, type SocketListener, type ConnectionStatus, setLateResponseActionType, } from '../types/socket-types'; import { actionLogger } from '../utils/action-logger'; import type { DispatchActionPromise } from '../utils/action-utils'; import { setNewSessionActionType, fetchNewCookieFromNativeCredentials, } from '../utils/action-utils'; import { getConfig } from '../utils/config'; import { ServerError } from '../utils/errors'; import { promiseAll } from '../utils/promises'; import sleep from '../utils/sleep'; - import ActivityHandler from './activity-handler.react'; import APIRequestHandler from './api-request-handler.react'; import CalendarQueryHandler from './calendar-query-handler.react'; import { InflightRequests, SocketTimeout, SocketOffline, } from './inflight-requests'; import MessageHandler from './message-handler.react'; import ReportHandler from './report-handler.react'; import RequestResponseHandler from './request-response-handler.react'; import UpdateHandler from './update-handler.react'; const remainingTimeAfterVisualTimeout = clientRequestSocketTimeout - clientRequestVisualTimeout; export type BaseSocketProps = {| +detectUnsupervisedBackgroundRef?: ( detectUnsupervisedBackground: (alreadyClosed: boolean) => boolean, ) => void, |}; type Props = {| ...BaseSocketProps, // Redux state +active: boolean, +openSocket: () => WebSocket, +getClientResponses: ( activeServerRequests: $ReadOnlyArray, ) => $ReadOnlyArray, +activeThread: ?string, +sessionStateFunc: () => SessionState, +sessionIdentification: SessionIdentification, +cookie: ?string, +urlPrefix: string, +connection: ConnectionInfo, +currentCalendarQuery: () => CalendarQuery, +canSendReports: boolean, +frozen: boolean, +preRequestUserState: PreRequestUserState, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +logOut: (preRequestUserState: PreRequestUserState) => Promise, |}; type State = {| +inflightRequests: ?InflightRequests, |}; class Socket extends React.PureComponent { static propTypes = { detectUnsupervisedBackgroundRef: PropTypes.func, active: PropTypes.bool.isRequired, openSocket: PropTypes.func.isRequired, getClientResponses: PropTypes.func.isRequired, activeThread: PropTypes.string, sessionStateFunc: PropTypes.func.isRequired, sessionIdentification: sessionIdentificationPropType.isRequired, cookie: PropTypes.string, urlPrefix: PropTypes.string.isRequired, connection: connectionInfoPropType.isRequired, currentCalendarQuery: PropTypes.func.isRequired, canSendReports: PropTypes.bool.isRequired, frozen: PropTypes.bool.isRequired, preRequestUserState: preRequestUserStatePropType.isRequired, dispatch: PropTypes.func.isRequired, dispatchActionPromise: PropTypes.func.isRequired, logOut: PropTypes.func.isRequired, }; state: State = { inflightRequests: null, }; socket: ?WebSocket; nextClientMessageID = 0; listeners: Set = new Set(); pingTimeoutID: ?TimeoutID; messageLastReceived: ?number; initialPlatformDetailsSent = getConfig().platformDetails.platform === 'web'; reopenConnectionAfterClosing = false; invalidationRecoveryInProgress = false; initializedWithUserState: ?PreRequestUserState; openSocket(newStatus: ConnectionStatus) { if ( this.props.frozen || (getConfig().platformDetails.platform !== 'web' && (!this.props.cookie || !this.props.cookie.startsWith('user='))) ) { return; } if (this.socket) { const { status } = this.props.connection; if (status === 'forcedDisconnecting') { this.reopenConnectionAfterClosing = true; return; } else if (status === 'disconnecting' && this.socket.readyState === 1) { this.markSocketInitialized(); return; } else if ( status === 'connected' || status === 'connecting' || status === 'reconnecting' ) { return; } if (this.socket.readyState < 2) { this.socket.close(); console.log(`this.socket seems open, but Redux thinks it's ${status}`); } } this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: newStatus }, }); const socket = this.props.openSocket(); const openObject = {}; socket.onopen = () => { if (this.socket === socket) { this.initializeSocket(); openObject.initializeMessageSent = true; } }; socket.onmessage = this.receiveMessage; socket.onclose = () => { if (this.socket === socket) { this.onClose(); } }; this.socket = socket; (async () => { await sleep(clientRequestVisualTimeout); if (this.socket !== socket || openObject.initializeMessageSent) { return; } this.setLateResponse(-1, true); await sleep(remainingTimeAfterVisualTimeout); if (this.socket !== socket || openObject.initializeMessageSent) { return; } this.finishClosingSocket(); })(); this.setState({ inflightRequests: new InflightRequests({ timeout: () => { if (this.socket === socket) { this.finishClosingSocket(); } }, setLateResponse: (messageID: number, isLate: boolean) => { if (this.socket === socket) { this.setLateResponse(messageID, isLate); } }, }), }); } markSocketInitialized() { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'connected' }, }); this.resetPing(); } closeSocket( // This param is a hack. When closing a socket there is a race between this // function and the one to propagate the activity update. We make sure that // the activity update wins the race by passing in this param. activityUpdatePending: boolean, ) { const { status } = this.props.connection; if (status === 'disconnected') { return; } else if (status === 'disconnecting' || status === 'forcedDisconnecting') { this.reopenConnectionAfterClosing = false; return; } this.stopPing(); this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnecting' }, }); if (!activityUpdatePending) { this.finishClosingSocket(); } } forceCloseSocket() { this.stopPing(); const { status } = this.props.connection; if (status !== 'forcedDisconnecting' && status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'forcedDisconnecting' }, }); } this.finishClosingSocket(); } finishClosingSocket(receivedResponseTo?: ?number) { const { inflightRequests } = this.state; if ( inflightRequests && !inflightRequests.allRequestsResolvedExcept(receivedResponseTo) ) { return; } if (this.socket && this.socket.readyState < 2) { // If it's not closing already, close it this.socket.close(); } this.socket = null; this.stopPing(); this.setState({ inflightRequests: null }); if (this.props.connection.status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnected' }, }); } if (this.reopenConnectionAfterClosing) { this.reopenConnectionAfterClosing = false; if (this.props.active) { this.openSocket('connecting'); } } } reconnect = _throttle(() => this.openSocket('reconnecting'), 2000); componentDidMount() { if (this.props.detectUnsupervisedBackgroundRef) { this.props.detectUnsupervisedBackgroundRef( this.detectUnsupervisedBackground, ); } if (this.props.active) { this.openSocket('connecting'); } } componentWillUnmount() { this.closeSocket(false); this.reconnect.cancel(); } componentDidUpdate(prevProps: Props) { if (this.props.active && !prevProps.active) { this.openSocket('connecting'); } else if (!this.props.active && prevProps.active) { this.closeSocket(!!prevProps.activeThread); } else if ( this.props.active && prevProps.openSocket !== this.props.openSocket ) { // This case happens when the baseURL/urlPrefix is changed this.reopenConnectionAfterClosing = true; this.forceCloseSocket(); } else if ( this.props.active && this.props.connection.status === 'disconnected' && prevProps.connection.status !== 'disconnected' && !this.invalidationRecoveryInProgress ) { this.reconnect(); } } render() { // It's important that APIRequestHandler get rendered first here. This is so // that it is registered with Redux first, so that its componentDidUpdate // processes before the other Handlers. This allows APIRequestHandler to // register itself with action-utils before other Handlers call // dispatchActionPromise in response to the componentDidUpdate triggered by // the same Redux change (state.connection.status). return ( ); } sendMessageWithoutID = (message: ClientSocketMessageWithoutID) => { const id = this.nextClientMessageID++; // These conditions all do the same thing and the runtime checks are only // necessary for Flow if (message.type === clientSocketMessageTypes.INITIAL) { this.sendMessage(({ ...message, id }: ClientInitialClientSocketMessage)); } else if (message.type === clientSocketMessageTypes.RESPONSES) { this.sendMessage( ({ ...message, id }: ClientResponsesClientSocketMessage), ); } else if (message.type === clientSocketMessageTypes.PING) { this.sendMessage(({ ...message, id }: PingClientSocketMessage)); } else if (message.type === clientSocketMessageTypes.ACK_UPDATES) { this.sendMessage(({ ...message, id }: AckUpdatesClientSocketMessage)); } else if (message.type === clientSocketMessageTypes.API_REQUEST) { this.sendMessage(({ ...message, id }: APIRequestClientSocketMessage)); } return id; }; sendMessage(message: ClientClientSocketMessage) { const socket = this.socket; invariant(socket, 'should be set'); socket.send(JSON.stringify(message)); } static messageFromEvent(event: MessageEvent): ?ServerSocketMessage { if (typeof event.data !== 'string') { console.log('socket received a non-string message'); return null; } try { return JSON.parse(event.data); } catch (e) { console.log(e); return null; } } receiveMessage = async (event: MessageEvent) => { const message = Socket.messageFromEvent(event); if (!message) { return; } const { inflightRequests } = this.state; if (!inflightRequests) { // inflightRequests can be falsey here if we receive a message after we've // begun shutting down the socket. It's possible for a React Native // WebSocket to deliver a message even after close() is called on it. In // this case the message is probably a PONG, which we can safely ignore. // If it's not a PONG, it has to be something server-initiated (like // UPDATES or MESSAGES), since InflightRequests.allRequestsResolvedExcept // will wait for all responses to client-initiated requests to be // delivered before closing a socket. UPDATES and MESSAGES are both // checkpointed on the client, so should be okay to just ignore here and // redownload them later, probably in an incremental STATE_SYNC. return; } // If we receive any message, that indicates that our connection is healthy, // so we can reset the ping timeout. this.resetPing(); inflightRequests.resolveRequestsForMessage(message); const { status } = this.props.connection; if (status === 'disconnecting' || status === 'forcedDisconnecting') { this.finishClosingSocket( // We do this for Flow message.responseTo !== undefined ? message.responseTo : null, ); } for (let listener of this.listeners) { listener(message); } if (message.type === serverSocketMessageTypes.ERROR) { const { message: errorMessage, payload } = message; if (payload) { console.log(`socket sent error ${errorMessage} with payload`, payload); } else { console.log(`socket sent error ${errorMessage}`); } } else if (message.type === serverSocketMessageTypes.AUTH_ERROR) { const { sessionChange } = message; const cookie = sessionChange ? sessionChange.cookie : this.props.cookie; this.invalidationRecoveryInProgress = true; const recoverySessionChange = await fetchNewCookieFromNativeCredentials( this.props.dispatch, cookie, this.props.urlPrefix, socketAuthErrorResolutionAttempt, ); if (!recoverySessionChange && sessionChange) { // This should only happen in the cookieSources.BODY (native) case when // the resolution attempt failed const { cookie: newerCookie, currentUserInfo } = sessionChange; this.props.dispatch({ type: setNewSessionActionType, payload: { sessionChange: { cookieInvalidated: true, currentUserInfo, cookie: newerCookie, }, preRequestUserState: this.initializedWithUserState, error: null, source: socketAuthErrorResolutionAttempt, }, }); } else if (!recoverySessionChange) { this.props.dispatchActionPromise( logOutActionTypes, this.props.logOut(this.props.preRequestUserState), ); } this.invalidationRecoveryInProgress = false; } }; addListener = (listener: SocketListener) => { this.listeners.add(listener); }; removeListener = (listener: SocketListener) => { this.listeners.delete(listener); }; onClose = () => { const { status } = this.props.connection; this.socket = null; this.stopPing(); if (this.state.inflightRequests) { this.state.inflightRequests.rejectAll(new Error('socket closed')); this.setState({ inflightRequests: null }); } const handled = this.detectUnsupervisedBackground(true); if (!handled && status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnected' }, }); } }; async sendInitialMessage() { const { inflightRequests } = this.state; invariant( inflightRequests, 'inflightRequests falsey inside sendInitialMessage', ); const messageID = this.nextClientMessageID++; const promises = {}; const clientResponses = []; if (!this.initialPlatformDetailsSent) { this.initialPlatformDetailsSent = true; clientResponses.push({ type: serverRequestTypes.PLATFORM_DETAILS, platformDetails: getConfig().platformDetails, }); } const { queuedActivityUpdates } = this.props.connection; if (queuedActivityUpdates.length > 0) { clientResponses.push({ type: serverRequestTypes.INITIAL_ACTIVITY_UPDATES, activityUpdates: queuedActivityUpdates, }); promises.activityUpdateMessage = inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE, ); } const sessionState = this.props.sessionStateFunc(); const { sessionIdentification } = this.props; const initialMessage = { type: clientSocketMessageTypes.INITIAL, id: messageID, payload: { clientResponses, sessionState, sessionIdentification, }, }; this.initializedWithUserState = this.props.preRequestUserState; this.sendMessage(initialMessage); promises.stateSyncMessage = inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.STATE_SYNC, ); const { stateSyncMessage, activityUpdateMessage } = await promiseAll( promises, ); if (activityUpdateMessage) { this.props.dispatch({ type: updateActivityActionTypes.success, payload: { activityUpdates: queuedActivityUpdates, result: activityUpdateMessage.payload, }, }); } if (stateSyncMessage.payload.type === stateSyncPayloadTypes.FULL) { const { sessionID, type, ...actionPayload } = stateSyncMessage.payload; this.props.dispatch({ type: fullStateSyncActionType, payload: { ...actionPayload, calendarQuery: sessionState.calendarQuery, }, }); if (sessionID !== null && sessionID !== undefined) { invariant( this.initializedWithUserState, 'initializedWithUserState should be set when state sync received', ); this.props.dispatch({ type: setNewSessionActionType, payload: { sessionChange: { cookieInvalidated: false, sessionID }, preRequestUserState: this.initializedWithUserState, error: null, source: undefined, }, }); } } else { const { type, ...actionPayload } = stateSyncMessage.payload; this.props.dispatch({ type: incrementalStateSyncActionType, payload: { ...actionPayload, calendarQuery: sessionState.calendarQuery, }, }); } const currentAsOf = stateSyncMessage.payload.type === stateSyncPayloadTypes.FULL ? stateSyncMessage.payload.updatesCurrentAsOf : stateSyncMessage.payload.updatesResult.currentAsOf; this.sendMessageWithoutID({ type: clientSocketMessageTypes.ACK_UPDATES, payload: { currentAsOf }, }); this.markSocketInitialized(); } initializeSocket = async (retriesLeft: number = 1) => { try { await this.sendInitialMessage(); } catch (e) { console.log(e); const { status } = this.props.connection; if ( e instanceof SocketTimeout || e instanceof SocketOffline || (status !== 'connecting' && status !== 'reconnecting') ) { // This indicates that the socket will be closed. Do nothing, since the // connection status update will trigger a reconnect. } else if ( retriesLeft === 0 || (e instanceof ServerError && e.message !== 'unknown_error') ) { if (e.message === 'not_logged_in') { this.props.dispatchActionPromise( logOutActionTypes, this.props.logOut(this.props.preRequestUserState), ); } else if (this.socket) { this.socket.close(); } } else { await this.initializeSocket(retriesLeft - 1); } } }; stopPing() { if (this.pingTimeoutID) { clearTimeout(this.pingTimeoutID); this.pingTimeoutID = null; } } resetPing() { this.stopPing(); const socket = this.socket; this.messageLastReceived = Date.now(); this.pingTimeoutID = setTimeout(() => { if (this.socket === socket) { this.sendPing(); } }, pingFrequency); } async sendPing() { if (this.props.connection.status !== 'connected') { // This generally shouldn't happen because anything that changes the // connection status should call stopPing(), but it's good to make sure return; } const messageID = this.sendMessageWithoutID({ type: clientSocketMessageTypes.PING, }); try { invariant( this.state.inflightRequests, 'inflightRequests falsey inside sendPing', ); await this.state.inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.PONG, ); } catch (e) {} } setLateResponse = (messageID: number, isLate: boolean) => { this.props.dispatch({ type: setLateResponseActionType, payload: { messageID, isLate }, }); }; cleanUpServerTerminatedSocket() { if (this.socket && this.socket.readyState < 2) { this.socket.close(); } else { this.onClose(); } } detectUnsupervisedBackground = (alreadyClosed: boolean) => { // On native, sometimes the app is backgrounded without the proper callbacks // getting triggered. This leaves us in an incorrect state for two reasons: // (1) The connection is still considered to be active, causing API requests // to be processed via socket and failing. // (2) We rely on flipping foreground state in Redux to detect activity // changes, and thus won't think we need to update activity. if ( this.props.connection.status !== 'connected' || !this.messageLastReceived || this.messageLastReceived + serverRequestSocketTimeout >= Date.now() || (actionLogger.mostRecentActionTime && actionLogger.mostRecentActionTime + 3000 < Date.now()) ) { return false; } if (!alreadyClosed) { this.cleanUpServerTerminatedSocket(); } this.props.dispatch({ type: unsupervisedBackgroundActionType, payload: null, }); return true; }; } export default Socket; diff --git a/lib/types/entry-types.js b/lib/types/entry-types.js index 5c7dbff4c..c0c4561fc 100644 --- a/lib/types/entry-types.js +++ b/lib/types/entry-types.js @@ -1,232 +1,231 @@ // @flow import PropTypes from 'prop-types'; import { fifteenDaysEarlier, fifteenDaysLater, thisMonthDates, } from '../utils/date-utils'; - import type { Platform } from './device-types'; import { type CalendarFilter, calendarFilterPropType, defaultCalendarFilters, } from './filter-types'; import type { RawMessageInfo } from './message-types'; import type { ClientEntryInconsistencyReportCreationRequest } from './report-types'; import type { CreateUpdatesResponse } from './update-types'; import type { AccountUserInfo } from './user-types'; export type RawEntryInfo = {| id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, text: string, year: number, month: number, // 1-indexed day: number, // 1-indexed creationTime: number, // millisecond timestamp creatorID: string, deleted: boolean, |}; export const rawEntryInfoPropType = PropTypes.shape({ id: PropTypes.string, localID: PropTypes.string, threadID: PropTypes.string.isRequired, text: PropTypes.string.isRequired, year: PropTypes.number.isRequired, month: PropTypes.number.isRequired, day: PropTypes.number.isRequired, creationTime: PropTypes.number.isRequired, creatorID: PropTypes.string.isRequired, deleted: PropTypes.bool.isRequired, }); export type EntryInfo = {| id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, text: string, year: number, month: number, // 1-indexed day: number, // 1-indexed creationTime: number, // millisecond timestamp creator: ?string, deleted: boolean, |}; export const entryInfoPropType = PropTypes.shape({ id: PropTypes.string, localID: PropTypes.string, threadID: PropTypes.string.isRequired, text: PropTypes.string.isRequired, year: PropTypes.number.isRequired, month: PropTypes.number.isRequired, day: PropTypes.number.isRequired, creationTime: PropTypes.number.isRequired, creator: PropTypes.string, deleted: PropTypes.bool.isRequired, }); export type EntryStore = {| entryInfos: { [id: string]: RawEntryInfo }, daysToEntries: { [day: string]: string[] }, lastUserInteractionCalendar: number, inconsistencyReports: $ReadOnlyArray, |}; export type CalendarQuery = {| startDate: string, endDate: string, filters: $ReadOnlyArray, |}; export const defaultCalendarQuery = ( platform: ?Platform, timeZone?: ?string, ) => { if (platform === 'web') { return { ...thisMonthDates(timeZone), filters: defaultCalendarFilters, }; } else { return { startDate: fifteenDaysEarlier(timeZone).valueOf(), endDate: fifteenDaysLater(timeZone).valueOf(), filters: defaultCalendarFilters, }; } }; export const calendarQueryPropType = PropTypes.shape({ startDate: PropTypes.string.isRequired, endDate: PropTypes.string.isRequired, filters: PropTypes.arrayOf(calendarFilterPropType).isRequired, }); export type SaveEntryInfo = {| entryID: string, text: string, prevText: string, timestamp: number, calendarQuery: CalendarQuery, |}; export type SaveEntryRequest = {| entryID: string, text: string, prevText: string, timestamp: number, calendarQuery?: CalendarQuery, |}; export type SaveEntryResponse = {| entryID: string, newMessageInfos: $ReadOnlyArray, updatesResult: CreateUpdatesResponse, |}; export type SaveEntryPayload = {| ...SaveEntryResponse, threadID: string, |}; export type CreateEntryInfo = {| text: string, timestamp: number, date: string, threadID: string, localID: string, calendarQuery: CalendarQuery, |}; export type CreateEntryRequest = {| text: string, timestamp: number, date: string, threadID: string, localID?: string, calendarQuery?: CalendarQuery, |}; export type CreateEntryPayload = {| ...SaveEntryPayload, localID: string, |}; export type DeleteEntryInfo = {| entryID: string, prevText: string, calendarQuery: CalendarQuery, |}; export type DeleteEntryRequest = {| entryID: string, prevText: string, timestamp: number, calendarQuery?: CalendarQuery, |}; export type RestoreEntryInfo = {| entryID: string, calendarQuery: CalendarQuery, |}; export type RestoreEntryRequest = {| entryID: string, timestamp: number, calendarQuery?: CalendarQuery, |}; export type DeleteEntryResponse = {| newMessageInfos: $ReadOnlyArray, threadID: string, updatesResult: CreateUpdatesResponse, |}; export type RestoreEntryResponse = {| newMessageInfos: $ReadOnlyArray, updatesResult: CreateUpdatesResponse, |}; export type RestoreEntryPayload = {| ...RestoreEntryResponse, threadID: string, |}; export type FetchEntryInfosBase = {| rawEntryInfos: $ReadOnlyArray, |}; export type FetchEntryInfosResponse = {| ...FetchEntryInfosBase, userInfos: { [id: string]: AccountUserInfo }, |}; export type FetchEntryInfosResult = FetchEntryInfosBase; export type DeltaEntryInfosResponse = {| rawEntryInfos: $ReadOnlyArray, deletedEntryIDs: $ReadOnlyArray, |}; export type DeltaEntryInfosResult = {| rawEntryInfos: $ReadOnlyArray, deletedEntryIDs: $ReadOnlyArray, userInfos: $ReadOnlyArray, |}; export type CalendarResult = {| rawEntryInfos: $ReadOnlyArray, calendarQuery: CalendarQuery, |}; export type CalendarQueryUpdateStartingPayload = {| calendarQuery?: CalendarQuery, |}; export type CalendarQueryUpdateResult = {| rawEntryInfos: $ReadOnlyArray, deletedEntryIDs: $ReadOnlyArray, calendarQuery: CalendarQuery, calendarQueryAlreadyUpdated: boolean, |}; diff --git a/lib/types/relationship-types.js b/lib/types/relationship-types.js index 34e90acf0..431964cd0 100644 --- a/lib/types/relationship-types.js +++ b/lib/types/relationship-types.js @@ -1,67 +1,66 @@ // @flow import { values } from '../utils/objects'; - import type { AccountUserInfo } from './user-types'; export const undirectedStatus = Object.freeze({ KNOW_OF: 0, FRIEND: 2, }); export type UndirectedStatus = $Values; export const directedStatus = Object.freeze({ PENDING_FRIEND: 1, BLOCKED: 3, }); export type DirectedStatus = $Values; export const userRelationshipStatus = Object.freeze({ REQUEST_SENT: 1, REQUEST_RECEIVED: 2, FRIEND: 3, BLOCKED_BY_VIEWER: 4, BLOCKED_VIEWER: 5, BOTH_BLOCKED: 6, }); export type UserRelationshipStatus = $Values; export const relationshipActions = Object.freeze({ FRIEND: 'friend', UNFRIEND: 'unfriend', BLOCK: 'block', UNBLOCK: 'unblock', }); export type RelationshipAction = $Values; export const relationshipActionsList: $ReadOnlyArray = values( relationshipActions, ); export type RelationshipRequest = {| action: RelationshipAction, userIDs: $ReadOnlyArray, |}; type SharedRelationshipRow = {| user1: string, user2: string, |}; export type DirectedRelationshipRow = {| ...SharedRelationshipRow, status: DirectedStatus, |}; export type UndirectedRelationshipRow = {| ...SharedRelationshipRow, status: UndirectedStatus, |}; export type RelationshipErrors = $Shape<{| invalid_user: string[], already_friends: string[], user_blocked: string[], |}>; export type UserRelationships = {| +friends: $ReadOnlyArray, +blocked: $ReadOnlyArray, |}; diff --git a/lib/utils/action-logger.js b/lib/utils/action-logger.js index 0d487d087..30beb7fb9 100644 --- a/lib/utils/action-logger.js +++ b/lib/utils/action-logger.js @@ -1,181 +1,180 @@ // @flow import inspect from 'util-inspect'; import { saveDraftActionType } from '../actions/miscellaneous-action-types'; import { rehydrateActionType } from '../types/redux-types'; import type { ActionSummary } from '../types/report-types'; - import { sanitizeAction } from './sanitization'; const uninterestingActionTypes = new Set([ saveDraftActionType, 'Navigation/COMPLETE_TRANSITION', ]); const maxActionSummaryLength = 500; type Subscriber = (action: Object, state: Object) => void; class ActionLogger { static n = 30; lastNActions = []; lastNStates = []; currentReduxState = undefined; currentOtherStates = {}; subscribers: Subscriber[] = []; get preloadedState(): Object { return this.lastNStates[0].state; } get actions(): Object[] { return this.lastNActions.map(({ action }) => action); } get interestingActionSummaries(): ActionSummary[] { return this.lastNActions .filter(({ action }) => !uninterestingActionTypes.has(action.type)) .map(({ action, time }) => ({ type: action.type, time, summary: ActionLogger.getSummaryForAction(action), })); } static getSummaryForAction(action: Object): string { const sanitized = sanitizeAction(action); let summary, length, depth = 3; do { summary = inspect(sanitized, { depth }); length = summary.length; depth--; } while (length > maxActionSummaryLength && depth > 0); return summary; } prepareForAction() { if ( this.lastNActions.length > 0 && this.lastNActions[this.lastNActions.length - 1].action.type === rehydrateActionType ) { // redux-persist can't handle replaying REHYDRATE // https://github.com/rt2zz/redux-persist/issues/743 this.lastNActions = []; this.lastNStates = []; } if (this.lastNActions.length === ActionLogger.n) { this.lastNActions.shift(); this.lastNStates.shift(); } } addReduxAction(action: Object, beforeState: Object, afterState: Object) { this.prepareForAction(); if (this.currentReduxState === undefined) { for (let i = 0; i < this.lastNStates.length; i++) { this.lastNStates[i] = { ...this.lastNStates[i], state: { ...this.lastNStates[i].state, ...beforeState, }, }; } } this.currentReduxState = afterState; const state = { ...beforeState }; for (let stateKey in this.currentOtherStates) { state[stateKey] = this.currentOtherStates[stateKey]; } const time = Date.now(); this.lastNActions.push({ action, time }); this.lastNStates.push({ state, time }); this.triggerSubscribers(action); } addOtherAction( key: string, action: Object, beforeState: Object, afterState: Object, ) { this.prepareForAction(); const currentState = this.currentOtherStates[key]; if (currentState === undefined) { for (let i = 0; i < this.lastNStates.length; i++) { this.lastNStates[i] = { ...this.lastNStates[i], state: { ...this.lastNStates[i].state, [key]: beforeState, }, }; } } this.currentOtherStates[key] = afterState; const state = { ...this.currentState, [key]: beforeState, }; const time = Date.now(); this.lastNActions.push({ action, time }); this.lastNStates.push({ state, time }); this.triggerSubscribers(action); } get mostRecentActionTime(): ?number { if (this.lastNActions.length === 0) { return null; } return this.lastNActions[this.lastNActions.length - 1].time; } get currentState(): Object { const state = this.currentReduxState ? { ...this.currentReduxState } : {}; for (let stateKey in this.currentOtherStates) { state[stateKey] = this.currentOtherStates[stateKey]; } return state; } subscribe(subscriber: Subscriber) { this.subscribers.push(subscriber); } unsubscribe(subscriber: Subscriber) { this.subscribers = this.subscribers.filter( (candidate) => candidate !== subscriber, ); } triggerSubscribers(action: Object) { if (uninterestingActionTypes.has(action.type)) { return; } const state = this.currentState; this.subscribers.forEach((subscriber) => subscriber(action, state)); } } const actionLogger = new ActionLogger(); const reduxLoggerMiddleware = (store: *) => (next: *) => (action: *) => { const beforeState = store.getState(); const result = next(action); const afterState = store.getState(); actionLogger.addReduxAction(action, beforeState, afterState); return result; }; export { actionLogger, reduxLoggerMiddleware }; diff --git a/lib/utils/action-utils.js b/lib/utils/action-utils.js index 92c2a4083..14e0ac8ca 100644 --- a/lib/utils/action-utils.js +++ b/lib/utils/action-utils.js @@ -1,499 +1,498 @@ // @flow import invariant from 'invariant'; import _mapValues from 'lodash/fp/mapValues'; import _memoize from 'lodash/memoize'; import * as React from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { createSelector } from 'reselect'; import { cookieInvalidationResolutionAttempt } from '../actions/user-actions'; import { serverCallStateSelector } from '../selectors/server-calls'; import type { LogInActionSource, LogInStartingPayload, LogInResult, } from '../types/account-types'; import type { Endpoint, SocketAPIHandler } from '../types/endpoints'; import type { LoadingOptions, LoadingInfo } from '../types/loading-types'; import type { ActionPayload, Dispatch, PromisedAction, BaseAction, } from '../types/redux-types'; import type { ClientSessionChange, PreRequestUserState, } from '../types/session-types'; import type { ConnectionStatus } from '../types/socket-types'; import type { CurrentUserInfo } from '../types/user-types'; - import { getConfig } from './config'; import fetchJSON from './fetch-json'; import type { FetchJSON, FetchJSONOptions } from './fetch-json'; let nextPromiseIndex = 0; export type ActionTypes = { started: AT, success: BT, failed: CT, }; function wrapActionPromise< AT: string, // *_STARTED action type (string literal) AP: ActionPayload, // *_STARTED payload BT: string, // *_SUCCESS action type (string literal) BP: ActionPayload, // *_SUCCESS payload CT: string, // *_FAILED action type (string literal) >( actionTypes: ActionTypes, promise: Promise, loadingOptions: ?LoadingOptions, startingPayload: ?AP, ): PromisedAction { const loadingInfo: LoadingInfo = { fetchIndex: nextPromiseIndex++, trackMultipleRequests: !!( loadingOptions && loadingOptions.trackMultipleRequests ), customKeyName: loadingOptions && loadingOptions.customKeyName ? loadingOptions.customKeyName : null, }; return async (dispatch: Dispatch): Promise => { const startAction = startingPayload ? { type: (actionTypes.started: AT), loadingInfo, payload: (startingPayload: AP), } : { type: (actionTypes.started: AT), loadingInfo, }; dispatch(startAction); try { const result = await promise; dispatch({ type: (actionTypes.success: BT), payload: (result: BP), loadingInfo, }); } catch (e) { console.log(e); dispatch({ type: (actionTypes.failed: CT), error: true, payload: (e: Error), loadingInfo, }); } }; } export type DispatchActionPayload = ( actionType: T, payload: P, ) => void; export type DispatchActionPromise = < A: BaseAction, B: BaseAction, C: BaseAction, >( actionTypes: ActionTypes< $PropertyType, $PropertyType, $PropertyType, >, promise: Promise<$PropertyType>, loadingOptions?: LoadingOptions, startingPayload?: $PropertyType, ) => Promise; function useDispatchActionPromise() { const dispatch = useDispatch(); return React.useMemo(() => createDispatchActionPromise(dispatch), [dispatch]); } function createDispatchActionPromise(dispatch: Dispatch) { const dispatchActionPromise = function < A: BaseAction, B: BaseAction, C: BaseAction, >( actionTypes: ActionTypes< $PropertyType, $PropertyType, $PropertyType, >, promise: Promise<$PropertyType>, loadingOptions?: LoadingOptions, startingPayload?: $PropertyType, ): Promise { return dispatch( wrapActionPromise(actionTypes, promise, loadingOptions, startingPayload), ); }; return dispatchActionPromise; } export type DispatchFunctions = {| +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, |}; type LegacyDispatchFunctions = { dispatch: Dispatch, dispatchActionPayload: DispatchActionPayload, dispatchActionPromise: DispatchActionPromise, }; function includeDispatchActionProps( dispatch: Dispatch, ): LegacyDispatchFunctions { const dispatchActionPromise = createDispatchActionPromise(dispatch); const dispatchActionPayload = function ( actionType: T, payload: P, ) { const action = { type: actionType, payload }; dispatch(action); }; return { dispatch, dispatchActionPayload, dispatchActionPromise }; } let currentlyWaitingForNewCookie = false; let fetchJSONCallsWaitingForNewCookie: ((fetchJSON: ?FetchJSON) => void)[] = []; export type DispatchRecoveryAttempt = ( actionTypes: ActionTypes<'LOG_IN_STARTED', 'LOG_IN_SUCCESS', 'LOG_IN_FAILED'>, promise: Promise, startingPayload: LogInStartingPayload, ) => Promise; const setNewSessionActionType = 'SET_NEW_SESSION'; function setNewSession( dispatch: Dispatch, sessionChange: ClientSessionChange, preRequestUserState: ?PreRequestUserState, error: ?string, source: ?LogInActionSource, ) { dispatch({ type: setNewSessionActionType, payload: { sessionChange, preRequestUserState, error, source }, }); } // This function calls resolveInvalidatedCookie, which dispatchs a log in action // using the native credentials. Note that we never actually specify a sessionID // here, on the assumption that only native clients will call this. (Native // clients don't specify a sessionID, indicating to the server that it should // use the cookieID as the sessionID.) async function fetchNewCookieFromNativeCredentials( dispatch: Dispatch, cookie: ?string, urlPrefix: string, source: LogInActionSource, ): Promise { const resolveInvalidatedCookie = getConfig().resolveInvalidatedCookie; if (!resolveInvalidatedCookie) { return null; } let newSessionChange = null; let fetchJSONCallback = null; const boundFetchJSON = async ( endpoint: Endpoint, data: { [key: string]: mixed }, options?: ?FetchJSONOptions, ) => { const innerBoundSetNewSession = ( sessionChange: ClientSessionChange, error: ?string, ) => { newSessionChange = sessionChange; setNewSession(dispatch, sessionChange, null, error, source); }; try { const result = await fetchJSON( cookie, innerBoundSetNewSession, () => new Promise((r) => r(null)), () => new Promise((r) => r(null)), urlPrefix, null, 'disconnected', null, endpoint, data, options, ); if (fetchJSONCallback) { fetchJSONCallback(!!newSessionChange); } return result; } catch (e) { if (fetchJSONCallback) { fetchJSONCallback(!!newSessionChange); } throw e; } }; const dispatchRecoveryAttempt = ( actionTypes: ActionTypes< 'LOG_IN_STARTED', 'LOG_IN_SUCCESS', 'LOG_IN_FAILED', >, promise: Promise, inputStartingPayload: LogInStartingPayload, ) => { const startingPayload = { ...inputStartingPayload, source }; dispatch(wrapActionPromise(actionTypes, promise, null, startingPayload)); return new Promise((r) => (fetchJSONCallback = r)); }; await resolveInvalidatedCookie( boundFetchJSON, dispatchRecoveryAttempt, source, ); return newSessionChange; } // Third param is optional and gets called with newCookie if we get a new cookie // Necessary to propagate cookie in cookieInvalidationRecovery below function bindCookieAndUtilsIntoFetchJSON( params: BindServerCallsParams, ): FetchJSON { const { dispatch, cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, } = params; const loggedIn = !!(currentUserInfo && !currentUserInfo.anonymous && true); const boundSetNewSession = ( sessionChange: ClientSessionChange, error: ?string, ) => setNewSession( dispatch, sessionChange, { currentUserInfo, cookie, sessionID }, error, undefined, ); // This function gets called before fetchJSON sends a request, to make sure // that we're not in the middle of trying to recover an invalidated cookie const waitIfCookieInvalidated = () => { if (!getConfig().resolveInvalidatedCookie) { // If there is no resolveInvalidatedCookie function, just let the caller // fetchJSON instance continue return Promise.resolve(null); } if (!currentlyWaitingForNewCookie) { // Our cookie seems to be valid return Promise.resolve(null); } // Wait to run until we get our new cookie return new Promise((r) => fetchJSONCallsWaitingForNewCookie.push(r)); }; // This function is a helper for the next function defined below const attemptToResolveInvalidation = async ( sessionChange: ClientSessionChange, ) => { const newAnonymousCookie = sessionChange.cookie; const newSessionChange = await fetchNewCookieFromNativeCredentials( dispatch, newAnonymousCookie, urlPrefix, cookieInvalidationResolutionAttempt, ); currentlyWaitingForNewCookie = false; const currentWaitingCalls = fetchJSONCallsWaitingForNewCookie; fetchJSONCallsWaitingForNewCookie = []; const newFetchJSON = newSessionChange ? bindCookieAndUtilsIntoFetchJSON({ ...params, cookie: newSessionChange.cookie, sessionID: newSessionChange.sessionID, currentUserInfo: newSessionChange.currentUserInfo, }) : null; for (const func of currentWaitingCalls) { func(newFetchJSON); } return newFetchJSON; }; // If this function is called, fetchJSON got a response invalidating its // cookie, and is wondering if it should just like... give up? Or if there's // a chance at redemption const cookieInvalidationRecovery = (sessionChange: ClientSessionChange) => { if (!getConfig().resolveInvalidatedCookie) { // If there is no resolveInvalidatedCookie function, just let the caller // fetchJSON instance continue return Promise.resolve(null); } if (!loggedIn) { // We don't want to attempt any use native credentials of a logged out // user to log-in after a cookieInvalidation while logged out return Promise.resolve(null); } if (currentlyWaitingForNewCookie) { return new Promise((r) => fetchJSONCallsWaitingForNewCookie.push(r)); } currentlyWaitingForNewCookie = true; return attemptToResolveInvalidation(sessionChange); }; return (endpoint: Endpoint, data: Object, options?: ?FetchJSONOptions) => fetchJSON( cookie, boundSetNewSession, waitIfCookieInvalidated, cookieInvalidationRecovery, urlPrefix, sessionID, connectionStatus, socketAPIHandler, endpoint, data, options, ); } export type ActionFunc = ( fetchJSON: FetchJSON, ...rest: $FlowFixMe ) => Promise<*>; type BindServerCallsParams = {| dispatch: Dispatch, cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, |}; // All server calls needs to include some information from the Redux state // (namely, the cookie). This information is used deep in the server call, // at the point where fetchJSON is called. We don't want to bother propagating // the cookie (and any future config info that fetchJSON needs) through to the // server calls so they can pass it to fetchJSON. Instead, we "curry" the cookie // onto fetchJSON within react-redux's connect's mapStateToProps function, and // then pass that "bound" fetchJSON that no longer needs the cookie as a // parameter on to the server call. const baseCreateBoundServerCallsSelector = (actionFunc: ActionFunc) => { return createSelector( (state: BindServerCallsParams) => state.dispatch, (state: BindServerCallsParams) => state.cookie, (state: BindServerCallsParams) => state.urlPrefix, (state: BindServerCallsParams) => state.sessionID, (state: BindServerCallsParams) => state.currentUserInfo, (state: BindServerCallsParams) => state.connectionStatus, ( dispatch: Dispatch, cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, ) => { const boundFetchJSON = bindCookieAndUtilsIntoFetchJSON({ dispatch, cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, }); return (...rest: $FlowFixMe) => actionFunc(boundFetchJSON, ...rest); }, ); }; const createBoundServerCallsSelector: ( actionFunc: ActionFunc, ) => (state: BindServerCallsParams) => BoundServerCall = _memoize( baseCreateBoundServerCallsSelector, ); export type ServerCalls = { [name: string]: ActionFunc }; export type BoundServerCall = (...rest: $FlowFixMe) => Promise; function bindServerCalls(serverCalls: ServerCalls) { return ( stateProps: { cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, }, dispatchProps: Object, ownProps: { [propName: string]: mixed }, ) => { const dispatch = dispatchProps.dispatch; invariant(dispatch, 'should be defined'); const { cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, } = stateProps; const boundServerCalls = _mapValues( (serverCall: (fetchJSON: FetchJSON, ...rest: any) => Promise) => createBoundServerCallsSelector(serverCall)({ dispatch, cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, }), )(serverCalls); return { ...ownProps, ...stateProps, ...dispatchProps, ...boundServerCalls, }; }; } function useServerCall(serverCall: ActionFunc): BoundServerCall { const dispatch = useDispatch(); const serverCallState = useSelector((state) => serverCallStateSelector(state), ); return React.useMemo( () => createBoundServerCallsSelector(serverCall)({ ...serverCallState, dispatch, }), [serverCall, dispatch, serverCallState], ); } let socketAPIHandler: ?SocketAPIHandler = null; function registerActiveSocket(passedSocketAPIHandler: ?SocketAPIHandler) { socketAPIHandler = passedSocketAPIHandler; } export { useDispatchActionPromise, setNewSessionActionType, includeDispatchActionProps, fetchNewCookieFromNativeCredentials, createBoundServerCallsSelector, bindServerCalls, registerActiveSocket, useServerCall, }; diff --git a/lib/utils/config.js b/lib/utils/config.js index 31ccbac0e..e8fc72c69 100644 --- a/lib/utils/config.js +++ b/lib/utils/config.js @@ -1,34 +1,33 @@ // @flow import invariant from 'invariant'; import type { LogInActionSource } from '../types/account-types'; import type { PlatformDetails } from '../types/device-types'; - import type { DispatchRecoveryAttempt } from './action-utils'; import type { FetchJSON } from './fetch-json'; export type Config = { resolveInvalidatedCookie: ?( fetchJSON: FetchJSON, dispatchRecoveryAttempt: DispatchRecoveryAttempt, source?: LogInActionSource, ) => Promise, setCookieOnRequest: boolean, setSessionIDOnRequest: boolean, calendarRangeInactivityLimit: ?number, platformDetails: PlatformDetails, }; let registeredConfig: ?Config = null; const registerConfig = (config: $Shape) => { registeredConfig = { ...registeredConfig, ...config }; }; const getConfig = (): Config => { invariant(registeredConfig, 'config should be set'); return registeredConfig; }; export { registerConfig, getConfig }; diff --git a/lib/utils/fetch-json.js b/lib/utils/fetch-json.js index cafb0c4ea..b0443ade1 100644 --- a/lib/utils/fetch-json.js +++ b/lib/utils/fetch-json.js @@ -1,195 +1,194 @@ // @flow import _cloneDeep from 'lodash/fp/cloneDeep'; import { fetchJSONTimeout } from '../shared/timeouts'; import { SocketOffline, SocketTimeout } from '../socket/inflight-requests'; import { type Endpoint, type SocketAPIHandler, endpointIsSocketPreferred, endpointIsSocketOnly, } from '../types/endpoints'; import type { ServerSessionChange, ClientSessionChange, } from '../types/session-types'; import type { ConnectionStatus } from '../types/socket-types'; import type { CurrentUserInfo } from '../types/user-types'; - import { getConfig } from './config'; import { ServerError, FetchTimeout } from './errors'; import sleep from './sleep'; import { uploadBlob, type UploadBlob } from './upload-blob'; export type FetchJSONOptions = $Shape<{| // null timeout means no timeout, which is the default for uploadBlob timeout: ?number, // in milliseconds blobUpload: boolean | UploadBlob, // the rest (onProgress, abortHandler) only work with blobUpload onProgress: (percent: number) => void, // abortHandler will receive an abort function once the upload starts abortHandler: (abort: () => void) => void, |}>; export type FetchJSONServerResponse = $Shape<{| cookieChange: ServerSessionChange, currentUserInfo?: ?CurrentUserInfo, error: string, payload: Object, |}>; // You'll notice that this is not the type of the fetchJSON function below. This // is because the first several parameters to that functon get bound in by the // helpers in lib/utils/action-utils.js. This type represents the form of the // fetchJSON function that gets passed to the action function in lib/actions. export type FetchJSON = ( endpoint: Endpoint, input: Object, options?: ?FetchJSONOptions, ) => Promise; type RequestData = {| input: { [key: string]: mixed }, cookie?: ?string, sessionID?: ?string, |}; // If cookie is undefined, then we will defer to the underlying environment to // handle cookies, and we won't worry about them. We do this on the web since // our cookies are httponly to protect against XSS attacks. On the other hand, // on native we want to keep track of the cookies since we don't trust the // underlying implementations and prefer for things to be explicit, and XSS // isn't a thing on native. Note that for native, cookie might be null // (indicating we don't have one), and we will then set an empty Cookie header. async function fetchJSON( cookie: ?string, setNewSession: (sessionChange: ClientSessionChange, error: ?string) => void, waitIfCookieInvalidated: () => Promise, cookieInvalidationRecovery: ( sessionChange: ClientSessionChange, ) => Promise, urlPrefix: string, sessionID: ?string, connectionStatus: ConnectionStatus, socketAPIHandler: ?SocketAPIHandler, endpoint: Endpoint, input: { [key: string]: mixed }, options?: ?FetchJSONOptions, ) { const possibleReplacement = await waitIfCookieInvalidated(); if (possibleReplacement) { return await possibleReplacement(endpoint, input, options); } if ( endpointIsSocketPreferred(endpoint) && connectionStatus === 'connected' && socketAPIHandler ) { try { return await socketAPIHandler({ endpoint, input }); } catch (e) { if (endpointIsSocketOnly(endpoint)) { throw e; } else if (e instanceof SocketOffline) { // nothing } else if (e instanceof SocketTimeout) { // nothing } else { throw e; } } } if (endpointIsSocketOnly(endpoint)) { throw new SocketOffline('socket_offline'); } const url = urlPrefix ? `${urlPrefix}/${endpoint}` : endpoint; let json; if (options && options.blobUpload) { const uploadBlobCallback = typeof options.blobUpload === 'function' ? options.blobUpload : uploadBlob; json = await uploadBlobCallback(url, cookie, sessionID, input, options); } else { const mergedData: RequestData = { input }; if (getConfig().setCookieOnRequest) { // We make sure that if setCookieOnRequest is true, we never set cookie to // undefined. null has a special meaning here: we don't currently have a // cookie, and we want the server to specify the new cookie it will generate // in the response body rather than the response header. See // session-types.js for more details on why we specify cookies in the body. mergedData.cookie = cookie ? cookie : null; } if (getConfig().setSessionIDOnRequest) { // We make sure that if setSessionIDOnRequest is true, we never set // sessionID to undefined. null has a special meaning here: we cannot // consider the cookieID to be a unique session identifier, but we do not // have a sessionID to use either. This should only happen when the user is // not logged in on web. mergedData.sessionID = sessionID ? sessionID : null; } const fetchPromise = (async (): Promise => { const response = await fetch(url, { method: 'POST', // This is necessary to allow cookie headers to get passed down to us credentials: 'same-origin', body: JSON.stringify(mergedData), headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', }, }); const text = await response.text(); try { return _cloneDeep(JSON.parse(text)); } catch (e) { console.log(text); throw e; } })(); const timeout = options && options.timeout ? options.timeout : fetchJSONTimeout; if (!timeout) { json = await fetchPromise; } else { const rejectPromise = (async () => { await sleep(timeout); throw new FetchTimeout( `fetchJSON timed out call to ${endpoint}`, endpoint, ); })(); json = await Promise.race([fetchPromise, rejectPromise]); } } const { cookieChange, error, payload, currentUserInfo } = json; const sessionChange: ?ServerSessionChange = cookieChange; if (sessionChange) { const { threadInfos, userInfos, ...rest } = sessionChange; const clientSessionChange = rest.cookieInvalidated ? rest : { cookieInvalidated: false, currentUserInfo, ...rest }; if (clientSessionChange.cookieInvalidated) { const maybeReplacement = await cookieInvalidationRecovery( clientSessionChange, ); if (maybeReplacement) { return await maybeReplacement(endpoint, input, options); } } setNewSession(clientSessionChange, error); } if (error) { throw new ServerError(error, payload); } return json; } export default fetchJSON; diff --git a/lib/utils/redux-utils.js b/lib/utils/redux-utils.js index 3cbec48e0..3aeaa7e10 100644 --- a/lib/utils/redux-utils.js +++ b/lib/utils/redux-utils.js @@ -1,88 +1,87 @@ // @flow import invariant from 'invariant'; import { connect as reactReduxConnect, useSelector as reactReduxUseSelector, } from 'react-redux'; import { serverCallStateSelector } from '../selectors/server-calls'; import type { AppState } from '../types/redux-types'; import type { ConnectionStatus } from '../types/socket-types'; import type { CurrentUserInfo } from '../types/user-types'; - import type { ServerCalls } from './action-utils'; import { includeDispatchActionProps, bindServerCalls } from './action-utils'; function connect( inputMapStateToProps: ?(state: S, ownProps: OP) => SP, serverCalls?: ?ServerCalls, includeDispatch?: boolean, ): * { const mapStateToProps = inputMapStateToProps; const serverCallExists = serverCalls && Object.keys(serverCalls).length > 0; let mapState = null; if (serverCallExists && mapStateToProps && mapStateToProps.length > 1) { mapState = ( state: S, ownProps: OP, ): { ...SP, cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, } => ({ ...mapStateToProps(state, ownProps), ...serverCallStateSelector(state), }); } else if (serverCallExists && mapStateToProps) { mapState = ( state: S, ): { ...SP, cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, } => ({ // $FlowFixMe ...mapStateToProps(state), ...serverCallStateSelector(state), }); } else if (mapStateToProps) { mapState = mapStateToProps; } else if (serverCallExists) { mapState = serverCallStateSelector; } const dispatchIncluded = includeDispatch === true || (includeDispatch === undefined && serverCallExists); if (dispatchIncluded && serverCallExists) { invariant(mapState && serverCalls, 'should be set'); return reactReduxConnect( mapState, includeDispatchActionProps, bindServerCalls(serverCalls), ); } else if (dispatchIncluded) { return reactReduxConnect(mapState, includeDispatchActionProps); } else if (serverCallExists) { invariant(mapState && serverCalls, 'should be set'); return reactReduxConnect(mapState, undefined, bindServerCalls(serverCalls)); } else { invariant(mapState, 'should be set'); return reactReduxConnect(mapState); } } function useSelector( selector: (state: AppState) => SS, equalityFn?: (a: SS, b: SS) => boolean, ): SS { return reactReduxUseSelector(selector, equalityFn); } export { connect, useSelector }; diff --git a/lib/utils/sanitization.js b/lib/utils/sanitization.js index 72b96e9b9..722e28324 100644 --- a/lib/utils/sanitization.js +++ b/lib/utils/sanitization.js @@ -1,62 +1,61 @@ // @flow import { setDeviceTokenActionTypes } from '../actions/device-actions'; import type { BaseAction, NativeAppState, AppState, } from '../types/redux-types'; - import { setNewSessionActionType } from './action-utils'; function sanitizeAction(action: BaseAction): BaseAction { if (action.type === setNewSessionActionType) { const { sessionChange } = action.payload; if (sessionChange.cookieInvalidated) { const { cookie, ...rest } = sessionChange; return { type: 'SET_NEW_SESSION', payload: { ...action.payload, sessionChange: { cookieInvalidated: true, ...rest }, }, }; } else { const { cookie, ...rest } = sessionChange; return { type: 'SET_NEW_SESSION', payload: { ...action.payload, sessionChange: { cookieInvalidated: false, ...rest }, }, }; } } else if (action.type === setDeviceTokenActionTypes.started) { return { type: 'SET_DEVICE_TOKEN_STARTED', payload: 'FAKE', loadingInfo: action.loadingInfo, }; } else if (action.type === setDeviceTokenActionTypes.success) { return { type: 'SET_DEVICE_TOKEN_SUCCESS', payload: 'FAKE', loadingInfo: action.loadingInfo, }; } return action; } function sanitizeState(state: AppState): AppState { if (state.cookie !== undefined && state.cookie !== null) { const oldState: NativeAppState = state; state = { ...oldState, cookie: null }; } if (state.deviceToken !== undefined && state.deviceToken !== null) { const oldState: NativeAppState = state; state = { ...oldState, deviceToken: null }; } return state; } export { sanitizeAction, sanitizeState }; diff --git a/native/account/forgot-password-panel.react.js b/native/account/forgot-password-panel.react.js index 7f9ed8431..9bf171429 100644 --- a/native/account/forgot-password-panel.react.js +++ b/native/account/forgot-password-panel.react.js @@ -1,189 +1,189 @@ // @flow import invariant from 'invariant'; +import React from 'react'; +import { StyleSheet, View, Alert, Keyboard } from 'react-native'; +import Animated from 'react-native-reanimated'; +import Icon from 'react-native-vector-icons/FontAwesome'; + import { forgotPasswordActionTypes, forgotPassword, } from 'lib/actions/user-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { oldValidUsernameRegex, validEmailRegex, } from 'lib/shared/account-utils'; import type { LoadingStatus } from 'lib/types/loading-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; -import React from 'react'; -import { StyleSheet, View, Alert, Keyboard } from 'react-native'; -import Animated from 'react-native-reanimated'; -import Icon from 'react-native-vector-icons/FontAwesome'; import { useSelector } from '../redux/redux-utils'; - import { TextInput, usernamePlaceholderSelector, } from './modal-components.react'; import { PanelButton, Panel } from './panel-components.react'; type BaseProps = {| +setActiveAlert: (activeAlert: boolean) => void, +opacityValue: Animated.Value, +onSuccess: () => void, |}; type Props = {| ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +usernamePlaceholder: string, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +forgotPassword: (usernameOrEmail: string) => Promise, |}; type State = {| +usernameOrEmailInputText: string, |}; class ForgotPasswordPanel extends React.PureComponent { state: State = { usernameOrEmailInputText: '', }; usernameOrEmailInput: ?TextInput; render() { return ( ); } usernameOrEmailInputRef = (usernameOrEmailInput: ?TextInput) => { this.usernameOrEmailInput = usernameOrEmailInput; }; onChangeUsernameOrEmailInputText = (text: string) => { this.setState({ usernameOrEmailInputText: text }); }; onSubmit = () => { this.props.setActiveAlert(true); if ( this.state.usernameOrEmailInputText.search(oldValidUsernameRegex) === -1 && this.state.usernameOrEmailInputText.search(validEmailRegex) === -1 ) { Alert.alert( 'Invalid username', 'Alphanumeric usernames or emails only', [{ text: 'OK', onPress: this.onUsernameOrEmailAlertAcknowledged }], { cancelable: false }, ); return; } Keyboard.dismiss(); this.props.dispatchActionPromise( forgotPasswordActionTypes, this.forgotPasswordAction(), ); }; onUsernameOrEmailAlertAcknowledged = () => { this.props.setActiveAlert(false); this.setState( { usernameOrEmailInputText: '', }, () => { invariant(this.usernameOrEmailInput, 'ref should exist'); this.usernameOrEmailInput.focus(); }, ); }; async forgotPasswordAction() { try { await this.props.forgotPassword(this.state.usernameOrEmailInputText); this.props.setActiveAlert(false); this.props.onSuccess(); } catch (e) { if (e.message === 'invalid_user') { Alert.alert( "User doesn't exist", 'No user with that username or email exists', [{ text: 'OK', onPress: this.onUsernameOrEmailAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUsernameOrEmailAlertAcknowledged }], { cancelable: false }, ); } throw e; } } } const styles = StyleSheet.create({ icon: { bottom: 8, left: 4, position: 'absolute', }, input: { paddingLeft: 35, }, }); const loadingStatusSelector = createLoadingStatusSelector( forgotPasswordActionTypes, ); export default React.memo(function ConnectedForgotPasswordPanel( props: BaseProps, ) { const loadingStatus = useSelector(loadingStatusSelector); const usernamePlaceholder = useSelector(usernamePlaceholderSelector); const dispatchActionPromise = useDispatchActionPromise(); const callForgotPassword = useServerCall(forgotPassword); return ( ); }); diff --git a/native/account/log-in-panel-container.react.js b/native/account/log-in-panel-container.react.js index 4a73300f8..5e12038ca 100644 --- a/native/account/log-in-panel-container.react.js +++ b/native/account/log-in-panel-container.react.js @@ -1,276 +1,276 @@ // @flow import invariant from 'invariant'; -import { connect } from 'lib/utils/redux-utils'; -import sleep from 'lib/utils/sleep'; import PropTypes from 'prop-types'; import * as React from 'react'; import { View, Text, StyleSheet } from 'react-native'; import Animated from 'react-native-reanimated'; import Icon from 'react-native-vector-icons/FontAwesome'; +import { connect } from 'lib/utils/redux-utils'; +import sleep from 'lib/utils/sleep'; + import type { AppState } from '../redux/redux-setup'; import { runTiming } from '../utils/animation-utils'; import { type StateContainer, stateContainerPropType, } from '../utils/state-container'; - import ForgotPasswordPanel from './forgot-password-panel.react'; import LogInPanel from './log-in-panel.react'; import type { InnerLogInPanel, LogInState } from './log-in-panel.react'; type LogInMode = 'log-in' | 'forgot-password' | 'forgot-password-success'; const modeNumbers: { [LogInMode]: number } = { 'log-in': 0, 'forgot-password': 1, 'forgot-password-success': 2, }; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, set, call, cond, eq, neq, lessThan, modulo, stopClock, interpolate, } = Animated; /* eslint-enable import/no-named-as-default-member */ type Props = {| setActiveAlert: (activeAlert: boolean) => void, opacityValue: Value, hideForgotPasswordLink: Value, logInState: StateContainer, innerRef: (container: ?LogInPanelContainer) => void, // Redux state windowWidth: number, |}; type State = {| logInMode: LogInMode, nextLogInMode: LogInMode, |}; class LogInPanelContainer extends React.PureComponent { static propTypes = { setActiveAlert: PropTypes.func.isRequired, opacityValue: PropTypes.object.isRequired, hideForgotPasswordLink: PropTypes.instanceOf(Value).isRequired, logInState: stateContainerPropType.isRequired, innerRef: PropTypes.func.isRequired, windowWidth: PropTypes.number.isRequired, }; logInPanel: ?InnerLogInPanel = null; panelTransitionTarget: Value; panelTransitionValue: Value; constructor(props: Props) { super(props); this.state = { logInMode: 'log-in', nextLogInMode: 'log-in', }; this.panelTransitionTarget = new Value(modeNumbers['log-in']); this.panelTransitionValue = this.panelTransition(); } proceedToNextMode = () => { this.setState({ logInMode: this.state.nextLogInMode }); }; panelTransition() { const panelTransition = new Value(-1); const prevPanelTransitionTarget = new Value(-1); const clock = new Clock(); return block([ cond(lessThan(panelTransition, 0), [ set(panelTransition, this.panelTransitionTarget), set(prevPanelTransitionTarget, this.panelTransitionTarget), ]), cond(neq(this.panelTransitionTarget, prevPanelTransitionTarget), [ stopClock(clock), set(prevPanelTransitionTarget, this.panelTransitionTarget), ]), cond( neq(panelTransition, this.panelTransitionTarget), set( panelTransition, runTiming(clock, panelTransition, this.panelTransitionTarget), ), ), cond(eq(modulo(panelTransition, 1), 0), call([], this.proceedToNextMode)), panelTransition, ]); } componentDidMount() { this.props.innerRef(this); } componentWillUnmount() { this.props.innerRef(null); } render() { const { windowWidth } = this.props; const logInPanelDynamicStyle = { left: interpolate(this.panelTransitionValue, { inputRange: [0, 2], outputRange: [0, windowWidth * -2], }), right: interpolate(this.panelTransitionValue, { inputRange: [0, 2], outputRange: [0, windowWidth * 2], }), }; const logInPanel = ( ); let forgotPasswordPanel = null; if ( this.state.nextLogInMode !== 'log-in' || this.state.logInMode !== 'log-in' ) { const forgotPasswordPanelDynamicStyle = { left: interpolate(this.panelTransitionValue, { inputRange: [0, 2], outputRange: [windowWidth, windowWidth * -1], }), right: interpolate(this.panelTransitionValue, { inputRange: [0, 2], outputRange: [windowWidth * -1, windowWidth], }), }; forgotPasswordPanel = ( ); } let forgotPasswordSuccess = null; if ( this.state.nextLogInMode === 'forgot-password-success' || this.state.logInMode === 'forgot-password-success' ) { const forgotPasswordSuccessDynamicStyle = { left: interpolate(this.panelTransitionValue, { inputRange: [0, 2], outputRange: [windowWidth * 2, 0], }), right: interpolate(this.panelTransitionValue, { inputRange: [0, 2], outputRange: [windowWidth * -2, 0], }), }; const successText = "Okay, we've sent that account an email. Check your inbox to " + 'complete the process.'; forgotPasswordSuccess = ( {successText} ); } return ( {logInPanel} {forgotPasswordPanel} {forgotPasswordSuccess} ); } logInPanelRef = (logInPanel: ?InnerLogInPanel) => { this.logInPanel = logInPanel; }; onPressForgotPassword = () => { this.props.hideForgotPasswordLink.setValue(1); this.setState({ nextLogInMode: 'forgot-password' }); this.panelTransitionTarget.setValue(modeNumbers['forgot-password']); }; backFromLogInMode = () => { if (this.state.nextLogInMode === 'log-in') { return false; } this.setState({ logInMode: this.state.nextLogInMode, nextLogInMode: 'log-in', }); invariant(this.logInPanel, 'ref should be set'); this.logInPanel.focusUsernameOrEmailInput(); this.props.hideForgotPasswordLink.setValue(0); this.panelTransitionTarget.setValue(modeNumbers['log-in']); return true; }; onForgotPasswordSuccess = () => { if (this.state.nextLogInMode === 'log-in') { return; } this.setState({ nextLogInMode: 'forgot-password-success' }); this.panelTransitionTarget.setValue(modeNumbers['forgot-password-success']); this.inCoupleSecondsNavigateToLogIn(); }; async inCoupleSecondsNavigateToLogIn() { await sleep(2350); this.backFromLogInMode(); } } const styles = StyleSheet.create({ forgotPasswordSuccessIcon: { marginTop: 40, textAlign: 'center', }, forgotPasswordSuccessText: { color: 'white', fontSize: 18, marginLeft: 20, marginRight: 20, marginTop: 10, textAlign: 'center', }, panel: { left: 0, position: 'absolute', right: 0, }, }); export default connect((state: AppState) => ({ windowWidth: state.dimensions.width, }))(LogInPanelContainer); diff --git a/native/account/log-in-panel.react.js b/native/account/log-in-panel.react.js index 52c5e367f..25ced7b45 100644 --- a/native/account/log-in-panel.react.js +++ b/native/account/log-in-panel.react.js @@ -1,355 +1,355 @@ // @flow import invariant from 'invariant'; +import * as React from 'react'; +import { View, StyleSheet, Alert, Keyboard, Platform } from 'react-native'; +import Animated from 'react-native-reanimated'; +import Icon from 'react-native-vector-icons/FontAwesome'; + import { logInActionTypes, logIn } from 'lib/actions/user-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { oldValidUsernameRegex, validEmailRegex, } from 'lib/shared/account-utils'; import type { LogInInfo, LogInExtraInfo, LogInResult, LogInStartingPayload, } from 'lib/types/account-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; -import * as React from 'react'; -import { View, StyleSheet, Alert, Keyboard, Platform } from 'react-native'; -import Animated from 'react-native-reanimated'; -import Icon from 'react-native-vector-icons/FontAwesome'; import { NavContext } from '../navigation/navigation-context'; import { useSelector } from '../redux/redux-utils'; import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors'; import type { StateContainer } from '../utils/state-container'; - import { TextInput, usernamePlaceholderSelector, } from './modal-components.react'; import { fetchNativeCredentials, setNativeCredentials, } from './native-credentials'; import { PanelButton, Panel } from './panel-components.react'; export type LogInState = {| +usernameOrEmailInputText: string, +passwordInputText: string, |}; type BaseProps = {| +setActiveAlert: (activeAlert: boolean) => void, +opacityValue: Animated.Value, +innerRef: (logInPanel: ?LogInPanel) => void, +state: StateContainer, |}; type Props = {| ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +logInExtraInfo: () => LogInExtraInfo, +usernamePlaceholder: string, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +logIn: (logInInfo: LogInInfo) => Promise, |}; class LogInPanel extends React.PureComponent { usernameOrEmailInput: ?TextInput; passwordInput: ?TextInput; componentDidMount() { this.props.innerRef(this); this.attemptToFetchCredentials(); } componentWillUnmount() { this.props.innerRef(null); } async attemptToFetchCredentials() { const credentials = await fetchNativeCredentials(); if (credentials) { this.props.state.setState({ usernameOrEmailInputText: credentials.username, passwordInputText: credentials.password, }); } } render() { return ( ); } usernameOrEmailInputRef = (usernameOrEmailInput: ?TextInput) => { this.usernameOrEmailInput = usernameOrEmailInput; if (Platform.OS === 'ios' && usernameOrEmailInput) { setTimeout(() => usernameOrEmailInput.focus()); } }; focusUsernameOrEmailInput = () => { invariant(this.usernameOrEmailInput, 'ref should be set'); this.usernameOrEmailInput.focus(); }; passwordInputRef = (passwordInput: ?TextInput) => { this.passwordInput = passwordInput; }; focusPasswordInput = () => { invariant(this.passwordInput, 'ref should be set'); this.passwordInput.focus(); }; onChangeUsernameOrEmailInputText = (text: string) => { this.props.state.setState({ usernameOrEmailInputText: text }); }; onUsernameOrEmailKeyPress = ( event: $ReadOnly<{ nativeEvent: $ReadOnly<{ key: string }> }>, ) => { const { key } = event.nativeEvent; if ( key.length > 1 && key !== 'Backspace' && key !== 'Enter' && this.props.state.state.passwordInputText.length === 0 ) { this.focusPasswordInput(); } }; onChangePasswordInputText = (text: string) => { this.props.state.setState({ passwordInputText: text }); }; onSubmit = () => { this.props.setActiveAlert(true); if ( this.props.state.state.usernameOrEmailInputText.search( oldValidUsernameRegex, ) === -1 && this.props.state.state.usernameOrEmailInputText.search( validEmailRegex, ) === -1 ) { Alert.alert( 'Invalid username', 'Alphanumeric usernames or emails only', [{ text: 'OK', onPress: this.onUsernameOrEmailAlertAcknowledged }], { cancelable: false }, ); return; } else if (this.props.state.state.passwordInputText === '') { Alert.alert( 'Empty password', 'Password cannot be empty', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); return; } Keyboard.dismiss(); const extraInfo = this.props.logInExtraInfo(); this.props.dispatchActionPromise( logInActionTypes, this.logInAction(extraInfo), undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); }; onUsernameOrEmailAlertAcknowledged = () => { this.props.setActiveAlert(false); this.props.state.setState( { usernameOrEmailInputText: '', }, () => { invariant(this.usernameOrEmailInput, 'ref should exist'); this.usernameOrEmailInput.focus(); }, ); }; async logInAction(extraInfo: LogInExtraInfo) { try { const result = await this.props.logIn({ usernameOrEmail: this.props.state.state.usernameOrEmailInputText, password: this.props.state.state.passwordInputText, ...extraInfo, }); this.props.setActiveAlert(false); await setNativeCredentials({ username: result.currentUserInfo.username, password: this.props.state.state.passwordInputText, }); return result; } catch (e) { if (e.message === 'invalid_parameters') { Alert.alert( 'Invalid username', "User doesn't exist", [{ text: 'OK', onPress: this.onUsernameOrEmailAlertAcknowledged }], { cancelable: false }, ); } else if (e.message === 'invalid_credentials') { Alert.alert( 'Incorrect password', 'The password you entered is incorrect', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); } else if (e.message === 'client_version_unsupported') { const app = Platform.select({ ios: 'Testflight', android: 'Play Store', }); Alert.alert( 'App out of date', "Your app version is pretty old, and the server doesn't know how " + `to speak to it anymore. Please use the ${app} app to update!`, [{ text: 'OK', onPress: this.onAppOutOfDateAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); } throw e; } } onPasswordAlertAcknowledged = () => { this.props.setActiveAlert(false); this.props.state.setState( { passwordInputText: '', }, () => { invariant(this.passwordInput, 'passwordInput ref unset'); this.passwordInput.focus(); }, ); }; onUnknownErrorAlertAcknowledged = () => { this.props.setActiveAlert(false); this.props.state.setState( { usernameOrEmailInputText: '', passwordInputText: '', }, () => { invariant(this.usernameOrEmailInput, 'ref should exist'); this.usernameOrEmailInput.focus(); }, ); }; onAppOutOfDateAlertAcknowledged = () => { this.props.setActiveAlert(false); }; } export type InnerLogInPanel = LogInPanel; const styles = StyleSheet.create({ icon: { bottom: 8, left: 4, position: 'absolute', }, input: { paddingLeft: 35, }, }); const loadingStatusSelector = createLoadingStatusSelector(logInActionTypes); export default React.memo(function ConnectedLogInPanel( props: BaseProps, ) { const loadingStatus = useSelector(loadingStatusSelector); const usernamePlaceholder = useSelector(usernamePlaceholderSelector); const navContext = React.useContext(NavContext); const logInExtraInfo = useSelector((state) => nativeLogInExtraInfoSelector({ redux: state, navContext, }), ); const dispatchActionPromise = useDispatchActionPromise(); const callLogIn = useServerCall(logIn); return ( ); }); diff --git a/native/account/logged-out-modal.react.js b/native/account/logged-out-modal.react.js index de05fc838..007b24162 100644 --- a/native/account/logged-out-modal.react.js +++ b/native/account/logged-out-modal.react.js @@ -1,807 +1,807 @@ // @flow import invariant from 'invariant'; -import { - appStartNativeCredentialsAutoLogIn, - appStartReduxLoggedInButInvalidCookie, -} from 'lib/actions/user-actions'; -import { isLoggedIn } from 'lib/selectors/user-selectors'; -import type { Dispatch } from 'lib/types/redux-types'; -import { fetchNewCookieFromNativeCredentials } from 'lib/utils/action-utils'; import _isEqual from 'lodash/fp/isEqual'; import * as React from 'react'; import { View, StyleSheet, Text, TouchableOpacity, Image, Keyboard, Platform, BackHandler, ActivityIndicator, } from 'react-native'; import Animated, { Easing } from 'react-native-reanimated'; import { SafeAreaView } from 'react-native-safe-area-context'; import Icon from 'react-native-vector-icons/FontAwesome'; import { useDispatch } from 'react-redux'; +import { + appStartNativeCredentialsAutoLogIn, + appStartReduxLoggedInButInvalidCookie, +} from 'lib/actions/user-actions'; +import { isLoggedIn } from 'lib/selectors/user-selectors'; +import type { Dispatch } from 'lib/types/redux-types'; +import { fetchNewCookieFromNativeCredentials } from 'lib/utils/action-utils'; + import ConnectedStatusBar from '../connected-status-bar.react'; import type { KeyboardEvent, EmitterSubscription } from '../keyboard/keyboard'; import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, } from '../keyboard/keyboard'; import { createIsForegroundSelector } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import { LoggedOutModalRouteName } from '../navigation/route-names'; import { resetUserStateActionType } from '../redux/action-types'; import { useSelector } from '../redux/redux-utils'; import { type DerivedDimensionsInfo, derivedDimensionsInfoSelector, } from '../selectors/dimensions-selectors'; import { splashStyleSelector } from '../splash'; import type { ImageStyle } from '../types/styles'; import { runTiming, ratchetAlongWithKeyboardHeight, } from '../utils/animation-utils'; import { type StateContainer, type StateChange, setStateForContainer, } from '../utils/state-container'; - import { splashBackgroundURI } from './background-info'; import LogInPanelContainer from './log-in-panel-container.react'; import type { LogInState } from './log-in-panel.react'; import RegisterPanel from './register-panel.react'; import type { RegisterState } from './register-panel.react'; let initialAppLoad = true; const safeAreaEdges = ['top', 'bottom']; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, set, call, cond, not, and, eq, neq, lessThan, greaterOrEq, add, sub, divide, max, stopClock, clockRunning, } = Animated; /* eslint-enable import/no-named-as-default-member */ type LoggedOutMode = 'loading' | 'prompt' | 'log-in' | 'register'; const modeNumbers: { [LoggedOutMode]: number } = { 'loading': 0, 'prompt': 1, 'log-in': 2, 'register': 3, }; function isPastPrompt(modeValue: Animated.Node) { return and( neq(modeValue, modeNumbers['loading']), neq(modeValue, modeNumbers['prompt']), ); } type Props = { // Navigation state +isForeground: boolean, // Redux state +rehydrateConcluded: boolean, +cookie: ?string, +urlPrefix: string, +loggedIn: boolean, +dimensions: DerivedDimensionsInfo, +splashStyle: ImageStyle, // Redux dispatch functions +dispatch: Dispatch, ... }; type State = {| +mode: LoggedOutMode, +logInState: StateContainer, +registerState: StateContainer, |}; class LoggedOutModal extends React.PureComponent { keyboardShowListener: ?EmitterSubscription; keyboardHideListener: ?EmitterSubscription; mounted = false; nextMode: LoggedOutMode = 'loading'; activeAlert = false; logInPanelContainer: ?LogInPanelContainer = null; contentHeight: Value; keyboardHeightValue = new Value(0); modeValue: Value; hideForgotPasswordLink = new Value(0); buttonOpacity: Value; panelPaddingTopValue: Value; footerPaddingTopValue: Value; panelOpacityValue: Value; forgotPasswordLinkOpacityValue: Value; constructor(props: Props) { super(props); // Man, this is a lot of boilerplate just to containerize some state. // Mostly due to Flow typing requirements... const setLogInState = setStateForContainer( this.guardedSetState, (change: $Shape) => (fullState: State) => ({ logInState: { ...fullState.logInState, state: { ...fullState.logInState.state, ...change }, }, }), ); const setRegisterState = setStateForContainer( this.guardedSetState, (change: $Shape) => (fullState: State) => ({ registerState: { ...fullState.registerState, state: { ...fullState.registerState.state, ...change }, }, }), ); this.state = { mode: props.rehydrateConcluded ? 'prompt' : 'loading', logInState: { state: { usernameOrEmailInputText: '', passwordInputText: '', }, setState: setLogInState, }, registerState: { state: { usernameInputText: '', emailInputText: '', passwordInputText: '', confirmPasswordInputText: '', }, setState: setRegisterState, }, }; if (props.rehydrateConcluded) { this.nextMode = 'prompt'; } this.contentHeight = new Value(props.dimensions.safeAreaHeight); this.modeValue = new Value(modeNumbers[this.nextMode]); this.buttonOpacity = new Value(props.rehydrateConcluded ? 1 : 0); this.panelPaddingTopValue = this.panelPaddingTop(); this.footerPaddingTopValue = this.footerPaddingTop(); this.panelOpacityValue = this.panelOpacity(); this.forgotPasswordLinkOpacityValue = this.forgotPasswordLinkOpacity(); } guardedSetState = (change: StateChange, callback?: () => mixed) => { if (this.mounted) { this.setState(change, callback); } }; setMode(newMode: LoggedOutMode) { this.nextMode = newMode; this.guardedSetState({ mode: newMode }); this.modeValue.setValue(modeNumbers[newMode]); } proceedToNextMode = () => { this.guardedSetState({ mode: this.nextMode }); }; componentDidMount() { this.mounted = true; if (this.props.rehydrateConcluded) { this.onInitialAppLoad(); } if (this.props.isForeground) { this.onForeground(); } } componentWillUnmount() { this.mounted = false; if (this.props.isForeground) { this.onBackground(); } } componentDidUpdate(prevProps: Props, prevState: State) { if (!prevProps.rehydrateConcluded && this.props.rehydrateConcluded) { this.setMode('prompt'); this.onInitialAppLoad(); } if (!prevProps.isForeground && this.props.isForeground) { this.onForeground(); } else if (prevProps.isForeground && !this.props.isForeground) { this.onBackground(); } if (this.state.mode === 'prompt' && prevState.mode !== 'prompt') { this.buttonOpacity.setValue(0); Animated.timing(this.buttonOpacity, { easing: Easing.out(Easing.ease), duration: 250, toValue: 1.0, }).start(); } const newContentHeight = this.props.dimensions.safeAreaHeight; const oldContentHeight = prevProps.dimensions.safeAreaHeight; if (newContentHeight !== oldContentHeight) { this.contentHeight.setValue(newContentHeight); } } onForeground() { this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); this.keyboardHideListener = addKeyboardDismissListener(this.keyboardHide); BackHandler.addEventListener('hardwareBackPress', this.hardwareBack); } onBackground() { if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardHideListener) { removeKeyboardListener(this.keyboardHideListener); this.keyboardHideListener = null; } BackHandler.removeEventListener('hardwareBackPress', this.hardwareBack); } // This gets triggered when an app is killed and restarted // Not when it is returned from being backgrounded async onInitialAppLoad() { if (!initialAppLoad) { return; } initialAppLoad = false; const { loggedIn, cookie, urlPrefix, dispatch } = this.props; const hasUserCookie = cookie && cookie.startsWith('user='); if (loggedIn && hasUserCookie) { return; } if (!__DEV__) { const actionSource = loggedIn ? appStartReduxLoggedInButInvalidCookie : appStartNativeCredentialsAutoLogIn; const sessionChange = await fetchNewCookieFromNativeCredentials( dispatch, cookie, urlPrefix, actionSource, ); if ( sessionChange && sessionChange.cookie && sessionChange.cookie.startsWith('user=') ) { // success! we can expect subsequent actions to fix up the state return; } } if (loggedIn || hasUserCookie) { this.props.dispatch({ type: resetUserStateActionType, payload: null, }); } } hardwareBack = () => { if (this.nextMode === 'log-in') { invariant(this.logInPanelContainer, 'ref should be set'); const returnValue = this.logInPanelContainer.backFromLogInMode(); if (returnValue) { return true; } } if (this.nextMode !== 'prompt') { this.goBackToPrompt(); return true; } return false; }; panelPaddingTop() { const headerHeight = Platform.OS === 'ios' ? 62.33 : 58.54; const promptButtonsSize = Platform.OS === 'ios' ? 40 : 61; const logInContainerSize = 165; const registerPanelSize = 246; // On large enough devices, we want to properly center the panels on screen. // But on smaller devices, this can lead to some issues: // - We need enough space below the log-in panel to render the // "Forgot password?" link // - On Android, ratchetAlongWithKeyboardHeight won't adjust the panel's // position when the keyboard size changes // To address these issues, we artifically increase the panel sizes so that // they get positioned a little higher than center on small devices. const smallDeviceThreshold = 600; const smallDeviceLogInContainerSize = 195; const smallDeviceRegisterPanelSize = 261; const containerSize = add( headerHeight, cond(not(isPastPrompt(this.modeValue)), promptButtonsSize, 0), cond( eq(this.modeValue, modeNumbers['log-in']), cond( lessThan(this.contentHeight, smallDeviceThreshold), smallDeviceLogInContainerSize, logInContainerSize, ), 0, ), cond( eq(this.modeValue, modeNumbers['register']), cond( lessThan(this.contentHeight, smallDeviceThreshold), smallDeviceRegisterPanelSize, registerPanelSize, ), 0, ), ); const potentialPanelPaddingTop = divide( max(sub(this.contentHeight, this.keyboardHeightValue, containerSize), 0), 2, ); const panelPaddingTop = new Value(-1); const targetPanelPaddingTop = new Value(-1); const prevModeValue = new Value(modeNumbers[this.nextMode]); const clock = new Clock(); const keyboardTimeoutClock = new Clock(); return block([ cond(lessThan(panelPaddingTop, 0), [ set(panelPaddingTop, potentialPanelPaddingTop), set(targetPanelPaddingTop, potentialPanelPaddingTop), ]), cond( lessThan(this.keyboardHeightValue, 0), [ runTiming(keyboardTimeoutClock, 0, 1, true, { duration: 500 }), cond( not(clockRunning(keyboardTimeoutClock)), set(this.keyboardHeightValue, 0), ), ], stopClock(keyboardTimeoutClock), ), cond( and( greaterOrEq(this.keyboardHeightValue, 0), neq(prevModeValue, this.modeValue), ), [ stopClock(clock), cond( neq(isPastPrompt(prevModeValue), isPastPrompt(this.modeValue)), set(targetPanelPaddingTop, potentialPanelPaddingTop), ), set(prevModeValue, this.modeValue), ], ), ratchetAlongWithKeyboardHeight(this.keyboardHeightValue, [ stopClock(clock), set(targetPanelPaddingTop, potentialPanelPaddingTop), ]), cond( neq(panelPaddingTop, targetPanelPaddingTop), set( panelPaddingTop, runTiming(clock, panelPaddingTop, targetPanelPaddingTop), ), ), panelPaddingTop, ]); } footerPaddingTop() { const textHeight = Platform.OS === 'ios' ? 17 : 19; const spacingAboveKeyboard = 15; const potentialFooterPaddingTop = max( sub( this.contentHeight, max(this.keyboardHeightValue, 0), textHeight, spacingAboveKeyboard, ), 0, ); const footerPaddingTop = new Value(-1); const targetFooterPaddingTop = new Value(-1); const clock = new Clock(); return block([ cond(lessThan(footerPaddingTop, 0), [ set(footerPaddingTop, potentialFooterPaddingTop), set(targetFooterPaddingTop, potentialFooterPaddingTop), ]), ratchetAlongWithKeyboardHeight(this.keyboardHeightValue, [ stopClock(clock), set(targetFooterPaddingTop, potentialFooterPaddingTop), ]), cond( neq(footerPaddingTop, targetFooterPaddingTop), set( footerPaddingTop, runTiming(clock, footerPaddingTop, targetFooterPaddingTop), ), ), footerPaddingTop, ]); } panelOpacity() { const targetPanelOpacity = isPastPrompt(this.modeValue); const panelOpacity = new Value(-1); const prevPanelOpacity = new Value(-1); const prevTargetPanelOpacity = new Value(-1); const clock = new Clock(); return block([ cond(lessThan(panelOpacity, 0), [ set(panelOpacity, targetPanelOpacity), set(prevPanelOpacity, targetPanelOpacity), set(prevTargetPanelOpacity, targetPanelOpacity), ]), cond(greaterOrEq(this.keyboardHeightValue, 0), [ cond(neq(targetPanelOpacity, prevTargetPanelOpacity), [ stopClock(clock), set(prevTargetPanelOpacity, targetPanelOpacity), ]), cond( neq(panelOpacity, targetPanelOpacity), set(panelOpacity, runTiming(clock, panelOpacity, targetPanelOpacity)), ), ]), cond( and(eq(panelOpacity, 0), neq(prevPanelOpacity, 0)), call([], this.proceedToNextMode), ), set(prevPanelOpacity, panelOpacity), panelOpacity, ]); } forgotPasswordLinkOpacity() { const targetForgotPasswordLinkOpacity = and( eq(this.modeValue, modeNumbers['log-in']), not(this.hideForgotPasswordLink), ); const forgotPasswordLinkOpacity = new Value(0); const prevTargetForgotPasswordLinkOpacity = new Value(0); const clock = new Clock(); return block([ cond(greaterOrEq(this.keyboardHeightValue, 0), [ cond( neq( targetForgotPasswordLinkOpacity, prevTargetForgotPasswordLinkOpacity, ), [ stopClock(clock), set( prevTargetForgotPasswordLinkOpacity, targetForgotPasswordLinkOpacity, ), ], ), cond( neq(forgotPasswordLinkOpacity, targetForgotPasswordLinkOpacity), set( forgotPasswordLinkOpacity, runTiming( clock, forgotPasswordLinkOpacity, targetForgotPasswordLinkOpacity, ), ), ), ]), forgotPasswordLinkOpacity, ]); } keyboardShow = (event: KeyboardEvent) => { if (_isEqual(event.startCoordinates)(event.endCoordinates)) { return; } const keyboardHeight = Platform.select({ // Android doesn't include the bottomInset in this height measurement android: event.endCoordinates.height, default: Math.max( event.endCoordinates.height - this.props.dimensions.bottomInset, 0, ), }); this.keyboardHeightValue.setValue(keyboardHeight); }; keyboardHide = () => { if (!this.activeAlert) { this.keyboardHeightValue.setValue(0); } }; setActiveAlert = (activeAlert: boolean) => { this.activeAlert = activeAlert; }; goBackToPrompt = () => { this.nextMode = 'prompt'; this.keyboardHeightValue.setValue(0); this.modeValue.setValue(modeNumbers['prompt']); Keyboard.dismiss(); }; render() { let panel = null; let buttons = null; if (this.state.mode === 'log-in') { panel = ( ); } else if (this.state.mode === 'register') { panel = ( ); } else if (this.state.mode === 'prompt') { const opacityStyle = { opacity: this.buttonOpacity }; buttons = ( LOG IN SIGN UP ); } else if (this.state.mode === 'loading') { panel = ( ); } let forgotPasswordLink = null; if (this.state.mode === 'log-in') { const reanimatedStyle = { top: this.footerPaddingTopValue, opacity: this.forgotPasswordLinkOpacityValue, }; forgotPasswordLink = ( Forgot password? ); } const windowWidth = this.props.dimensions.width; const buttonStyle = { opacity: this.panelOpacityValue, left: windowWidth < 360 ? 28 : 40, }; const padding = { paddingTop: this.panelPaddingTopValue }; const animatedContent = ( SquadCal {panel} ); const backgroundSource = { uri: splashBackgroundURI }; return ( {animatedContent} {buttons} {forgotPasswordLink} ); } logInPanelContainerRef = (logInPanelContainer: ?LogInPanelContainer) => { this.logInPanelContainer = logInPanelContainer; }; onPressLogIn = () => { if (Platform.OS !== 'ios') { // For some strange reason, iOS's password management logic doesn't // realize that the username and password fields in LogInPanel are related // if the username field gets focused on mount. To avoid this issue we // need the username and password fields to both appear on-screen before // we focus the username field. However, when we set keyboardHeightValue // to -1 here, we are telling our Reanimated logic to wait until the // keyboard appears before showing LogInPanel. Since we need LogInPanel // to appear before the username field is focused, we need to avoid this // behavior on iOS. this.keyboardHeightValue.setValue(-1); } this.setMode('log-in'); }; onPressRegister = () => { this.keyboardHeightValue.setValue(-1); this.setMode('register'); }; onPressForgotPassword = () => { invariant(this.logInPanelContainer, 'ref should be set'); this.logInPanelContainer.onPressForgotPassword(); }; } const styles = StyleSheet.create({ animationContainer: { flex: 1, }, backButton: { position: 'absolute', top: 13, }, button: { backgroundColor: '#FFFFFFAA', borderRadius: 6, marginBottom: 10, marginLeft: 40, marginRight: 40, marginTop: 10, paddingBottom: 6, paddingLeft: 18, paddingRight: 18, paddingTop: 6, }, buttonContainer: { bottom: 0, left: 0, paddingBottom: 20, position: 'absolute', right: 0, }, buttonText: { color: '#000000FF', fontFamily: 'OpenSans-Semibold', fontSize: 22, textAlign: 'center', }, container: { backgroundColor: 'transparent', flex: 1, }, forgotPasswordText: { color: '#8899FF', }, forgotPasswordTextContainer: { alignSelf: 'flex-end', position: 'absolute', right: 20, }, header: { color: 'white', fontFamily: 'Anaheim-Regular', fontSize: 48, textAlign: 'center', }, loadingIndicator: { paddingTop: 15, }, modalBackground: { bottom: 0, left: 0, position: 'absolute', right: 0, top: 0, }, }); const isForegroundSelector = createIsForegroundSelector( LoggedOutModalRouteName, ); export default React.memo<{ ... }>(function ConnectedLoggedOutModal(props: { ... }) { const navContext = React.useContext(NavContext); const isForeground = isForegroundSelector(navContext); const rehydrateConcluded = useSelector( (state) => !!(state._persist && state._persist.rehydrated && navContext), ); const cookie = useSelector((state) => state.cookie); const urlPrefix = useSelector((state) => state.urlPrefix); const loggedIn = useSelector(isLoggedIn); const dimensions = useSelector(derivedDimensionsInfoSelector); const splashStyle = useSelector(splashStyleSelector); const dispatch = useDispatch(); return ( ); }); diff --git a/native/account/panel-components.react.js b/native/account/panel-components.react.js index 251451609..0fe9b56be 100644 --- a/native/account/panel-components.react.js +++ b/native/account/panel-components.react.js @@ -1,217 +1,218 @@ // @flow -import type { LoadingStatus } from 'lib/types/loading-types'; -import { loadingStatusPropType } from 'lib/types/loading-types'; -import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; import * as React from 'react'; import { View, ActivityIndicator, Text, StyleSheet, ScrollView, LayoutAnimation, ViewPropTypes, } from 'react-native'; import Animated from 'react-native-reanimated'; import Icon from 'react-native-vector-icons/FontAwesome'; +import { loadingStatusPropType } from 'lib/types/loading-types'; +import type { LoadingStatus } from 'lib/types/loading-types'; +import { connect } from 'lib/utils/redux-utils'; + import Button from '../components/button.react'; import type { KeyboardEvent, EmitterSubscription } from '../keyboard/keyboard'; import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, } from '../keyboard/keyboard'; import { type DimensionsInfo, dimensionsInfoPropType, } from '../redux/dimensions-updater.react'; import type { AppState } from '../redux/redux-setup'; import type { ViewStyle } from '../types/styles'; type ButtonProps = {| text: string, loadingStatus: LoadingStatus, onSubmit: () => void, |}; class PanelButton extends React.PureComponent { static propTypes = { text: PropTypes.string.isRequired, loadingStatus: loadingStatusPropType.isRequired, onSubmit: PropTypes.func.isRequired, }; render() { let buttonIcon; if (this.props.loadingStatus === 'loading') { buttonIcon = ( ); } else { buttonIcon = ( ); } return ( ); } } const scrollViewBelow = 568; type PanelProps = {| opacityValue: Animated.Value, children: React.Node, style?: ViewStyle, dimensions: DimensionsInfo, |}; type PanelState = {| keyboardHeight: number, |}; class InnerPanel extends React.PureComponent { static propTypes = { opacityValue: PropTypes.object.isRequired, children: PropTypes.node.isRequired, style: ViewPropTypes.style, dimensions: dimensionsInfoPropType.isRequired, }; state: PanelState = { keyboardHeight: 0, }; keyboardShowListener: ?EmitterSubscription; keyboardHideListener: ?EmitterSubscription; componentDidMount() { this.keyboardShowListener = addKeyboardShowListener(this.keyboardHandler); this.keyboardHideListener = addKeyboardDismissListener( this.keyboardHandler, ); } componentWillUnmount() { if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardHideListener) { removeKeyboardListener(this.keyboardHideListener); this.keyboardHideListener = null; } } keyboardHandler = (event: ?KeyboardEvent) => { const frameEdge = this.props.dimensions.height - this.props.dimensions.bottomInset; const keyboardHeight = event ? frameEdge - event.endCoordinates.screenY : 0; if (keyboardHeight === this.state.keyboardHeight) { return; } const windowHeight = this.props.dimensions.height; if ( windowHeight < scrollViewBelow && event && event.duration && event.easing ) { LayoutAnimation.configureNext({ duration: event.duration, update: { duration: event.duration, type: LayoutAnimation.Types[event.easing] || 'keyboard', }, }); } this.setState({ keyboardHeight }); }; render() { const windowHeight = this.props.dimensions.height; const containerStyle = { opacity: this.props.opacityValue, marginTop: windowHeight < 600 ? 15 : 40, }; const content = ( {this.props.children} ); if (windowHeight >= scrollViewBelow) { return content; } const scrollViewStyle = { paddingBottom: 73.5 + this.state.keyboardHeight, }; return ( {content} ); } } const Panel = connect((state: AppState) => ({ dimensions: state.dimensions, }))(InnerPanel); const styles = StyleSheet.create({ container: { backgroundColor: '#FFFFFFAA', borderRadius: 6, marginLeft: 20, marginRight: 20, paddingBottom: 37, paddingLeft: 18, paddingRight: 18, paddingTop: 6, }, loadingIndicatorContainer: { paddingBottom: 2, width: 14, }, submitButton: { borderBottomRightRadius: 6, bottom: 0, position: 'absolute', right: 0, }, submitContentContainer: { alignItems: 'flex-end', flexDirection: 'row', paddingHorizontal: 18, paddingVertical: 6, }, submitContentIconContainer: { paddingBottom: 5, width: 14, }, submitContentText: { color: '#555', fontFamily: 'OpenSans-Semibold', fontSize: 18, paddingRight: 7, }, }); export { PanelButton, Panel }; diff --git a/native/account/register-panel.react.js b/native/account/register-panel.react.js index 11e5382e9..fba200c45 100644 --- a/native/account/register-panel.react.js +++ b/native/account/register-panel.react.js @@ -1,467 +1,467 @@ // @flow import invariant from 'invariant'; +import React from 'react'; +import { View, StyleSheet, Platform, Keyboard, Alert } from 'react-native'; +import Animated from 'react-native-reanimated'; +import Icon from 'react-native-vector-icons/FontAwesome'; + import { registerActionTypes, register } from 'lib/actions/user-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { validUsernameRegex, validEmailRegex } from 'lib/shared/account-utils'; import type { RegisterInfo, LogInExtraInfo, RegisterResult, LogInStartingPayload, } from 'lib/types/account-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; -import React from 'react'; -import { View, StyleSheet, Platform, Keyboard, Alert } from 'react-native'; -import Animated from 'react-native-reanimated'; -import Icon from 'react-native-vector-icons/FontAwesome'; import { NavContext } from '../navigation/navigation-context'; import { useSelector } from '../redux/redux-utils'; import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors'; import { type StateContainer } from '../utils/state-container'; - import { TextInput } from './modal-components.react'; import { setNativeCredentials } from './native-credentials'; import { PanelButton, Panel } from './panel-components.react'; export type RegisterState = {| +usernameInputText: string, +emailInputText: string, +passwordInputText: string, +confirmPasswordInputText: string, |}; type BaseProps = {| +setActiveAlert: (activeAlert: boolean) => void, +opacityValue: Animated.Value, +state: StateContainer, |}; type Props = {| ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +logInExtraInfo: () => LogInExtraInfo, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +register: (registerInfo: RegisterInfo) => Promise, |}; type State = {| +confirmPasswordFocused: boolean, |}; class RegisterPanel extends React.PureComponent { state: State = { confirmPasswordFocused: false, }; usernameInput: ?TextInput; emailInput: ?TextInput; passwordInput: ?TextInput; confirmPasswordInput: ?TextInput; passwordBeingAutoFilled = false; render() { let confirmPasswordTextInputExtraProps; if ( Platform.OS !== 'ios' || this.state.confirmPasswordFocused || this.props.state.state.confirmPasswordInputText.length > 0 ) { confirmPasswordTextInputExtraProps = { secureTextEntry: true, textContentType: 'password', }; } let onPasswordKeyPress; if (Platform.OS === 'ios') { onPasswordKeyPress = this.onPasswordKeyPress; } return ( ); } usernameInputRef = (usernameInput: ?TextInput) => { this.usernameInput = usernameInput; }; emailInputRef = (emailInput: ?TextInput) => { this.emailInput = emailInput; }; passwordInputRef = (passwordInput: ?TextInput) => { this.passwordInput = passwordInput; }; confirmPasswordInputRef = (confirmPasswordInput: ?TextInput) => { this.confirmPasswordInput = confirmPasswordInput; }; focusUsernameInput = () => { invariant(this.usernameInput, 'ref should be set'); this.usernameInput.focus(); }; focusPasswordInput = () => { invariant(this.passwordInput, 'ref should be set'); this.passwordInput.focus(); }; focusConfirmPasswordInput = () => { invariant(this.confirmPasswordInput, 'ref should be set'); this.confirmPasswordInput.focus(); }; onChangeUsernameInputText = (text: string) => { this.props.state.setState({ usernameInputText: text }); }; onChangeEmailInputText = (text: string) => { this.props.state.setState({ emailInputText: text }); if (this.props.state.state.emailInputText.length === 0 && text.length > 1) { this.focusUsernameInput(); } }; onEmailBlur = () => { const trimmedEmail = this.props.state.state.emailInputText.trim(); if (trimmedEmail !== this.props.state.state.emailInputText) { this.props.state.setState({ emailInputText: trimmedEmail }); } }; onChangePasswordInputText = (text: string) => { const stateUpdate = {}; stateUpdate.passwordInputText = text; if (this.passwordBeingAutoFilled) { this.passwordBeingAutoFilled = false; stateUpdate.confirmPasswordInputText = text; } this.props.state.setState(stateUpdate); }; onPasswordKeyPress = ( event: $ReadOnly<{ nativeEvent: $ReadOnly<{ key: string }> }>, ) => { const { key } = event.nativeEvent; if ( key.length > 1 && key !== 'Backspace' && key !== 'Enter' && this.props.state.state.confirmPasswordInputText.length === 0 ) { this.passwordBeingAutoFilled = true; } }; onChangeConfirmPasswordInputText = (text: string) => { this.props.state.setState({ confirmPasswordInputText: text }); }; onConfirmPasswordFocus = () => { this.setState({ confirmPasswordFocused: true }); }; onSubmit = () => { this.props.setActiveAlert(true); if (this.props.state.state.passwordInputText === '') { Alert.alert( 'Empty password', 'Password cannot be empty', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); } else if ( this.props.state.state.passwordInputText !== this.props.state.state.confirmPasswordInputText ) { Alert.alert( "Passwords don't match", 'Password fields must contain the same password', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); } else if ( this.props.state.state.usernameInputText.search(validUsernameRegex) === -1 ) { Alert.alert( 'Invalid username', 'Usernames must be at least six characters long, start with either a ' + 'letter or a number, and may contain only letters, numbers, or the ' + 'characters “-” and “_”', [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); } else if ( this.props.state.state.emailInputText.search(validEmailRegex) === -1 ) { Alert.alert( 'Invalid email address', 'Valid email addresses only', [{ text: 'OK', onPress: this.onEmailAlertAcknowledged }], { cancelable: false }, ); } else { Keyboard.dismiss(); const extraInfo = this.props.logInExtraInfo(); this.props.dispatchActionPromise( registerActionTypes, this.registerAction(extraInfo), undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); } }; onPasswordAlertAcknowledged = () => { this.props.setActiveAlert(false); this.props.state.setState( { passwordInputText: '', confirmPasswordInputText: '', }, () => { invariant(this.passwordInput, 'ref should exist'); this.passwordInput.focus(); }, ); }; onUsernameAlertAcknowledged = () => { this.props.setActiveAlert(false); this.props.state.setState( { usernameInputText: '', }, () => { invariant(this.usernameInput, 'ref should exist'); this.usernameInput.focus(); }, ); }; onEmailAlertAcknowledged = () => { this.props.setActiveAlert(false); this.props.state.setState( { emailInputText: '', }, () => { invariant(this.emailInput, 'ref should exist'); this.emailInput.focus(); }, ); }; async registerAction(extraInfo: LogInExtraInfo) { try { const result = await this.props.register({ username: this.props.state.state.usernameInputText, email: this.props.state.state.emailInputText, password: this.props.state.state.passwordInputText, ...extraInfo, }); this.props.setActiveAlert(false); await setNativeCredentials({ username: result.currentUserInfo.username, password: this.props.state.state.passwordInputText, }); return result; } catch (e) { if (e.message === 'username_taken') { Alert.alert( 'Username taken', 'An account with that username already exists', [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); } else if (e.message === 'email_taken') { Alert.alert( 'Email taken', 'An account with that email already exists', [{ text: 'OK', onPress: this.onEmailAlertAcknowledged }], { cancelable: false }, ); } else if (e.message === 'client_version_unsupported') { const app = Platform.select({ ios: 'Testflight', android: 'Play Store', }); Alert.alert( 'App out of date', "Your app version is pretty old, and the server doesn't know how " + `to speak to it anymore. Please use the ${app} app to update!`, [{ text: 'OK', onPress: this.onAppOutOfDateAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); } throw e; } } onUnknownErrorAlertAcknowledged = () => { this.props.setActiveAlert(false); this.props.state.setState( { usernameInputText: '', emailInputText: '', passwordInputText: '', confirmPasswordInputText: '', }, () => { invariant(this.usernameInput, 'ref should exist'); this.usernameInput.focus(); }, ); }; onAppOutOfDateAlertAcknowledged = () => { this.props.setActiveAlert(false); }; } const styles = StyleSheet.create({ container: { paddingBottom: Platform.OS === 'ios' ? 37 : 36, zIndex: 2, }, envelopeIcon: { bottom: 10, left: 3, }, icon: { bottom: 8, left: 4, position: 'absolute', }, input: { paddingLeft: 35, }, }); const loadingStatusSelector = createLoadingStatusSelector(registerActionTypes); export default React.memo(function ConnectedRegisterPanel( props: BaseProps, ) { const loadingStatus = useSelector(loadingStatusSelector); const navContext = React.useContext(NavContext); const logInExtraInfo = useSelector((state) => nativeLogInExtraInfoSelector({ redux: state, navContext, }), ); const dispatchActionPromise = useDispatchActionPromise(); const callRegister = useServerCall(register); return ( ); }); diff --git a/native/account/reset-password-panel.react.js b/native/account/reset-password-panel.react.js index 3dc7f06a2..94d9c5c9d 100644 --- a/native/account/reset-password-panel.react.js +++ b/native/account/reset-password-panel.react.js @@ -1,307 +1,307 @@ // @flow import invariant from 'invariant'; +import React from 'react'; +import { + Alert, + StyleSheet, + Keyboard, + View, + Text, + Platform, +} from 'react-native'; +import Animated from 'react-native-reanimated'; +import Icon from 'react-native-vector-icons/FontAwesome'; + import { resetPasswordActionTypes, resetPassword, } from 'lib/actions/user-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import type { UpdatePasswordInfo, LogInExtraInfo, LogInResult, LogInStartingPayload, } from 'lib/types/account-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; -import React from 'react'; -import { - Alert, - StyleSheet, - Keyboard, - View, - Text, - Platform, -} from 'react-native'; -import Animated from 'react-native-reanimated'; -import Icon from 'react-native-vector-icons/FontAwesome'; import { NavContext } from '../navigation/navigation-context'; import { useSelector } from '../redux/redux-utils'; import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors'; - import { TextInput } from './modal-components.react'; import { PanelButton, Panel } from './panel-components.react'; type BaseProps = {| +verifyCode: string, +username: string, +onSuccess: () => Promise, +setActiveAlert: (activeAlert: boolean) => void, +opacityValue: Animated.Value, |}; type Props = {| ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +logInExtraInfo: () => LogInExtraInfo, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +resetPassword: (info: UpdatePasswordInfo) => Promise, |}; type State = {| +passwordInputText: string, +confirmPasswordInputText: string, |}; class ResetPasswordPanel extends React.PureComponent { state: State = { passwordInputText: '', confirmPasswordInputText: '', }; passwordInput: ?TextInput; confirmPasswordInput: ?TextInput; passwordBeingAutoFilled = false; render() { let onPasswordKeyPress; if (Platform.OS === 'ios') { onPasswordKeyPress = this.onPasswordKeyPress; } return ( {this.props.username} ); } passwordInputRef = (passwordInput: ?TextInput) => { this.passwordInput = passwordInput; }; confirmPasswordInputRef = (confirmPasswordInput: ?TextInput) => { this.confirmPasswordInput = confirmPasswordInput; }; focusConfirmPasswordInput = () => { invariant(this.confirmPasswordInput, 'ref should be set'); this.confirmPasswordInput.focus(); }; onChangePasswordInputText = (text: string) => { const stateUpdate = {}; stateUpdate.passwordInputText = text; if (this.passwordBeingAutoFilled) { this.passwordBeingAutoFilled = false; stateUpdate.confirmPasswordInputText = text; } this.setState(stateUpdate); }; onPasswordKeyPress = ( event: $ReadOnly<{ nativeEvent: $ReadOnly<{ key: string }> }>, ) => { const { key } = event.nativeEvent; if ( key.length > 1 && key !== 'Backspace' && key !== 'Enter' && this.state.confirmPasswordInputText.length === 0 ) { this.passwordBeingAutoFilled = true; } }; onChangeConfirmPasswordInputText = (text: string) => { this.setState({ confirmPasswordInputText: text }); }; onSubmit = () => { this.props.setActiveAlert(true); if (this.state.passwordInputText === '') { Alert.alert( 'Empty password', 'Password cannot be empty', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); return; } else if ( this.state.passwordInputText !== this.state.confirmPasswordInputText ) { Alert.alert( "Passwords don't match", 'Password fields must contain the same password', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); return; } Keyboard.dismiss(); const extraInfo = this.props.logInExtraInfo(); this.props.dispatchActionPromise( resetPasswordActionTypes, this.resetPasswordAction(extraInfo), undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); }; onPasswordAlertAcknowledged = () => { this.props.setActiveAlert(false); this.setState( { passwordInputText: '', confirmPasswordInputText: '', }, () => { invariant(this.passwordInput, 'ref should exist'); this.passwordInput.focus(); }, ); }; async resetPasswordAction(extraInfo: LogInExtraInfo) { try { const result = await this.props.resetPassword({ ...extraInfo, code: this.props.verifyCode, password: this.state.passwordInputText, }); this.props.setActiveAlert(false); await this.props.onSuccess(); return result; } catch (e) { if (e.message === 'client_version_unsupported') { const app = Platform.select({ ios: 'Testflight', android: 'Play Store', }); Alert.alert( 'App out of date', "Your app version is pretty old, and the server doesn't know how " + `to speak to it anymore. Please use the ${app} app to update!`, [{ text: 'OK', onPress: this.onAppOutOfDateAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); throw e; } } } onAppOutOfDateAlertAcknowledged = () => { this.props.setActiveAlert(false); }; } const styles = StyleSheet.create({ container: { marginTop: 0, }, icon: { bottom: 8, left: 4, position: 'absolute', }, input: { paddingLeft: 35, }, usernameContainer: { borderBottomColor: '#BBBBBB', borderBottomWidth: 1, paddingLeft: 35, }, usernameText: { color: '#444', fontSize: 20, height: 40, paddingTop: 8, }, }); const loadingStatusSelector = createLoadingStatusSelector( resetPasswordActionTypes, ); export default React.memo(function ConnectedResetPasswordPanel( props: BaseProps, ) { const loadingStatus = useSelector(loadingStatusSelector); const navContext = React.useContext(NavContext); const logInExtraInfo = useSelector((state) => nativeLogInExtraInfoSelector({ redux: state, navContext, }), ); const dispatchActionPromise = useDispatchActionPromise(); const callResetPassword = useServerCall(resetPassword); return ( ); }); diff --git a/native/account/resolve-invalidated-cookie.js b/native/account/resolve-invalidated-cookie.js index ba9ed97d4..b4ae214f5 100644 --- a/native/account/resolve-invalidated-cookie.js +++ b/native/account/resolve-invalidated-cookie.js @@ -1,63 +1,62 @@ // @flow import { logInActionTypes, logIn } from 'lib/actions/user-actions'; import type { LogInActionSource } from 'lib/types/account-types'; import type { DispatchRecoveryAttempt } from 'lib/utils/action-utils'; import type { FetchJSON } from 'lib/utils/fetch-json'; import { getGlobalNavContext } from '../navigation/icky-global'; import { store } from '../redux/redux-setup'; import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors'; - import { fetchNativeKeychainCredentials, getNativeSharedWebCredentials, } from './native-credentials'; async function resolveInvalidatedCookie( fetchJSON: FetchJSON, dispatchRecoveryAttempt: DispatchRecoveryAttempt, source?: LogInActionSource, ) { const keychainCredentials = await fetchNativeKeychainCredentials(); if (keychainCredentials) { const extraInfo = nativeLogInExtraInfoSelector({ redux: store.getState(), navContext: getGlobalNavContext(), })(); const { calendarQuery } = extraInfo; const newCookie = await dispatchRecoveryAttempt( logInActionTypes, logIn(fetchJSON, { usernameOrEmail: keychainCredentials.username, password: keychainCredentials.password, source, ...extraInfo, }), { calendarQuery }, ); if (newCookie) { return; } } const sharedWebCredentials = getNativeSharedWebCredentials(); if (sharedWebCredentials) { const extraInfo = nativeLogInExtraInfoSelector({ redux: store.getState(), navContext: getGlobalNavContext(), })(); const { calendarQuery } = extraInfo; await dispatchRecoveryAttempt( logInActionTypes, logIn(fetchJSON, { usernameOrEmail: sharedWebCredentials.username, password: sharedWebCredentials.password, source, ...extraInfo, }), { calendarQuery }, ); } } export { resolveInvalidatedCookie }; diff --git a/native/account/verification-modal.react.js b/native/account/verification-modal.react.js index f71399e8f..ebee0c478 100644 --- a/native/account/verification-modal.react.js +++ b/native/account/verification-modal.react.js @@ -1,573 +1,573 @@ // @flow import invariant from 'invariant'; +import * as React from 'react'; +import { + Image, + Text, + View, + StyleSheet, + ActivityIndicator, + Platform, + Keyboard, + TouchableHighlight, +} from 'react-native'; +import Animated from 'react-native-reanimated'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import Icon from 'react-native-vector-icons/FontAwesome'; + import { handleVerificationCodeActionTypes, handleVerificationCode, } from 'lib/actions/user-actions'; import { registerFetchKey } from 'lib/reducers/loading-reducer'; import { type VerifyField, verifyField, type HandleVerificationCodeResult, } from 'lib/types/verify-types'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; import sleep from 'lib/utils/sleep'; -import * as React from 'react'; -import { - Image, - Text, - View, - StyleSheet, - ActivityIndicator, - Platform, - Keyboard, - TouchableHighlight, -} from 'react-native'; -import Animated from 'react-native-reanimated'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import Icon from 'react-native-vector-icons/FontAwesome'; import ConnectedStatusBar from '../connected-status-bar.react'; import type { KeyboardEvent } from '../keyboard/keyboard'; import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, } from '../keyboard/keyboard'; import { createIsForegroundSelector } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import type { RootNavigationProp } from '../navigation/root-navigator.react'; import { VerificationModalRouteName } from '../navigation/route-names'; import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { type DerivedDimensionsInfo, derivedDimensionsInfoSelector, } from '../selectors/dimensions-selectors'; import { splashStyleSelector } from '../splash'; import type { ImageStyle } from '../types/styles'; import { runTiming, ratchetAlongWithKeyboardHeight, } from '../utils/animation-utils'; - import { splashBackgroundURI } from './background-info'; import ResetPasswordPanel from './reset-password-panel.react'; const safeAreaEdges = ['top', 'bottom']; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, set, call, cond, not, and, eq, neq, lessThan, greaterOrEq, sub, divide, max, stopClock, clockRunning, } = Animated; /* eslint-enable import/no-named-as-default-member */ export type VerificationModalParams = {| +verifyCode: string, |}; type VerificationModalMode = 'simple-text' | 'reset-password'; const modeNumbers: { [VerificationModalMode]: number } = { 'simple-text': 0, 'reset-password': 1, }; type BaseProps = {| +navigation: RootNavigationProp<'VerificationModal'>, +route: NavigationRoute<'VerificationModal'>, |}; type Props = {| ...BaseProps, // Navigation state +isForeground: boolean, // Redux state +dimensions: DerivedDimensionsInfo, +splashStyle: ImageStyle, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +handleVerificationCode: ( code: string, ) => Promise, |}; type State = {| +mode: VerificationModalMode, +verifyField: ?VerifyField, +errorMessage: ?string, +resetPasswordUsername: ?string, |}; class VerificationModal extends React.PureComponent { keyboardShowListener: ?Object; keyboardHideListener: ?Object; activeAlert = false; nextMode: VerificationModalMode = 'simple-text'; contentHeight: Value; keyboardHeightValue = new Value(0); modeValue: Value; paddingTopValue: Value; resetPasswordPanelOpacityValue: Value; constructor(props: Props) { super(props); this.state = { mode: 'simple-text', verifyField: null, errorMessage: null, resetPasswordUsername: null, }; this.contentHeight = new Value(props.dimensions.safeAreaHeight); this.modeValue = new Value(modeNumbers[this.nextMode]); this.paddingTopValue = this.paddingTop(); this.resetPasswordPanelOpacityValue = this.resetPasswordPanelOpacity(); } proceedToNextMode = () => { this.setState({ mode: this.nextMode }); }; paddingTop() { const simpleTextHeight = 90; const resetPasswordPanelHeight = 165; const potentialPaddingTop = divide( max( sub( this.contentHeight, cond( eq(this.modeValue, modeNumbers['simple-text']), simpleTextHeight, ), cond( eq(this.modeValue, modeNumbers['reset-password']), resetPasswordPanelHeight, ), this.keyboardHeightValue, ), 0, ), 2, ); const paddingTop = new Value(-1); const targetPaddingTop = new Value(-1); const prevModeValue = new Value(modeNumbers[this.nextMode]); const clock = new Clock(); const keyboardTimeoutClock = new Clock(); return block([ cond(lessThan(paddingTop, 0), [ set(paddingTop, potentialPaddingTop), set(targetPaddingTop, potentialPaddingTop), ]), cond( lessThan(this.keyboardHeightValue, 0), [ runTiming(keyboardTimeoutClock, 0, 1, true, { duration: 500 }), cond( not(clockRunning(keyboardTimeoutClock)), set(this.keyboardHeightValue, 0), ), ], stopClock(keyboardTimeoutClock), ), cond( and( greaterOrEq(this.keyboardHeightValue, 0), neq(prevModeValue, this.modeValue), ), [ stopClock(clock), set(targetPaddingTop, potentialPaddingTop), set(prevModeValue, this.modeValue), ], ), ratchetAlongWithKeyboardHeight(this.keyboardHeightValue, [ stopClock(clock), set(targetPaddingTop, potentialPaddingTop), ]), cond( neq(paddingTop, targetPaddingTop), set(paddingTop, runTiming(clock, paddingTop, targetPaddingTop)), ), paddingTop, ]); } resetPasswordPanelOpacity() { const targetResetPasswordPanelOpacity = eq( this.modeValue, modeNumbers['reset-password'], ); const resetPasswordPanelOpacity = new Value(-1); const prevResetPasswordPanelOpacity = new Value(-1); const prevTargetResetPasswordPanelOpacity = new Value(-1); const clock = new Clock(); return block([ cond(lessThan(resetPasswordPanelOpacity, 0), [ set(resetPasswordPanelOpacity, targetResetPasswordPanelOpacity), set(prevResetPasswordPanelOpacity, targetResetPasswordPanelOpacity), set( prevTargetResetPasswordPanelOpacity, targetResetPasswordPanelOpacity, ), ]), cond(greaterOrEq(this.keyboardHeightValue, 0), [ cond( neq( targetResetPasswordPanelOpacity, prevTargetResetPasswordPanelOpacity, ), [ stopClock(clock), set( prevTargetResetPasswordPanelOpacity, targetResetPasswordPanelOpacity, ), ], ), cond( neq(resetPasswordPanelOpacity, targetResetPasswordPanelOpacity), set( resetPasswordPanelOpacity, runTiming( clock, resetPasswordPanelOpacity, targetResetPasswordPanelOpacity, ), ), ), ]), cond( and( eq(resetPasswordPanelOpacity, 0), neq(prevResetPasswordPanelOpacity, 0), ), call([], this.proceedToNextMode), ), set(prevResetPasswordPanelOpacity, resetPasswordPanelOpacity), resetPasswordPanelOpacity, ]); } componentDidMount() { this.props.dispatchActionPromise( handleVerificationCodeActionTypes, this.handleVerificationCodeAction(), ); Keyboard.dismiss(); if (this.props.isForeground) { this.onForeground(); } } componentDidUpdate(prevProps: Props, prevState: State) { if ( this.state.verifyField === verifyField.EMAIL && prevState.verifyField !== verifyField.EMAIL ) { sleep(1500).then(this.dismiss); } const prevCode = prevProps.route.params.verifyCode; const code = this.props.route.params.verifyCode; if (code !== prevCode) { Keyboard.dismiss(); this.nextMode = 'simple-text'; this.modeValue.setValue(modeNumbers[this.nextMode]); this.keyboardHeightValue.setValue(0); this.setState({ mode: this.nextMode, verifyField: null, errorMessage: null, resetPasswordUsername: null, }); this.props.dispatchActionPromise( handleVerificationCodeActionTypes, this.handleVerificationCodeAction(), ); } if (this.props.isForeground && !prevProps.isForeground) { this.onForeground(); } else if (!this.props.isForeground && prevProps.isForeground) { this.onBackground(); } const newContentHeight = this.props.dimensions.safeAreaHeight; const oldContentHeight = prevProps.dimensions.safeAreaHeight; if (newContentHeight !== oldContentHeight) { this.contentHeight.setValue(newContentHeight); } } onForeground() { this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); this.keyboardHideListener = addKeyboardDismissListener(this.keyboardHide); } onBackground() { if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardHideListener) { removeKeyboardListener(this.keyboardHideListener); this.keyboardHideListener = null; } } dismiss = () => { this.props.navigation.clearRootModals([this.props.route.key]); }; onResetPasswordSuccess = async () => { this.nextMode = 'simple-text'; this.modeValue.setValue(modeNumbers[this.nextMode]); this.keyboardHeightValue.setValue(0); Keyboard.dismiss(); // Wait a couple seconds before letting the SUCCESS action propagate and // clear VerificationModal await sleep(1750); this.dismiss(); }; async handleVerificationCodeAction() { const code = this.props.route.params.verifyCode; try { const result = await this.props.handleVerificationCode(code); if (result.verifyField === verifyField.EMAIL) { this.setState({ verifyField: result.verifyField }); } else if (result.verifyField === verifyField.RESET_PASSWORD) { this.nextMode = 'reset-password'; this.modeValue.setValue(modeNumbers[this.nextMode]); this.keyboardHeightValue.setValue(-1); this.setState({ verifyField: result.verifyField, mode: 'reset-password', resetPasswordUsername: result.resetPasswordUsername, }); } } catch (e) { if (e.message === 'invalid_code') { this.setState({ errorMessage: 'Invalid verification code' }); } else { this.setState({ errorMessage: 'Unknown error occurred' }); } throw e; } } keyboardShow = (event: KeyboardEvent) => { const keyboardHeight = Platform.select({ // Android doesn't include the bottomInset in this height measurement android: event.endCoordinates.height, default: Math.max( event.endCoordinates.height - this.props.dimensions.bottomInset, 0, ), }); this.keyboardHeightValue.setValue(keyboardHeight); }; keyboardHide = () => { if (!this.activeAlert) { this.keyboardHeightValue.setValue(0); } }; setActiveAlert = (activeAlert: boolean) => { this.activeAlert = activeAlert; }; render() { const statusBar = ; const background = ( ); const closeButton = ( ); let content; if (this.state.mode === 'reset-password') { const code = this.props.route.params.verifyCode; invariant(this.state.resetPasswordUsername, 'should be set'); content = ( ); } else if (this.state.errorMessage) { content = ( {this.state.errorMessage} ); } else if (this.state.verifyField !== null) { let message; if (this.state.verifyField === verifyField.EMAIL) { message = 'Thanks for verifying your email!'; } else { message = 'Your password has been reset.'; } content = ( {message} ); } else { content = ( Verifying code... ); } const padding = { paddingTop: this.paddingTopValue }; const animatedContent = ( {content} ); return ( {background} {statusBar} {animatedContent} {closeButton} ); } } const styles = StyleSheet.create({ closeButton: { backgroundColor: '#D0D0D055', borderRadius: 3, height: 36, position: 'absolute', right: 15, top: 15, width: 36, }, closeButtonIcon: { left: 10, position: 'absolute', top: 8, }, container: { backgroundColor: 'transparent', flex: 1, }, contentContainer: { height: 90, }, icon: { textAlign: 'center', }, loadingText: { bottom: 0, color: 'white', fontSize: 20, left: 0, position: 'absolute', right: 0, textAlign: 'center', }, modalBackground: { height: ('100%': number | string), position: 'absolute', width: ('100%': number | string), }, }); registerFetchKey(handleVerificationCodeActionTypes); const isForegroundSelector = createIsForegroundSelector( VerificationModalRouteName, ); export default React.memo(function ConnectedVerificationModal( props: BaseProps, ) { const navContext = React.useContext(NavContext); const isForeground = isForegroundSelector(navContext); const dimensions = useSelector(derivedDimensionsInfoSelector); const splashStyle = useSelector(splashStyleSelector); const dispatchActionPromise = useDispatchActionPromise(); const callHandleVerificationCode = useServerCall(handleVerificationCode); return ( ); }); diff --git a/native/calendar/calendar-input-bar.react.js b/native/calendar/calendar-input-bar.react.js index 7182bb29a..881d8d25d 100644 --- a/native/calendar/calendar-input-bar.react.js +++ b/native/calendar/calendar-input-bar.react.js @@ -1,53 +1,54 @@ // @flow -import { connect } from 'lib/utils/redux-utils'; import * as React from 'react'; import { View, Text } from 'react-native'; +import { connect } from 'lib/utils/redux-utils'; + import Button from '../components/button.react'; import type { AppState } from '../redux/redux-setup'; import { styleSelector } from '../themes/colors'; type Props = {| onSave: () => void, disabled: boolean, // Redux state styles: typeof styles, |}; function CalendarInputBar(props: Props) { const inactiveStyle = props.disabled ? props.styles.inactiveContainer : undefined; return ( ); } const styles = { container: { alignItems: 'flex-end', backgroundColor: 'listInputBar', }, inactiveContainer: { opacity: 0, }, saveButtonText: { color: 'link', fontSize: 16, fontWeight: 'bold', marginRight: 5, padding: 8, }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ styles: stylesSelector(state), }))(CalendarInputBar); diff --git a/native/calendar/calendar.react.js b/native/calendar/calendar.react.js index fe1d04c6c..d20f86a2a 100644 --- a/native/calendar/calendar.react.js +++ b/native/calendar/calendar.react.js @@ -1,1093 +1,1093 @@ // @flow import invariant from 'invariant'; +import _filter from 'lodash/fp/filter'; +import _find from 'lodash/fp/find'; +import _findIndex from 'lodash/fp/findIndex'; +import _map from 'lodash/fp/map'; +import _pickBy from 'lodash/fp/pickBy'; +import _size from 'lodash/fp/size'; +import _sum from 'lodash/fp/sum'; +import _throttle from 'lodash/throttle'; +import * as React from 'react'; +import { + View, + Text, + FlatList, + AppState as NativeAppState, + Platform, + LayoutAnimation, + TouchableWithoutFeedback, +} from 'react-native'; +import SafeAreaView from 'react-native-safe-area-view'; + import { updateCalendarQueryActionTypes, updateCalendarQuery, } from 'lib/actions/entry-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { entryKey } from 'lib/shared/entry-utils'; import type { EntryInfo, CalendarQuery, CalendarQueryUpdateResult, } from 'lib/types/entry-types'; import type { CalendarFilter } from 'lib/types/filter-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { ConnectionStatus } from 'lib/types/socket-types'; import type { ThreadInfo } from 'lib/types/thread-types'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; import { dateString, prettyDate, dateFromString } from 'lib/utils/date-utils'; import sleep from 'lib/utils/sleep'; -import _filter from 'lodash/fp/filter'; -import _find from 'lodash/fp/find'; -import _findIndex from 'lodash/fp/findIndex'; -import _map from 'lodash/fp/map'; -import _pickBy from 'lodash/fp/pickBy'; -import _size from 'lodash/fp/size'; -import _sum from 'lodash/fp/sum'; -import _throttle from 'lodash/throttle'; -import * as React from 'react'; -import { - View, - Text, - FlatList, - AppState as NativeAppState, - Platform, - LayoutAnimation, - TouchableWithoutFeedback, -} from 'react-native'; -import SafeAreaView from 'react-native-safe-area-view'; import ContentLoading from '../components/content-loading.react'; import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react'; import ListLoadingIndicator from '../components/list-loading-indicator.react'; import NodeHeightMeasurer from '../components/node-height-measurer.react'; import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, } from '../keyboard/keyboard'; import type { KeyboardEvent } from '../keyboard/keyboard'; import type { TabNavigationProp } from '../navigation/app-navigator.react'; import DisconnectedBar from '../navigation/disconnected-bar.react'; import { createIsForegroundSelector, createActiveTabSelector, } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import { CalendarRouteName, ThreadPickerModalRouteName, } from '../navigation/route-names'; import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { calendarListData } from '../selectors/calendar-selectors'; import type { CalendarItem, SectionHeaderItem, SectionFooterItem, LoaderItem, } from '../selectors/calendar-selectors'; import { type DerivedDimensionsInfo, derivedDimensionsInfoSelector, } from '../selectors/dimensions-selectors'; import { useColors, useStyles, useIndicatorStyle, type Colors, type IndicatorStyle, } from '../themes/colors'; import type { ViewToken } from '../types/react-native'; - import CalendarInputBar from './calendar-input-bar.react'; import { Entry, InternalEntry, dummyNodeForEntryHeightMeasurement, } from './entry.react'; import SectionFooter from './section-footer.react'; export type EntryInfoWithHeight = {| ...EntryInfo, +textHeight: number, |}; type CalendarItemWithHeight = | LoaderItem | SectionHeaderItem | SectionFooterItem | {| itemType: 'entryInfo', entryInfo: EntryInfoWithHeight, threadInfo: ThreadInfo, |}; type ExtraData = {| +activeEntries: { +[key: string]: boolean }, +visibleEntries: { +[key: string]: boolean }, |}; const safeAreaViewForceInset = { top: 'always', bottom: 'never', }; type BaseProps = {| +navigation: TabNavigationProp<'Calendar'>, +route: NavigationRoute<'Calendar'>, |}; type Props = {| ...BaseProps, // Nav state +calendarActive: boolean, // Redux state +listData: ?$ReadOnlyArray, +startDate: string, +endDate: string, +calendarFilters: $ReadOnlyArray, +dimensions: DerivedDimensionsInfo, +loadingStatus: LoadingStatus, +connectionStatus: ConnectionStatus, +colors: Colors, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +updateCalendarQuery: ( calendarQuery: CalendarQuery, reduxAlreadyUpdated?: boolean, ) => Promise, |}; type State = {| +listDataWithHeights: ?$ReadOnlyArray, +readyToShowList: boolean, +extraData: ExtraData, +currentlyEditing: $ReadOnlyArray, |}; class Calendar extends React.PureComponent { flatList: ?FlatList = null; currentState: ?string = NativeAppState.currentState; lastForegrounded = 0; lastCalendarReset = 0; currentScrollPosition: ?number = null; // We don't always want an extraData update to trigger a state update, so we // cache the most recent value as a member here latestExtraData: ExtraData; // For some reason, we have to delay the scrollToToday call after the first // scroll upwards firstScrollComplete = false; // When an entry becomes active, we make a note of its key so that once the // keyboard event happens, we know where to move the scrollPos to lastEntryKeyActive: ?string = null; keyboardShowListener: ?{ +remove: () => void }; keyboardDismissListener: ?{ +remove: () => void }; keyboardShownHeight: ?number = null; // If the query fails, we try it again topLoadingFromScroll: ?CalendarQuery = null; bottomLoadingFromScroll: ?CalendarQuery = null; // We wait until the loaders leave view before letting them be triggered again topLoaderWaitingToLeaveView = true; bottomLoaderWaitingToLeaveView = true; // We keep refs to the entries so CalendarInputBar can save them entryRefs = new Map(); constructor(props: Props) { super(props); this.latestExtraData = { activeEntries: {}, visibleEntries: {}, }; this.state = { listDataWithHeights: null, readyToShowList: false, extraData: this.latestExtraData, currentlyEditing: [], }; } componentDidMount() { NativeAppState.addEventListener('change', this.handleAppStateChange); this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); this.keyboardDismissListener = addKeyboardDismissListener( this.keyboardDismiss, ); this.props.navigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { NativeAppState.removeEventListener('change', this.handleAppStateChange); if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardDismissListener) { removeKeyboardListener(this.keyboardDismissListener); this.keyboardDismissListener = null; } this.props.navigation.removeListener('tabPress', this.onTabPress); } handleAppStateChange = (nextAppState: ?string) => { const lastState = this.currentState; this.currentState = nextAppState; if ( !lastState || !lastState.match(/inactive|background/) || this.currentState !== 'active' ) { // We're only handling foregrounding here return; } if (Date.now() - this.lastCalendarReset < 500) { // If the calendar got reset right before this callback triggered, that // indicates we should reset the scroll position this.lastCalendarReset = 0; this.scrollToToday(false); } else { // Otherwise, it's possible that the calendar is about to get reset. We // record a timestamp here so we can scrollToToday there. this.lastForegrounded = Date.now(); } }; onTabPress = () => { if (this.props.navigation.isFocused()) { this.scrollToToday(); } }; componentDidUpdate(prevProps: Props, prevState: State) { if (!this.props.listData && this.props.listData !== prevProps.listData) { this.latestExtraData = { activeEntries: {}, visibleEntries: {}, }; this.setState({ listDataWithHeights: null, readyToShowList: false, extraData: this.latestExtraData, }); this.firstScrollComplete = false; this.topLoaderWaitingToLeaveView = true; this.bottomLoaderWaitingToLeaveView = true; } const { loadingStatus, connectionStatus } = this.props; const { loadingStatus: prevLoadingStatus, connectionStatus: prevConnectionStatus, } = prevProps; if ( (loadingStatus === 'error' && prevLoadingStatus === 'loading') || (connectionStatus === 'connected' && prevConnectionStatus !== 'connected') ) { this.loadMoreAbove(); this.loadMoreBelow(); } const lastLDWH = prevState.listDataWithHeights; const newLDWH = this.state.listDataWithHeights; if (!newLDWH) { return; } else if (!lastLDWH) { if (!this.props.calendarActive) { // FlatList has an initialScrollIndex prop, which is usually close to // centering but can be off when there is a particularly large Entry in // the list. scrollToToday lets us actually center, but gets overriden // by initialScrollIndex if we call it right after the FlatList mounts sleep(50).then(() => this.scrollToToday()); } return; } if (newLDWH.length < lastLDWH.length) { this.topLoaderWaitingToLeaveView = true; this.bottomLoaderWaitingToLeaveView = true; if (this.flatList) { if (!this.props.calendarActive) { // If the currentCalendarQuery gets reset we scroll to the center this.scrollToToday(); } else if (Date.now() - this.lastForegrounded < 500) { // If the app got foregrounded right before the calendar got reset, // that indicates we should reset the scroll position this.lastForegrounded = 0; this.scrollToToday(false); } else { // Otherwise, it's possible that we got triggered before the // foreground callback. Let's record a timestamp here so we can call // scrollToToday there this.lastCalendarReset = Date.now(); } } } const { lastStartDate, newStartDate, lastEndDate, newEndDate, } = Calendar.datesFromListData(lastLDWH, newLDWH); if (newStartDate > lastStartDate || newEndDate < lastEndDate) { // If there are fewer items in our new data, which happens when the // current calendar query gets reset due to inactivity, let's reset the // scroll position to the center (today) if (!this.props.calendarActive) { sleep(50).then(() => this.scrollToToday()); } this.firstScrollComplete = false; } else if (newStartDate < lastStartDate) { this.updateScrollPositionAfterPrepend(lastLDWH, newLDWH); } else if (newEndDate > lastEndDate) { this.firstScrollComplete = true; } else if (newLDWH.length > lastLDWH.length) { LayoutAnimation.easeInEaseOut(); } if (newStartDate < lastStartDate) { this.topLoadingFromScroll = null; } if (newEndDate > lastEndDate) { this.bottomLoadingFromScroll = null; } const { keyboardShownHeight, lastEntryKeyActive } = this; if (keyboardShownHeight && lastEntryKeyActive) { this.scrollToKey(lastEntryKeyActive, keyboardShownHeight); this.lastEntryKeyActive = null; } } static datesFromListData( lastLDWH: $ReadOnlyArray, newLDWH: $ReadOnlyArray, ) { const lastSecondItem = lastLDWH[1]; const newSecondItem = newLDWH[1]; invariant( newSecondItem.itemType === 'header' && lastSecondItem.itemType === 'header', 'second item in listData should be a header', ); const lastStartDate = dateFromString(lastSecondItem.dateString); const newStartDate = dateFromString(newSecondItem.dateString); const lastPenultimateItem = lastLDWH[lastLDWH.length - 2]; const newPenultimateItem = newLDWH[newLDWH.length - 2]; invariant( newPenultimateItem.itemType === 'footer' && lastPenultimateItem.itemType === 'footer', 'penultimate item in listData should be a footer', ); const lastEndDate = dateFromString(lastPenultimateItem.dateString); const newEndDate = dateFromString(newPenultimateItem.dateString); return { lastStartDate, newStartDate, lastEndDate, newEndDate }; } /** * When prepending list items, FlatList isn't smart about preserving scroll * position. If we're at the start of the list before prepending, FlatList * will just keep us at the front after prepending. But we want to preserve * the previous on-screen items, so we have to do a calculation to get the new * scroll position. (And deal with the inherent glitchiness of trying to time * that change with the items getting prepended... *sigh*.) */ updateScrollPositionAfterPrepend( lastLDWH: $ReadOnlyArray, newLDWH: $ReadOnlyArray, ) { const existingKeys = new Set(_map(Calendar.keyExtractor)(lastLDWH)); const newItems = _filter( (item: CalendarItemWithHeight) => !existingKeys.has(Calendar.keyExtractor(item)), )(newLDWH); const heightOfNewItems = Calendar.heightOfItems(newItems); const flatList = this.flatList; invariant(flatList, 'flatList should be set'); const scrollAction = () => { invariant( this.currentScrollPosition !== undefined && this.currentScrollPosition !== null, 'currentScrollPosition should be set', ); const currentScrollPosition = Math.max(this.currentScrollPosition, 0); let offset = currentScrollPosition + heightOfNewItems; flatList.scrollToOffset({ offset, animated: false, }); }; scrollAction(); if (!this.firstScrollComplete) { setTimeout(scrollAction, 0); this.firstScrollComplete = true; } } scrollToToday(animated: ?boolean = undefined) { if (animated === undefined) { animated = this.props.calendarActive; } const ldwh = this.state.listDataWithHeights; if (!ldwh) { return; } const todayIndex = _findIndex(['dateString', dateString(new Date())])(ldwh); invariant(this.flatList, "scrollToToday called, but flatList isn't set"); this.flatList.scrollToIndex({ index: todayIndex, animated, viewPosition: 0.5, }); } renderItem = (row: { item: CalendarItemWithHeight }) => { const item = row.item; if (item.itemType === 'loader') { return ; } else if (item.itemType === 'header') { return this.renderSectionHeader(item); } else if (item.itemType === 'entryInfo') { const key = entryKey(item.entryInfo); return ( ); } else if (item.itemType === 'footer') { return this.renderSectionFooter(item); } invariant(false, 'renderItem conditions should be exhaustive'); }; renderSectionHeader = (item: SectionHeaderItem) => { let date = prettyDate(item.dateString); if (dateString(new Date()) === item.dateString) { date += ' (today)'; } const dateObj = dateFromString(item.dateString).getDay(); const weekendStyle = dateObj === 0 || dateObj === 6 ? this.props.styles.weekendSectionHeader : null; return ( {date} ); }; renderSectionFooter = (item: SectionFooterItem) => { return ( ); }; onAdd = (dayString: string) => { this.props.navigation.navigate(ThreadPickerModalRouteName, { presentedFrom: this.props.route.key, dateString: dayString, }); }; static keyExtractor = (item: CalendarItemWithHeight | CalendarItem) => { if (item.itemType === 'loader') { return item.key; } else if (item.itemType === 'header') { return item.dateString + '/header'; } else if (item.itemType === 'entryInfo') { return entryKey(item.entryInfo); } else if (item.itemType === 'footer') { return item.dateString + '/footer'; } invariant(false, 'keyExtractor conditions should be exhaustive'); }; static getItemLayout( data: ?$ReadOnlyArray, index: number, ) { if (!data) { return { length: 0, offset: 0, index }; } const offset = Calendar.heightOfItems(data.filter((_, i) => i < index)); const item = data[index]; const length = item ? Calendar.itemHeight(item) : 0; return { length, offset, index }; } static itemHeight(item: CalendarItemWithHeight): number { if (item.itemType === 'loader') { return 56; } else if (item.itemType === 'header') { return 31; } else if (item.itemType === 'entryInfo') { const verticalPadding = 10; return verticalPadding + item.entryInfo.textHeight; } else if (item.itemType === 'footer') { return 40; } invariant(false, 'itemHeight conditions should be exhaustive'); } static heightOfItems(data: $ReadOnlyArray): number { return _sum(data.map(Calendar.itemHeight)); } render() { const { listDataWithHeights } = this.state; let flatList = null; if (listDataWithHeights) { const flatListStyle = { opacity: this.state.readyToShowList ? 1 : 0 }; const initialScrollIndex = this.initialScrollIndex(listDataWithHeights); flatList = ( ); } let loadingIndicator = null; if (!listDataWithHeights || !this.state.readyToShowList) { loadingIndicator = ( ); } const disableInputBar = this.state.currentlyEditing.length === 0; return ( <> {loadingIndicator} {flatList} ); } flatListHeight() { const { safeAreaHeight, tabBarHeight } = this.props.dimensions; return safeAreaHeight - tabBarHeight; } initialScrollIndex(data: $ReadOnlyArray) { const todayIndex = _findIndex(['dateString', dateString(new Date())])(data); const heightOfTodayHeader = Calendar.itemHeight(data[todayIndex]); let returnIndex = todayIndex; let heightLeft = (this.flatListHeight() - heightOfTodayHeader) / 2; while (heightLeft > 0) { heightLeft -= Calendar.itemHeight(data[--returnIndex]); } return returnIndex; } flatListRef = (flatList: ?FlatList) => { this.flatList = flatList; }; entryRef = (inEntryKey: string, entry: ?InternalEntry) => { this.entryRefs.set(inEntryKey, entry); }; makeAllEntriesInactive = () => { if (_size(this.state.extraData.activeEntries) === 0) { if (_size(this.latestExtraData.activeEntries) !== 0) { this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: this.state.extraData.activeEntries, }; } return; } this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: {}, }; this.setState({ extraData: this.latestExtraData }); }; makeActive = (key: string, active: boolean) => { if (!active) { const activeKeys = Object.keys(this.latestExtraData.activeEntries); if (activeKeys.length === 0) { if (Object.keys(this.state.extraData.activeEntries).length !== 0) { this.setState({ extraData: this.latestExtraData }); } return; } const activeKey = activeKeys[0]; if (activeKey === key) { this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: {}, }; this.setState({ extraData: this.latestExtraData }); } return; } if ( _size(this.state.extraData.activeEntries) === 1 && this.state.extraData.activeEntries[key] ) { if ( _size(this.latestExtraData.activeEntries) !== 1 || !this.latestExtraData.activeEntries[key] ) { this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: this.state.extraData.activeEntries, }; } return; } this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: { [key]: true }, }; this.setState({ extraData: this.latestExtraData }); }; onEnterEntryEditMode = (entryInfo: EntryInfoWithHeight) => { const key = entryKey(entryInfo); const keyboardShownHeight = this.keyboardShownHeight; if (keyboardShownHeight && this.state.listDataWithHeights) { this.scrollToKey(key, keyboardShownHeight); } else { this.lastEntryKeyActive = key; } const newCurrentlyEditing = [ ...new Set([...this.state.currentlyEditing, key]), ]; if (newCurrentlyEditing.length > this.state.currentlyEditing.length) { this.setState({ currentlyEditing: newCurrentlyEditing }); } }; onConcludeEntryEditMode = (entryInfo: EntryInfoWithHeight) => { const key = entryKey(entryInfo); const newCurrentlyEditing = this.state.currentlyEditing.filter( (k) => k !== key, ); if (newCurrentlyEditing.length < this.state.currentlyEditing.length) { this.setState({ currentlyEditing: newCurrentlyEditing }); } }; keyboardShow = (event: KeyboardEvent) => { // flatListHeight() factors in the size of the tab bar, // but it is hidden by the keyboard since it is at the bottom const { bottomInset, tabBarHeight } = this.props.dimensions; const inputBarHeight = Platform.OS === 'android' ? 37.7 : 35.5; const keyboardHeight = Platform.select({ // Android doesn't include the bottomInset in this height measurement android: event.endCoordinates.height, default: Math.max(event.endCoordinates.height - bottomInset, 0), }); const keyboardShownHeight = inputBarHeight + Math.max(keyboardHeight - tabBarHeight, 0); this.keyboardShownHeight = keyboardShownHeight; const lastEntryKeyActive = this.lastEntryKeyActive; if (lastEntryKeyActive && this.state.listDataWithHeights) { this.scrollToKey(lastEntryKeyActive, keyboardShownHeight); this.lastEntryKeyActive = null; } }; keyboardDismiss = () => { this.keyboardShownHeight = null; }; scrollToKey(lastEntryKeyActive: string, keyboardHeight: number) { const data = this.state.listDataWithHeights; invariant(data, 'should be set'); const index = data.findIndex( (item: CalendarItemWithHeight) => Calendar.keyExtractor(item) === lastEntryKeyActive, ); if (index === -1) { return; } const itemStart = Calendar.heightOfItems(data.filter((_, i) => i < index)); const itemHeight = Calendar.itemHeight(data[index]); const entryAdditionalActiveHeight = Platform.OS === 'android' ? 21 : 20; const itemEnd = itemStart + itemHeight + entryAdditionalActiveHeight; const visibleHeight = this.flatListHeight() - keyboardHeight; if ( this.currentScrollPosition !== undefined && this.currentScrollPosition !== null && itemStart > this.currentScrollPosition && itemEnd < this.currentScrollPosition + visibleHeight ) { return; } const offset = itemStart - (visibleHeight - itemHeight) / 2; invariant(this.flatList, 'flatList should be set'); this.flatList.scrollToOffset({ offset, animated: true }); } heightMeasurerKey = (item: CalendarItem) => { if (item.itemType !== 'entryInfo') { return null; } return item.entryInfo.text; }; heightMeasurerDummy = (item: CalendarItem) => { invariant( item.itemType === 'entryInfo', 'NodeHeightMeasurer asked for dummy for non-entryInfo item', ); return dummyNodeForEntryHeightMeasurement(item.entryInfo.text); }; heightMeasurerMergeItem = (item: CalendarItem, height: ?number) => { if (item.itemType !== 'entryInfo') { return item; } invariant(height !== null && height !== undefined, 'height should be set'); const { entryInfo } = item; return { itemType: 'entryInfo', entryInfo: Calendar.entryInfoWithHeight(entryInfo, height), threadInfo: item.threadInfo, }; }; static entryInfoWithHeight( entryInfo: EntryInfo, textHeight: number, ): EntryInfoWithHeight { // Blame Flow for not accepting object spread on exact types if (entryInfo.id && entryInfo.localID) { return { id: entryInfo.id, localID: entryInfo.localID, threadID: entryInfo.threadID, text: entryInfo.text, year: entryInfo.year, month: entryInfo.month, day: entryInfo.day, creationTime: entryInfo.creationTime, creator: entryInfo.creator, deleted: entryInfo.deleted, textHeight: Math.ceil(textHeight), }; } else if (entryInfo.id) { return { id: entryInfo.id, threadID: entryInfo.threadID, text: entryInfo.text, year: entryInfo.year, month: entryInfo.month, day: entryInfo.day, creationTime: entryInfo.creationTime, creator: entryInfo.creator, deleted: entryInfo.deleted, textHeight: Math.ceil(textHeight), }; } else { return { localID: entryInfo.localID, threadID: entryInfo.threadID, text: entryInfo.text, year: entryInfo.year, month: entryInfo.month, day: entryInfo.day, creationTime: entryInfo.creationTime, creator: entryInfo.creator, deleted: entryInfo.deleted, textHeight: Math.ceil(textHeight), }; } } allHeightsMeasured = ( listDataWithHeights: $ReadOnlyArray, ) => { this.setState({ listDataWithHeights }); }; onViewableItemsChanged = (info: { viewableItems: ViewToken[], changed: ViewToken[], }) => { const ldwh = this.state.listDataWithHeights; if (!ldwh) { // This indicates the listData was cleared (set to null) right before this // callback was called. Since this leads to the FlatList getting cleared, // we'll just ignore this callback. return; } const visibleEntries = {}; for (let token of info.viewableItems) { if (token.item.itemType === 'entryInfo') { visibleEntries[entryKey(token.item.entryInfo)] = true; } } this.latestExtraData = { activeEntries: _pickBy((_, key: string) => { if (visibleEntries[key]) { return true; } // We don't automatically set scrolled-away entries to be inactive // because entries can be out-of-view at creation time if they need to // be scrolled into view (see onEnterEntryEditMode). If Entry could // distinguish the reasons its active prop gets set to false, it could // differentiate the out-of-view case from the something-pressed case, // and then we could set scrolled-away entries to be inactive without // worrying about this edge case. Until then... const foundItem = _find( (item) => item.entryInfo && entryKey(item.entryInfo) === key, )(ldwh); return !!foundItem; })(this.latestExtraData.activeEntries), visibleEntries, }; const topLoader = _find({ key: 'TopLoader' })(info.viewableItems); if (this.topLoaderWaitingToLeaveView && !topLoader) { this.topLoaderWaitingToLeaveView = false; this.topLoadingFromScroll = null; } const bottomLoader = _find({ key: 'BottomLoader' })(info.viewableItems); if (this.bottomLoaderWaitingToLeaveView && !bottomLoader) { this.bottomLoaderWaitingToLeaveView = false; this.bottomLoadingFromScroll = null; } if ( !this.state.readyToShowList && !this.topLoaderWaitingToLeaveView && !this.bottomLoaderWaitingToLeaveView && info.viewableItems.length > 0 ) { this.setState({ readyToShowList: true, extraData: this.latestExtraData, }); } if ( topLoader && !this.topLoaderWaitingToLeaveView && !this.topLoadingFromScroll ) { this.topLoaderWaitingToLeaveView = true; const start = dateFromString(this.props.startDate); start.setDate(start.getDate() - 31); const startDate = dateString(start); const endDate = this.props.endDate; this.topLoadingFromScroll = { startDate, endDate, filters: this.props.calendarFilters, }; this.loadMoreAbove(); } else if ( bottomLoader && !this.bottomLoaderWaitingToLeaveView && !this.bottomLoadingFromScroll ) { this.bottomLoaderWaitingToLeaveView = true; const end = dateFromString(this.props.endDate); end.setDate(end.getDate() + 31); const endDate = dateString(end); const startDate = this.props.startDate; this.bottomLoadingFromScroll = { startDate, endDate, filters: this.props.calendarFilters, }; this.loadMoreBelow(); } }; dispatchCalendarQueryUpdate(calendarQuery: CalendarQuery) { this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery(calendarQuery), ); } loadMoreAbove = _throttle(() => { if ( this.topLoadingFromScroll && this.topLoaderWaitingToLeaveView && this.props.connectionStatus === 'connected' ) { this.dispatchCalendarQueryUpdate(this.topLoadingFromScroll); } }, 1000); loadMoreBelow = _throttle(() => { if ( this.bottomLoadingFromScroll && this.bottomLoaderWaitingToLeaveView && this.props.connectionStatus === 'connected' ) { this.dispatchCalendarQueryUpdate(this.bottomLoadingFromScroll); } }, 1000); onScroll = (event: { +nativeEvent: { +contentOffset: { +y: number } } }) => { this.currentScrollPosition = event.nativeEvent.contentOffset.y; }; // When the user "flicks" the scroll view, this callback gets triggered after // the scrolling ends onMomentumScrollEnd = () => { this.setState({ extraData: this.latestExtraData }); }; // This callback gets triggered when the user lets go of scrolling the scroll // view, regardless of whether it was a "flick" or a pan onScrollEndDrag = () => { // We need to figure out if this was a flick or not. If it's a flick, we'll // let onMomentumScrollEnd handle it once scroll position stabilizes const currentScrollPosition = this.currentScrollPosition; setTimeout(() => { if (this.currentScrollPosition === currentScrollPosition) { this.setState({ extraData: this.latestExtraData }); } }, 50); }; onSaveEntry = () => { const entryKeys = Object.keys(this.latestExtraData.activeEntries); if (entryKeys.length === 0) { return; } const entryRef = this.entryRefs.get(entryKeys[0]); if (entryRef) { entryRef.completeEdit(); } }; } const unboundStyles = { container: { backgroundColor: 'listBackground', flex: 1, }, flatList: { backgroundColor: 'listBackground', flex: 1, }, keyboardAvoidingViewContainer: { position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, }, keyboardAvoidingView: { position: 'absolute', left: 0, right: 0, bottom: 0, }, sectionHeader: { backgroundColor: 'listSeparator', borderBottomWidth: 2, borderColor: 'listBackground', height: 31, }, sectionHeaderText: { color: 'listSeparatorLabel', fontWeight: 'bold', padding: 5, }, weekendSectionHeader: {}, }; const loadingStatusSelector = createLoadingStatusSelector( updateCalendarQueryActionTypes, ); const activeTabSelector = createActiveTabSelector(CalendarRouteName); const activeThreadPickerSelector = createIsForegroundSelector( ThreadPickerModalRouteName, ); export default React.memo(function ConnectedCalendar( props: BaseProps, ) { const navContext = React.useContext(NavContext); const calendarActive = activeTabSelector(navContext) || activeThreadPickerSelector(navContext); const listData = useSelector(calendarListData); const startDate = useSelector((state) => state.navInfo.startDate); const endDate = useSelector((state) => state.navInfo.endDate); const calendarFilters = useSelector((state) => state.calendarFilters); const dimensions = useSelector(derivedDimensionsInfoSelector); const loadingStatus = useSelector(loadingStatusSelector); const connectionStatus = useSelector((state) => state.connection.status); const colors = useColors(); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateCalendarQuery = useServerCall(updateCalendarQuery); return ( ); }); diff --git a/native/calendar/entry.react.js b/native/calendar/entry.react.js index a48ec6b5c..11794cdd3 100644 --- a/native/calendar/entry.react.js +++ b/native/calendar/entry.react.js @@ -1,791 +1,791 @@ // @flow import invariant from 'invariant'; +import _isEqual from 'lodash/fp/isEqual'; +import _omit from 'lodash/fp/omit'; +import * as React from 'react'; +import { + View, + Text, + TextInput, + Platform, + TouchableWithoutFeedback, + Alert, + LayoutAnimation, + Keyboard, +} from 'react-native'; +import Icon from 'react-native-vector-icons/FontAwesome'; +import { useDispatch } from 'react-redux'; +import shallowequal from 'shallowequal'; +import tinycolor from 'tinycolor2'; + import { createEntryActionTypes, createEntry, saveEntryActionTypes, saveEntry, deleteEntryActionTypes, deleteEntry, concurrentModificationResetActionType, } from 'lib/actions/entry-actions'; import { registerFetchKey } from 'lib/reducers/loading-reducer'; import { entryKey } from 'lib/shared/entry-utils'; import { colorIsDark, threadHasPermission } from 'lib/shared/thread-utils'; import type { CreateEntryInfo, SaveEntryInfo, SaveEntryResponse, CreateEntryPayload, DeleteEntryInfo, DeleteEntryResponse, CalendarQuery, } from 'lib/types/entry-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { Dispatch } from 'lib/types/redux-types'; import { type ThreadInfo, threadPermissions } from 'lib/types/thread-types'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; import { dateString } from 'lib/utils/date-utils'; import { ServerError } from 'lib/utils/errors'; import sleep from 'lib/utils/sleep'; -import _isEqual from 'lodash/fp/isEqual'; -import _omit from 'lodash/fp/omit'; -import * as React from 'react'; -import { - View, - Text, - TextInput, - Platform, - TouchableWithoutFeedback, - Alert, - LayoutAnimation, - Keyboard, -} from 'react-native'; -import Icon from 'react-native-vector-icons/FontAwesome'; -import { useDispatch } from 'react-redux'; -import shallowequal from 'shallowequal'; -import tinycolor from 'tinycolor2'; import Button from '../components/button.react'; import { SingleLine } from '../components/single-line.react'; import Markdown from '../markdown/markdown.react'; import { inlineMarkdownRules } from '../markdown/rules.react'; import type { TabNavigationProp } from '../navigation/app-navigator.react'; import { createIsForegroundSelector, nonThreadCalendarQuery, } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import { MessageListRouteName, ThreadPickerModalRouteName, } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { colors, useStyles } from '../themes/colors'; import type { LayoutEvent } from '../types/react-native'; import { waitForInteractions } from '../utils/timers'; - import type { EntryInfoWithHeight } from './calendar.react'; import LoadingIndicator from './loading-indicator.react'; function hueDistance(firstColor: string, secondColor: string): number { const firstHue = tinycolor(firstColor).toHsv().h; const secondHue = tinycolor(secondColor).toHsv().h; const distance = Math.abs(firstHue - secondHue); return distance > 180 ? 360 - distance : distance; } const omitEntryInfo = _omit(['entryInfo']); function dummyNodeForEntryHeightMeasurement(entryText: string) { const text = entryText === '' ? ' ' : entryText; return ( {text} ); } type BaseProps = {| +navigation: TabNavigationProp<'Calendar'>, +entryInfo: EntryInfoWithHeight, +threadInfo: ThreadInfo, +visible: boolean, +active: boolean, +makeActive: (entryKey: string, active: boolean) => void, +onEnterEditMode: (entryInfo: EntryInfoWithHeight) => void, +onConcludeEditMode: (entryInfo: EntryInfoWithHeight) => void, +onPressWhitespace: () => void, +entryRef: (entryKey: string, entry: ?InternalEntry) => void, |}; type Props = {| ...BaseProps, // Redux state +calendarQuery: () => CalendarQuery, +online: boolean, +styles: typeof unboundStyles, // Nav state +threadPickerActive: boolean, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +createEntry: (info: CreateEntryInfo) => Promise, +saveEntry: (info: SaveEntryInfo) => Promise, +deleteEntry: (info: DeleteEntryInfo) => Promise, |}; type State = {| +editing: boolean, +text: string, +loadingStatus: LoadingStatus, +height: number, |}; class InternalEntry extends React.Component { textInput: ?React.ElementRef; creating = false; needsUpdateAfterCreation = false; needsDeleteAfterCreation = false; nextSaveAttemptIndex = 0; mounted = false; deleted = false; currentlySaving: ?string; constructor(props: Props) { super(props); this.state = { editing: false, text: props.entryInfo.text, loadingStatus: 'inactive', height: props.entryInfo.textHeight, }; this.state = { ...this.state, editing: InternalEntry.isActive(props, this.state), }; } guardedSetState(input: $Shape) { if (this.mounted) { this.setState(input); } } shouldComponentUpdate(nextProps: Props, nextState: State) { return ( !shallowequal(nextState, this.state) || !shallowequal(omitEntryInfo(nextProps), omitEntryInfo(this.props)) || !_isEqual(nextProps.entryInfo)(this.props.entryInfo) ); } componentDidUpdate(prevProps: Props, prevState: State) { const wasActive = InternalEntry.isActive(prevProps, prevState); const isActive = InternalEntry.isActive(this.props, this.state); if ( !isActive && (this.props.entryInfo.text !== prevProps.entryInfo.text || this.props.entryInfo.textHeight !== prevProps.entryInfo.textHeight) && (this.props.entryInfo.text !== this.state.text || this.props.entryInfo.textHeight !== this.state.height) ) { this.guardedSetState({ text: this.props.entryInfo.text, height: this.props.entryInfo.textHeight, }); this.currentlySaving = null; } if ( !this.props.active && this.state.text === prevState.text && this.state.height !== prevState.height && this.state.height !== this.props.entryInfo.textHeight ) { const approxMeasuredHeight = Math.round(this.state.height * 1000) / 1000; const approxExpectedHeight = Math.round(this.props.entryInfo.textHeight * 1000) / 1000; console.log( `Entry height for ${entryKey(this.props.entryInfo)} was expected to ` + `be ${approxExpectedHeight} but is actually ` + `${approxMeasuredHeight}. This means Calendar's FlatList isn't ` + 'getting the right item height for some of its nodes, which is ' + 'guaranteed to cause glitchy behavior. Please investigate!!', ); } // Our parent will set the active prop to false if something else gets // pressed or if the Entry is scrolled out of view. In either of those cases // we should complete the edit process. if (!this.props.active && prevProps.active) { this.completeEdit(); } if (this.state.height !== prevState.height || isActive !== wasActive) { LayoutAnimation.easeInEaseOut(); } if ( this.props.online && !prevProps.online && this.state.loadingStatus === 'error' ) { this.save(); } if ( this.state.editing && prevState.editing && (this.state.text.trim() === '') !== (prevState.text.trim() === '') ) { LayoutAnimation.easeInEaseOut(); } } componentDidMount() { this.mounted = true; this.props.entryRef(entryKey(this.props.entryInfo), this); } componentWillUnmount() { this.mounted = false; this.props.entryRef(entryKey(this.props.entryInfo), null); this.props.onConcludeEditMode(this.props.entryInfo); } static isActive(props: Props, state: State) { return ( props.active || state.editing || !props.entryInfo.id || state.loadingStatus !== 'inactive' ); } render() { const active = InternalEntry.isActive(this.props, this.state); const { editing } = this.state; const threadColor = `#${this.props.threadInfo.color}`; const darkColor = colorIsDark(this.props.threadInfo.color); let actionLinks = null; if (active) { const actionLinksColor = darkColor ? '#D3D3D3' : '#404040'; const actionLinksTextStyle = { color: actionLinksColor }; const { modalIosHighlightUnderlay: actionLinksUnderlayColor } = darkColor ? colors.dark : colors.light; const loadingIndicatorCanUseRed = hueDistance('red', threadColor) > 50; let editButtonContent = null; if (editing && this.state.text.trim() === '') { // nothing } else if (editing) { editButtonContent = ( SAVE ); } else { editButtonContent = ( EDIT ); } actionLinks = ( ); } const textColor = darkColor ? 'white' : 'black'; let textInput; if (editing) { const textInputStyle = { color: textColor, backgroundColor: threadColor, }; const selectionColor = darkColor ? '#129AFF' : '#036AFF'; textInput = ( ); } let rawText = this.state.text; if (rawText === '' || rawText.slice(-1) === '\n') { rawText += ' '; } const textStyle = { ...this.props.styles.text, color: textColor, opacity: textInput ? 0 : 1, }; // We use an empty View to set the height of the entry, and then position // the Text and TextInput absolutely. This allows to measure height changes // to the Text while controlling the actual height of the entry. const heightStyle = { height: this.state.height }; const entryStyle = { backgroundColor: threadColor }; const opacity = editing ? 1.0 : 0.6; const canEditEntry = threadHasPermission( this.props.threadInfo, threadPermissions.EDIT_ENTRIES, ); return ( ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; if (textInput && this.state.editing) { this.enterEditMode(); } }; enterEditMode = async () => { this.setActive(); this.props.onEnterEditMode(this.props.entryInfo); if (Platform.OS === 'android') { // For some reason if we don't do this the scroll stops halfway through await waitForInteractions(); await sleep(15); } this.focus(); }; focus = () => { const { textInput } = this; if (!textInput) { return; } textInput.focus(); }; onFocus = () => { if (this.props.threadPickerActive) { this.props.navigation.goBack(); } }; setActive = () => this.makeActive(true); completeEdit = () => { // This gets called from CalendarInputBar (save button above keyboard), // onPressEdit (save button in Entry action links), and in // componentDidUpdate above when Calendar sets this Entry to inactive. // Calendar does this if something else gets pressed or the Entry is // scrolled out of view. Note that an Entry won't consider itself inactive // until it's done updating the server with its state, and if the network // requests fail it may stay "active". if (this.textInput) { this.textInput.blur(); } this.onBlur(); }; onBlur = () => { if (this.state.text.trim() === '') { this.delete(); } else if (this.props.entryInfo.text !== this.state.text) { this.save(); } this.guardedSetState({ editing: false }); this.makeActive(false); this.props.onConcludeEditMode(this.props.entryInfo); }; save = () => { this.dispatchSave(this.props.entryInfo.id, this.state.text); }; onTextContainerLayout = (event: LayoutEvent) => { this.guardedSetState({ height: Math.ceil(event.nativeEvent.layout.height), }); }; onChangeText = (newText: string) => { this.guardedSetState({ text: newText }); }; makeActive(active: boolean) { const { threadInfo } = this.props; if (!threadHasPermission(threadInfo, threadPermissions.EDIT_ENTRIES)) { return; } this.props.makeActive(entryKey(this.props.entryInfo), active); } dispatchSave(serverID: ?string, newText: string) { if (this.currentlySaving === newText) { return; } this.currentlySaving = newText; if (newText.trim() === '') { // We don't save the empty string, since as soon as the element becomes // inactive it'll get deleted return; } if (!serverID) { if (this.creating) { // We need the first save call to return so we know the ID of the entry // we're updating, so we'll need to handle this save later this.needsUpdateAfterCreation = true; return; } else { this.creating = true; } } this.guardedSetState({ loadingStatus: 'loading' }); if (!serverID) { this.props.dispatchActionPromise( createEntryActionTypes, this.createAction(newText), ); } else { this.props.dispatchActionPromise( saveEntryActionTypes, this.saveAction(serverID, newText), ); } } async createAction(text: string) { const localID = this.props.entryInfo.localID; invariant(localID, "if there's no serverID, there should be a localID"); const curSaveAttempt = this.nextSaveAttemptIndex++; try { const response = await this.props.createEntry({ text, timestamp: this.props.entryInfo.creationTime, date: dateString( this.props.entryInfo.year, this.props.entryInfo.month, this.props.entryInfo.day, ), threadID: this.props.entryInfo.threadID, localID, calendarQuery: this.props.calendarQuery(), }); if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'inactive' }); } this.creating = false; if (this.needsUpdateAfterCreation) { this.needsUpdateAfterCreation = false; this.dispatchSave(response.entryID, this.state.text); } if (this.needsDeleteAfterCreation) { this.needsDeleteAfterCreation = false; this.dispatchDelete(response.entryID); } return response; } catch (e) { if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'error' }); } this.currentlySaving = null; this.creating = false; throw e; } } async saveAction(entryID: string, newText: string) { const curSaveAttempt = this.nextSaveAttemptIndex++; try { const response = await this.props.saveEntry({ entryID, text: newText, prevText: this.props.entryInfo.text, timestamp: Date.now(), calendarQuery: this.props.calendarQuery(), }); if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'inactive' }); } return { ...response, threadID: this.props.entryInfo.threadID }; } catch (e) { if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'error' }); } this.currentlySaving = null; if (e instanceof ServerError && e.message === 'concurrent_modification') { const revertedText = e.payload.db; const onRefresh = () => { this.guardedSetState({ loadingStatus: 'inactive', text: revertedText, }); this.props.dispatch({ type: concurrentModificationResetActionType, payload: { id: entryID, dbText: revertedText }, }); }; Alert.alert( 'Concurrent modification', 'It looks like somebody is attempting to modify that field at the ' + 'same time as you! Please try again.', [{ text: 'OK', onPress: onRefresh }], { cancelable: false }, ); } throw e; } } delete = () => { this.dispatchDelete(this.props.entryInfo.id); }; onPressEdit = () => { if (this.state.editing) { this.completeEdit(); } else { this.guardedSetState({ editing: true }); } }; dispatchDelete(serverID: ?string) { if (this.deleted) { return; } this.deleted = true; LayoutAnimation.easeInEaseOut(); const { localID } = this.props.entryInfo; this.props.dispatchActionPromise( deleteEntryActionTypes, this.deleteAction(serverID), undefined, { localID, serverID }, ); } async deleteAction(serverID: ?string) { if (serverID) { return await this.props.deleteEntry({ entryID: serverID, prevText: this.props.entryInfo.text, calendarQuery: this.props.calendarQuery(), }); } else if (this.creating) { this.needsDeleteAfterCreation = true; } return null; } onPressThreadName = () => { Keyboard.dismiss(); const { threadInfo } = this.props; this.props.navigation.navigate({ name: MessageListRouteName, params: { threadInfo }, key: `${MessageListRouteName}${threadInfo.id}`, }); }; } const unboundStyles = { actionLinks: { flex: 1, flexDirection: 'row', justifyContent: 'space-between', marginTop: -5, }, button: { padding: 5, }, buttonContents: { flex: 1, flexDirection: 'row', }, container: { backgroundColor: 'listBackground', }, entry: { borderRadius: 8, margin: 5, overflow: 'hidden', }, leftLinks: { flex: 1, flexDirection: 'row', justifyContent: 'flex-start', paddingHorizontal: 5, }, leftLinksText: { fontSize: 12, fontWeight: 'bold', paddingLeft: 5, }, pencilIcon: { lineHeight: 13, paddingTop: 1, }, rightLinks: { flex: 1, flexDirection: 'row', justifyContent: 'flex-end', paddingHorizontal: 5, }, rightLinksText: { fontSize: 12, fontWeight: 'bold', }, text: { fontFamily: 'System', fontSize: 16, }, textContainer: { position: 'absolute', top: 0, paddingBottom: 6, paddingLeft: 10, paddingRight: 10, paddingTop: 5, }, textInput: { fontFamily: 'System', fontSize: 16, left: Platform.OS === 'android' ? 9.8 : 10, margin: 0, padding: 0, position: 'absolute', right: 10, top: Platform.OS === 'android' ? 4.8 : 0.5, }, }; registerFetchKey(saveEntryActionTypes); registerFetchKey(deleteEntryActionTypes); const activeThreadPickerSelector = createIsForegroundSelector( ThreadPickerModalRouteName, ); const Entry = React.memo(function ConnectedEntry(props: BaseProps) { const navContext = React.useContext(NavContext); const threadPickerActive = activeThreadPickerSelector(navContext); const calendarQuery = useSelector((state) => nonThreadCalendarQuery({ redux: state, navContext, }), ); const online = useSelector( (state) => state.connection.status === 'connected', ); const styles = useStyles(unboundStyles); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callCreateEntry = useServerCall(createEntry); const callSaveEntry = useServerCall(saveEntry); const callDeleteEntry = useServerCall(deleteEntry); return ( ); }); export { InternalEntry, Entry, dummyNodeForEntryHeightMeasurement }; diff --git a/native/calendar/loading-indicator.react.js b/native/calendar/loading-indicator.react.js index b79aad348..c601f6f2d 100644 --- a/native/calendar/loading-indicator.react.js +++ b/native/calendar/loading-indicator.react.js @@ -1,33 +1,34 @@ // @flow -import type { LoadingStatus } from 'lib/types/loading-types'; import * as React from 'react'; import { ActivityIndicator, StyleSheet, Platform } from 'react-native'; import Icon from 'react-native-vector-icons/Feather'; +import type { LoadingStatus } from 'lib/types/loading-types'; + type Props = {| loadingStatus: LoadingStatus, color: string, canUseRed: boolean, |}; function LoadingIndicator(props: Props) { if (props.loadingStatus === 'error') { const colorStyle = props.canUseRed ? { color: 'red' } : { color: props.color }; return ; } else if (props.loadingStatus === 'loading') { return ; } else { return null; } } const styles = StyleSheet.create({ errorIcon: { fontSize: 16, paddingTop: Platform.OS === 'android' ? 6 : 4, }, }); export default LoadingIndicator; diff --git a/native/calendar/section-footer.react.js b/native/calendar/section-footer.react.js index ceb70ad1f..c73a55662 100644 --- a/native/calendar/section-footer.react.js +++ b/native/calendar/section-footer.react.js @@ -1,96 +1,97 @@ // @flow -import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; import * as React from 'react'; import { View, Text, TouchableWithoutFeedback } from 'react-native'; import Icon from 'react-native-vector-icons/FontAwesome'; +import { connect } from 'lib/utils/redux-utils'; + import Button from '../components/button.react'; import type { AppState } from '../redux/redux-setup'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../themes/colors'; type Props = {| dateString: string, onAdd: (dateString: string) => void, onPressWhitespace: () => void, // Redux state colors: Colors, styles: typeof styles, |}; class SectionFooter extends React.PureComponent { static propTypes = { dateString: PropTypes.string.isRequired, onAdd: PropTypes.func.isRequired, onPressWhitespace: PropTypes.func.isRequired, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, }; render() { const { modalIosHighlightUnderlay: underlayColor } = this.props.colors; return ( ); } onSubmit = () => { this.props.onAdd(this.props.dateString); }; } const styles = { actionLinksText: { color: 'listSeparatorLabel', fontWeight: 'bold', }, addButton: { backgroundColor: 'listSeparator', borderRadius: 5, margin: 5, paddingBottom: 5, paddingLeft: 10, paddingRight: 10, paddingTop: 5, }, addButtonContents: { alignItems: 'center', flexDirection: 'row', }, addIcon: { color: 'listSeparatorLabel', fontSize: 14, paddingRight: 6, }, sectionFooter: { alignItems: 'flex-start', backgroundColor: 'listBackground', height: 40, }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ colors: colorsSelector(state), styles: stylesSelector(state), }))(SectionFooter); diff --git a/native/calendar/thread-picker-modal.react.js b/native/calendar/thread-picker-modal.react.js index e5511118f..f20a7f7e6 100644 --- a/native/calendar/thread-picker-modal.react.js +++ b/native/calendar/thread-picker-modal.react.js @@ -1,98 +1,99 @@ // @flow import invariant from 'invariant'; +import * as React from 'react'; +import { StyleSheet } from 'react-native'; +import { useDispatch } from 'react-redux'; + import { createLocalEntry, createLocalEntryActionType, } from 'lib/actions/entry-actions'; import { threadSearchIndex } from 'lib/selectors/nav-selectors'; import { onScreenEntryEditableThreadInfos } from 'lib/selectors/thread-selectors'; -import * as React from 'react'; -import { StyleSheet } from 'react-native'; -import { useDispatch } from 'react-redux'; import Modal from '../components/modal.react'; import ThreadList from '../components/thread-list.react'; import { RootNavigatorContext } from '../navigation/root-navigator-context'; import type { RootNavigationProp } from '../navigation/root-navigator.react'; import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { waitForInteractions } from '../utils/timers'; export type ThreadPickerModalParams = {| presentedFrom: string, dateString: string, |}; type Props = {| navigation: RootNavigationProp<'ThreadPickerModal'>, route: NavigationRoute<'ThreadPickerModal'>, |}; function ThreadPickerModal(props: Props) { const { navigation, route: { params: { dateString }, }, } = props; const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const nextLocalID = useSelector((state) => state.nextLocalID); const dispatch = useDispatch(); const rootNavigatorContext = React.useContext(RootNavigatorContext); const threadPicked = React.useCallback( (threadID: string) => { invariant( dateString && viewerID && rootNavigatorContext, 'inputs to threadPicked should be set', ); rootNavigatorContext.setKeyboardHandlingEnabled(false); dispatch({ type: createLocalEntryActionType, payload: createLocalEntry(threadID, nextLocalID, dateString, viewerID), }); }, [rootNavigatorContext, dispatch, viewerID, nextLocalID, dateString], ); React.useEffect( () => navigation.addListener('blur', async () => { await waitForInteractions(); invariant( rootNavigatorContext, 'RootNavigatorContext should be set in onScreenBlur', ); rootNavigatorContext.setKeyboardHandlingEnabled(true); }), [navigation, rootNavigatorContext], ); const index = useSelector((state) => threadSearchIndex(state)); const onScreenThreadInfos = useSelector((state) => onScreenEntryEditableThreadInfos(state), ); return ( ); } const styles = StyleSheet.create({ threadListItem: { paddingLeft: 10, paddingRight: 10, paddingVertical: 2, }, }); export default ThreadPickerModal; diff --git a/native/chat/background-chat-thread-list.react.js b/native/chat/background-chat-thread-list.react.js index 389726ca5..de05c9724 100644 --- a/native/chat/background-chat-thread-list.react.js +++ b/native/chat/background-chat-thread-list.react.js @@ -1,65 +1,65 @@ // @flow +import * as React from 'react'; +import { Text } from 'react-native'; + import { unreadBackgroundCount } from 'lib/selectors/thread-selectors'; import { threadInBackgroundChatList, emptyItemText, } from 'lib/shared/thread-utils'; -import * as React from 'react'; -import { Text } from 'react-native'; import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { useStyles } from '../themes/colors'; - import ChatThreadList from './chat-thread-list.react'; import type { ChatTopTabsNavigationProp } from './chat.react'; type BackgroundChatThreadListProps = {| navigation: ChatTopTabsNavigationProp<'BackgroundChatThreadList'>, route: NavigationRoute<'BackgroundChatThreadList'>, |}; export default function BackgroundChatThreadList( props: BackgroundChatThreadListProps, ) { const unreadBackgroundThreadsNumber = useSelector((state) => unreadBackgroundCount(state), ); const prevUnreadNumber = React.useRef(0); React.useEffect(() => { if (unreadBackgroundThreadsNumber === prevUnreadNumber.current) { return; } prevUnreadNumber.current = unreadBackgroundThreadsNumber; let title = 'Background'; if (unreadBackgroundThreadsNumber !== 0) { title += ` (${unreadBackgroundThreadsNumber})`; } props.navigation.setOptions({ title }); }, [props.navigation, unreadBackgroundThreadsNumber]); return ( ); } function EmptyItem() { const styles = useStyles(unboundStyles); return {emptyItemText}; } const unboundStyles = { emptyList: { color: 'listBackgroundLabel', fontSize: 17, marginHorizontal: 15, marginVertical: 10, textAlign: 'center', }, }; diff --git a/native/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js index 35e705a8c..899e0a929 100644 --- a/native/chat/chat-input-bar.react.js +++ b/native/chat/chat-input-bar.react.js @@ -1,894 +1,894 @@ // @flow import invariant from 'invariant'; +import _throttle from 'lodash/throttle'; +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { + View, + TextInput, + TouchableOpacity, + Platform, + Text, + ActivityIndicator, + TouchableWithoutFeedback, + Alert, +} from 'react-native'; +import { TextInputKeyboardMangerIOS } from 'react-native-keyboard-input'; +import Animated, { Easing } from 'react-native-reanimated'; +import FAIcon from 'react-native-vector-icons/FontAwesome'; +import Icon from 'react-native-vector-icons/Ionicons'; +import { useDispatch } from 'react-redux'; + import { saveDraftActionType } from 'lib/actions/miscellaneous-action-types'; import { joinThreadActionTypes, joinThread, newThread, newThreadActionTypes, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { trimMessage } from 'lib/shared/message-utils'; import { getOtherMemberID, threadHasPermission, viewerIsMember, threadIsPersonalAndPending, threadFrozenDueToViewerBlock, threadActualMembers, } from 'lib/shared/thread-utils'; import type { CalendarQuery } from 'lib/types/entry-types'; import { loadingStatusPropType } from 'lib/types/loading-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import { messageTypes } from 'lib/types/message-types'; import type { Dispatch } from 'lib/types/redux-types'; import { type ThreadInfo, threadInfoPropType, threadPermissions, type ClientThreadJoinRequest, type ThreadJoinPayload, threadTypes, type NewThreadRequest, type NewThreadResult, } from 'lib/types/thread-types'; import { type UserInfos, userInfoPropType } from 'lib/types/user-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; -import _throttle from 'lodash/throttle'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import { - View, - TextInput, - TouchableOpacity, - Platform, - Text, - ActivityIndicator, - TouchableWithoutFeedback, - Alert, -} from 'react-native'; -import { TextInputKeyboardMangerIOS } from 'react-native-keyboard-input'; -import Animated, { Easing } from 'react-native-reanimated'; -import FAIcon from 'react-native-vector-icons/FontAwesome'; -import Icon from 'react-native-vector-icons/Ionicons'; -import { useDispatch } from 'react-redux'; import Button from '../components/button.react'; import ClearableTextInput from '../components/clearable-text-input.react'; import { type InputState, inputStatePropType, InputStateContext, } from '../input/input-state'; import { getKeyboardHeight } from '../keyboard/keyboard'; import KeyboardInputHost from '../keyboard/keyboard-input-host.react'; import { type KeyboardState, keyboardStatePropType, KeyboardContext, } from '../keyboard/keyboard-state'; import { nonThreadCalendarQuery, activeThreadSelector, } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import type { NavigationRoute } from '../navigation/route-names'; import { CameraModalRouteName } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { type Colors, colorsPropType, useStyles, useColors, } from '../themes/colors'; import type { ViewStyle } from '../types/styles'; import { runTiming } from '../utils/animation-utils'; - import type { ChatNavigationProp } from './chat.react'; import { messageListRoutePropType, messageListNavPropType, } from './message-list-types'; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, set, cond, neq, sub, interpolate, stopClock, } = Animated; /* eslint-enable import/no-named-as-default-member */ const expandoButtonsAnimationConfig = { duration: 500, easing: Easing.inOut(Easing.ease), }; const sendButtonAnimationConfig = { duration: 150, easing: Easing.inOut(Easing.ease), }; const draftKeyFromThreadID = (threadID: string) => `${threadID}/message_composer`; type BaseProps = {| +threadInfo: ThreadInfo, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, |}; type Props = {| ...BaseProps, // Redux state +viewerID: ?string, +draft: string, +joinThreadLoadingStatus: LoadingStatus, +calendarQuery: () => CalendarQuery, +nextLocalID: number, +userInfos: UserInfos, +colors: Colors, +styles: typeof unboundStyles, // connectNav +isActive: boolean, // withKeyboardState +keyboardState: ?KeyboardState, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +joinThread: (request: ClientThreadJoinRequest) => Promise, +newThread: (request: NewThreadRequest) => Promise, // withInputState +inputState: ?InputState, |}; type State = {| +text: string, +buttonsExpanded: boolean, |}; class ChatInputBar extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, navigation: messageListNavPropType.isRequired, route: messageListRoutePropType.isRequired, isActive: PropTypes.bool.isRequired, viewerID: PropTypes.string, draft: PropTypes.string.isRequired, joinThreadLoadingStatus: loadingStatusPropType.isRequired, calendarQuery: PropTypes.func.isRequired, nextLocalID: PropTypes.number.isRequired, userInfos: PropTypes.objectOf(userInfoPropType).isRequired, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, keyboardState: keyboardStatePropType, dispatch: PropTypes.func.isRequired, dispatchActionPromise: PropTypes.func.isRequired, joinThread: PropTypes.func.isRequired, inputState: inputStatePropType, newThread: PropTypes.func.isRequired, }; textInput: ?React.ElementRef; clearableTextInput: ?ClearableTextInput; expandoButtonsOpen: Value; targetExpandoButtonsOpen: Value; expandoButtonsStyle: ViewStyle; cameraRollIconStyle: ViewStyle; cameraIconStyle: ViewStyle; expandIconStyle: ViewStyle; sendButtonContainerOpen: Value; targetSendButtonContainerOpen: Value; sendButtonContainerStyle: ViewStyle; newThreadID: ?string; constructor(props: Props) { super(props); this.state = { text: props.draft, buttonsExpanded: true, }; this.expandoButtonsOpen = new Value(1); this.targetExpandoButtonsOpen = new Value(1); const prevTargetExpandoButtonsOpen = new Value(1); const expandoButtonClock = new Clock(); const expandoButtonsOpen = block([ cond(neq(this.targetExpandoButtonsOpen, prevTargetExpandoButtonsOpen), [ stopClock(expandoButtonClock), set(prevTargetExpandoButtonsOpen, this.targetExpandoButtonsOpen), ]), cond( neq(this.expandoButtonsOpen, this.targetExpandoButtonsOpen), set( this.expandoButtonsOpen, runTiming( expandoButtonClock, this.expandoButtonsOpen, this.targetExpandoButtonsOpen, true, expandoButtonsAnimationConfig, ), ), ), this.expandoButtonsOpen, ]); this.cameraRollIconStyle = { ...unboundStyles.cameraRollIcon, opacity: expandoButtonsOpen, }; this.cameraIconStyle = { ...unboundStyles.cameraIcon, opacity: expandoButtonsOpen, }; const expandoButtonsWidth = interpolate(expandoButtonsOpen, { inputRange: [0, 1], outputRange: [22, 60], }); this.expandoButtonsStyle = { ...unboundStyles.expandoButtons, width: expandoButtonsWidth, }; const expandOpacity = sub(1, expandoButtonsOpen); this.expandIconStyle = { ...unboundStyles.expandIcon, opacity: expandOpacity, }; const initialSendButtonContainerOpen = trimMessage(props.draft) ? 1 : 0; this.sendButtonContainerOpen = new Value(initialSendButtonContainerOpen); this.targetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const prevTargetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const sendButtonClock = new Clock(); const sendButtonContainerOpen = block([ cond( neq( this.targetSendButtonContainerOpen, prevTargetSendButtonContainerOpen, ), [ stopClock(sendButtonClock), set( prevTargetSendButtonContainerOpen, this.targetSendButtonContainerOpen, ), ], ), cond( neq(this.sendButtonContainerOpen, this.targetSendButtonContainerOpen), set( this.sendButtonContainerOpen, runTiming( sendButtonClock, this.sendButtonContainerOpen, this.targetSendButtonContainerOpen, true, sendButtonAnimationConfig, ), ), ), this.sendButtonContainerOpen, ]); const sendButtonContainerWidth = interpolate(sendButtonContainerOpen, { inputRange: [0, 1], outputRange: [4, 38], }); this.sendButtonContainerStyle = { width: sendButtonContainerWidth }; } static mediaGalleryOpen(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.mediaGalleryOpen); } static systemKeyboardShowing(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.systemKeyboardShowing); } get systemKeyboardShowing() { return ChatInputBar.systemKeyboardShowing(this.props); } immediatelyShowSendButton() { this.sendButtonContainerOpen.setValue(1); this.targetSendButtonContainerOpen.setValue(1); } updateSendButton(currentText: string) { this.targetSendButtonContainerOpen.setValue(currentText === '' ? 0 : 1); } componentDidMount() { if (this.props.isActive) { this.addReplyListener(); } } componentWillUnmount() { if (this.props.isActive) { this.removeReplyListener(); } } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.isActive && !prevProps.isActive) { this.addReplyListener(); } else if (!this.props.isActive && prevProps.isActive) { this.removeReplyListener(); } const currentText = trimMessage(this.state.text); const prevText = trimMessage(prevState.text); if ( (currentText === '' && prevText !== '') || (currentText !== '' && prevText === '') ) { this.updateSendButton(currentText); } const systemKeyboardIsShowing = ChatInputBar.systemKeyboardShowing( this.props, ); const systemKeyboardWasShowing = ChatInputBar.systemKeyboardShowing( prevProps, ); if (systemKeyboardIsShowing && !systemKeyboardWasShowing) { this.hideButtons(); } else if (!systemKeyboardIsShowing && systemKeyboardWasShowing) { this.expandButtons(); } const imageGalleryIsOpen = ChatInputBar.mediaGalleryOpen(this.props); const imageGalleryWasOpen = ChatInputBar.mediaGalleryOpen(prevProps); if (!imageGalleryIsOpen && imageGalleryWasOpen) { this.hideButtons(); } else if (imageGalleryIsOpen && !imageGalleryWasOpen) { this.expandButtons(); this.setIOSKeyboardHeight(); } } addReplyListener() { invariant( this.props.inputState, 'inputState should be set in addReplyListener', ); this.props.inputState.addReplyListener(this.focusAndUpdateText); } removeReplyListener() { invariant( this.props.inputState, 'inputState should be set in removeReplyListener', ); this.props.inputState.removeReplyListener(this.focusAndUpdateText); } setIOSKeyboardHeight() { if (Platform.OS !== 'ios') { return; } const { textInput } = this; if (!textInput) { return; } const keyboardHeight = getKeyboardHeight(); if (keyboardHeight === null || keyboardHeight === undefined) { return; } TextInputKeyboardMangerIOS.setKeyboardHeight(textInput, keyboardHeight); } render() { const isMember = viewerIsMember(this.props.threadInfo); const canJoin = threadHasPermission( this.props.threadInfo, threadPermissions.JOIN_THREAD, ); let joinButton = null; if (!isMember && canJoin) { let buttonContent; if (this.props.joinThreadLoadingStatus === 'loading') { buttonContent = ( ); } else { buttonContent = ( Join Thread ); } joinButton = ( ); } let content; if (threadHasPermission(this.props.threadInfo, threadPermissions.VOICED)) { content = this.renderInput(); } else if ( threadFrozenDueToViewerBlock( this.props.threadInfo, this.props.viewerID, this.props.userInfos, ) && threadActualMembers(this.props.threadInfo.members).length === 2 ) { content = ( You can't send messages to a user that you've blocked. ); } else if (isMember) { content = ( You don't have permission to send messages. ); } else { const defaultRoleID = Object.keys(this.props.threadInfo.roles).find( (roleID) => this.props.threadInfo.roles[roleID].isDefault, ); invariant( defaultRoleID !== undefined, 'all threads should have a default role', ); const defaultRole = this.props.threadInfo.roles[defaultRoleID]; const membersAreVoiced = !!defaultRole.permissions[ threadPermissions.VOICED ]; if (membersAreVoiced && canJoin) { content = ( Join this thread to send messages. ); } else { content = ( You don't have permission to send messages. ); } } const keyboardInputHost = Platform.OS === 'android' ? null : ( ); return ( {joinButton} {content} {keyboardInputHost} ); } renderInput() { const expandoButton = ( ); return ( {this.state.buttonsExpanded ? expandoButton : null} {this.state.buttonsExpanded ? null : expandoButton} ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; clearableTextInputRef = (clearableTextInput: ?ClearableTextInput) => { this.clearableTextInput = clearableTextInput; }; updateText = (text: string) => { this.setState({ text }); this.saveDraft(text); }; saveDraft = _throttle((text: string) => { this.props.dispatch({ type: saveDraftActionType, payload: { key: draftKeyFromThreadID(this.props.threadInfo.id), draft: text, }, }); }, 400); focusAndUpdateText = (text: string) => { const currentText = this.state.text; if (!currentText.startsWith(text)) { const prependedText = text.concat(currentText); this.updateText(prependedText); this.immediatelyShowSendButton(); this.immediatelyHideButtons(); } invariant(this.textInput, 'textInput should be set in focusAndUpdateText'); this.textInput.focus(); }; getServerThreadID = async () => { if (this.newThreadID) { return this.newThreadID; } const { threadInfo } = this.props; if (!threadIsPersonalAndPending(threadInfo)) { return threadInfo.id; } const otherMemberID = getOtherMemberID(threadInfo); invariant( otherMemberID, 'Pending thread should contain other member id in its id', ); try { const resultPromise = this.props.newThread({ type: threadTypes.PERSONAL, initialMemberIDs: [otherMemberID], color: threadInfo.color, }); this.props.dispatchActionPromise(newThreadActionTypes, resultPromise); const { newThreadID } = await resultPromise; this.newThreadID = newThreadID; return newThreadID; } catch (e) { Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], { cancelable: false, }); } return undefined; }; onSend = async () => { if (!trimMessage(this.state.text)) { return; } this.updateSendButton(''); const { clearableTextInput } = this; invariant( clearableTextInput, 'clearableTextInput should be sent in onSend', ); let text = await clearableTextInput.getValueAndReset(); text = trimMessage(text); if (!text) { return; } const localID = `local${this.props.nextLocalID}`; const creatorID = this.props.viewerID; const threadID = await this.getServerThreadID(); invariant(creatorID, 'should have viewer ID in order to send a message'); invariant( this.props.inputState, 'inputState should be set in ChatInputBar.onSend', ); if (threadID) { this.props.inputState.sendTextMessage({ type: messageTypes.TEXT, localID, threadID, text, creatorID, time: Date.now(), }); } }; onPressJoin = () => { this.props.dispatchActionPromise(joinThreadActionTypes, this.joinAction()); }; async joinAction() { const query = this.props.calendarQuery(); return await this.props.joinThread({ threadID: this.props.threadInfo.id, calendarQuery: { startDate: query.startDate, endDate: query.endDate, filters: [ ...query.filters, { type: 'threads', threadIDs: [this.props.threadInfo.id] }, ], }, }); } expandButtons = () => { if (this.state.buttonsExpanded) { return; } this.targetExpandoButtonsOpen.setValue(1); this.setState({ buttonsExpanded: true }); }; hideButtons() { if ( ChatInputBar.mediaGalleryOpen(this.props) || !this.systemKeyboardShowing || !this.state.buttonsExpanded ) { return; } this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } immediatelyHideButtons() { this.expandoButtonsOpen.setValue(0); this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } openCamera = async () => { this.dismissKeyboard(); this.props.navigation.navigate({ name: CameraModalRouteName, params: { presentedFrom: this.props.route.key, threadID: this.props.threadInfo.id, }, }); }; showMediaGallery = () => { const { keyboardState } = this.props; invariant(keyboardState, 'keyboardState should be initialized'); keyboardState.showMediaGallery(this.props.threadInfo.id); }; dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const unboundStyles = { cameraIcon: { paddingBottom: Platform.OS === 'android' ? 11 : 10, paddingRight: 3, }, cameraRollIcon: { paddingBottom: Platform.OS === 'android' ? 8 : 7, paddingRight: 8, }, container: { backgroundColor: 'listBackground', }, expandButton: { bottom: 0, position: 'absolute', right: 0, }, expandIcon: { paddingBottom: Platform.OS === 'android' ? 12 : 10, }, expandoButtons: { alignSelf: 'flex-end', }, explanation: { color: 'listBackgroundSecondaryLabel', paddingBottom: 4, paddingTop: 1, textAlign: 'center', }, innerExpandoButtons: { alignItems: 'flex-end', alignSelf: 'flex-end', flexDirection: 'row', }, inputContainer: { flexDirection: 'row', }, joinButton: { backgroundColor: 'mintButton', borderRadius: 5, flex: 1, justifyContent: 'center', marginHorizontal: 12, marginVertical: 3, paddingBottom: 5, paddingTop: 3, }, joinButtonContainer: { flexDirection: 'row', height: 36, }, joinButtonText: { color: 'listBackground', fontSize: 20, textAlign: 'center', }, joinThreadLoadingIndicator: { paddingVertical: 2, }, sendButton: { position: 'absolute', bottom: Platform.OS === 'android' ? 4 : 3, left: 0, }, sendIcon: { paddingLeft: 9, paddingRight: 8, paddingVertical: 5, }, textInput: { backgroundColor: 'listInputBackground', borderRadius: 10, color: 'listForegroundLabel', fontSize: 16, marginLeft: 4, marginVertical: 5, maxHeight: 250, paddingHorizontal: 10, paddingVertical: 5, }, }; const joinThreadLoadingStatusSelector = createLoadingStatusSelector( joinThreadActionTypes, ); export default React.memo(function ConnectedChatInputBar( props: BaseProps, ) { const inputState = React.useContext(InputStateContext); const keyboardState = React.useContext(KeyboardContext); const navContext = React.useContext(NavContext); const styles = useStyles(unboundStyles); const colors = useColors(); const isActive = React.useMemo( () => props.threadInfo.id === activeThreadSelector(navContext), [props.threadInfo.id, navContext], ); const draftKey = draftKeyFromThreadID(props.threadInfo.id); const draft = useSelector((state) => state.drafts[draftKey] || ''); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const joinThreadLoadingStatus = useSelector(joinThreadLoadingStatusSelector); const calendarQuery = useSelector((state) => nonThreadCalendarQuery({ redux: state, navContext, }), ); const nextLocalID = useSelector((state) => state.nextLocalID); const userInfos = useSelector((state) => state.userStore.userInfos); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callJoinThread = useServerCall(joinThread); const callNewThread = useServerCall(newThread); return ( ); }); diff --git a/native/chat/chat-list.react.js b/native/chat/chat-list.react.js index 2485a7664..d449f08be 100644 --- a/native/chat/chat-list.react.js +++ b/native/chat/chat-list.react.js @@ -1,322 +1,322 @@ // @flow import invariant from 'invariant'; -import type { ChatMessageItem } from 'lib/selectors/chat-selectors'; -import { messageKey } from 'lib/shared/message-utils'; import _sum from 'lodash/fp/sum'; import * as React from 'react'; import { FlatList, Animated, Easing, StyleSheet, TouchableWithoutFeedback, View, } from 'react-native'; import type { Props as FlatListProps, DefaultProps as FlatListDefaultProps, } from 'react-native/Libraries/Lists/FlatList'; +import type { ChatMessageItem } from 'lib/selectors/chat-selectors'; +import { messageKey } from 'lib/shared/message-utils'; + import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state'; import type { TabNavigationProp } from '../navigation/app-navigator.react'; import { useSelector } from '../redux/redux-utils'; import type { ViewStyle } from '../types/styles'; - import type { ChatNavigationProp } from './chat.react'; import type { ChatMessageItemWithHeight } from './message-list-container.react'; import { messageItemHeight } from './message.react'; import NewMessagesPill from './new-messages-pill.react'; function chatMessageItemKey(item: ChatMessageItemWithHeight | ChatMessageItem) { if (item.itemType === 'loader') { return 'loader'; } return messageKey(item.messageInfo); } function chatMessageItemHeight(item: ChatMessageItemWithHeight) { if (item.itemType === 'loader') { return 56; } return messageItemHeight(item); } const animationSpec = { duration: 150, useNativeDriver: true, }; type BaseProps = {| ...$ReadOnly< $Exact< React.Config< FlatListProps, FlatListDefaultProps, >, >, >, +navigation: ChatNavigationProp<'MessageList'>, +data: $ReadOnlyArray, |}; type Props = {| ...BaseProps, // Redux state +viewerID: ?string, // withKeyboardState +keyboardState: ?KeyboardState, |}; type State = {| +newMessageCount: number, |}; class ChatList extends React.PureComponent { state: State = { newMessageCount: 0, }; flatList: ?React.ElementRef; scrollPos = 0; newMessagesPillProgress = new Animated.Value(0); newMessagesPillStyle: ViewStyle; constructor(props: Props) { super(props); const sendButtonTranslateY = this.newMessagesPillProgress.interpolate({ inputRange: [0, 1], outputRange: ([10, 0]: number[]), // Flow... }); this.newMessagesPillStyle = { opacity: this.newMessagesPillProgress, transform: [{ translateY: sendButtonTranslateY }], }; } componentDidMount() { const tabNavigation: ?TabNavigationProp< 'Chat', > = this.props.navigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { const tabNavigation: ?TabNavigationProp< 'Chat', > = this.props.navigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.removeListener('tabPress', this.onTabPress); } onTabPress = () => { const { flatList } = this; if (!this.props.navigation.isFocused() || !flatList) { return; } if (this.scrollPos > 0) { flatList.scrollToOffset({ offset: 0 }); } else { this.props.navigation.popToTop(); } }; get scrolledToBottom() { return this.scrollPos <= 0; } componentDidUpdate(prevProps: Props) { const { flatList } = this; if (!flatList || this.props.data.length === prevProps.data.length) { return; } if (this.props.data.length < prevProps.data.length) { // This should only happen due to MessageStorePruner, // which will only prune a thread when it is off-screen flatList.scrollToOffset({ offset: 0, animated: false }); return; } const { scrollPos } = this; let curDataIndex = 0, prevDataIndex = 0, heightSoFar = 0; let adjustScrollPos = 0, newLocalMessage = false, newRemoteMessageCount = 0; while (prevDataIndex < prevProps.data.length && heightSoFar <= scrollPos) { const prevItem = prevProps.data[prevDataIndex]; invariant(prevItem, 'prevDatum should exist'); const prevItemKey = chatMessageItemKey(prevItem); const prevItemHeight = chatMessageItemHeight(prevItem); let curItem = this.props.data[curDataIndex]; while (curItem) { const curItemKey = chatMessageItemKey(curItem); if (curItemKey === prevItemKey) { break; } if (curItemKey.startsWith('local')) { newLocalMessage = true; } else if ( curItem.itemType === 'message' && curItem.messageInfo.creator.id !== this.props.viewerID ) { newRemoteMessageCount++; } adjustScrollPos += chatMessageItemHeight(curItem); curDataIndex++; curItem = this.props.data[curDataIndex]; } if (!curItem) { // Should never happen... console.log(`items added to ChatList, but ${prevItemKey} now missing`); return; } const curItemHeight = chatMessageItemHeight(curItem); adjustScrollPos += curItemHeight - prevItemHeight; heightSoFar += prevItemHeight; prevDataIndex++; curDataIndex++; } if (adjustScrollPos === 0) { return; } flatList.scrollToOffset({ offset: scrollPos + adjustScrollPos, animated: false, }); if (newLocalMessage || scrollPos <= 0) { flatList.scrollToOffset({ offset: 0 }); } else if (newRemoteMessageCount > 0) { this.setState((prevState) => ({ newMessageCount: prevState.newMessageCount + newRemoteMessageCount, })); this.toggleNewMessagesPill(true); } } render() { const { navigation, viewerID, ...rest } = this.props; const { newMessageCount } = this.state; return ( 0 ? 'auto' : 'none'} containerStyle={styles.newMessagesPillContainer} style={this.newMessagesPillStyle} /> ); } flatListRef = (flatList: ?React.ElementRef) => { this.flatList = flatList; }; static getItemLayout( data: ?$ReadOnlyArray, index: number, ) { if (!data) { return { length: 0, offset: 0, index }; } const offset = ChatList.heightOfItems(data.filter((_, i) => i < index)); const item = data[index]; const length = item ? chatMessageItemHeight(item) : 0; return { length, offset, index }; } static heightOfItems( data: $ReadOnlyArray, ): number { return _sum(data.map(chatMessageItemHeight)); } toggleNewMessagesPill(show: boolean) { Animated.timing(this.newMessagesPillProgress, { ...animationSpec, easing: show ? Easing.ease : Easing.out(Easing.ease), toValue: show ? 1 : 0, }).start(({ finished }) => { if (finished && !show) { this.setState({ newMessageCount: 0 }); } }); } onScroll = (event: { +nativeEvent: { +contentOffset: { +y: number }, +contentSize: { +height: number }, }, }) => { this.scrollPos = event.nativeEvent.contentOffset.y; if (this.scrollPos <= 0) { this.toggleNewMessagesPill(false); } // $FlowFixMe FlatList doesn't type ScrollView props this.props.onScroll && this.props.onScroll(event); }; onPressNewMessagesPill = () => { const { flatList } = this; if (!flatList) { return; } flatList.scrollToOffset({ offset: 0 }); this.toggleNewMessagesPill(false); }; onPressBackground = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const styles = StyleSheet.create({ container: { flex: 1, }, newMessagesPillContainer: { bottom: 30, position: 'absolute', right: 30, }, }); const ConnectedChatList = React.memo(function ConnectedChatList( props: BaseProps, ) { const keyboardState = React.useContext(KeyboardContext); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); return ( ); }); export { ConnectedChatList as ChatList, chatMessageItemKey }; diff --git a/native/chat/chat-router.js b/native/chat/chat-router.js index f4d48ed5d..9ddb3d14d 100644 --- a/native/chat/chat-router.js +++ b/native/chat/chat-router.js @@ -1,178 +1,179 @@ // @flow import type { StackNavigationProp, ParamListBase, StackAction, Route, Router, StackRouterOptions, StackNavigationState, RouterConfigOptions, GenericNavigationAction, } from '@react-navigation/native'; import { StackRouter, CommonActions } from '@react-navigation/native'; + import type { ThreadInfo } from 'lib/types/thread-types'; import { clearScreensActionType, replaceWithThreadActionType, clearThreadsActionType, pushNewThreadActionType, } from '../navigation/action-types'; import { removeScreensFromStack, getThreadIDFromRoute, } from '../navigation/navigation-utils'; import { ChatThreadListRouteName, MessageListRouteName, ComposeThreadRouteName, } from '../navigation/route-names'; type ClearScreensAction = {| +type: 'CLEAR_SCREENS', +payload: {| +routeNames: $ReadOnlyArray, |}, |}; type ReplaceWithThreadAction = {| +type: 'REPLACE_WITH_THREAD', +payload: {| +threadInfo: ThreadInfo, |}, |}; type ClearThreadsAction = {| +type: 'CLEAR_THREADS', +payload: {| +threadIDs: $ReadOnlyArray, |}, |}; type PushNewThreadAction = {| +type: 'PUSH_NEW_THREAD', +payload: {| +threadInfo: ThreadInfo, |}, |}; export type ChatRouterNavigationAction = | StackAction | ClearScreensAction | ReplaceWithThreadAction | ClearThreadsAction | PushNewThreadAction; export type ChatRouterNavigationProp< ParamList: ParamListBase = ParamListBase, RouteName: string = string, > = {| ...StackNavigationProp, +clearScreens: (routeNames: $ReadOnlyArray) => void, +replaceWithThread: (threadInfo: ThreadInfo) => void, +clearThreads: (threadIDs: $ReadOnlyArray) => void, +pushNewThread: (threadInfo: ThreadInfo) => void, |}; function ChatRouter( routerOptions: StackRouterOptions, ): Router { const { getStateForAction: baseGetStateForAction, actionCreators: baseActionCreators, shouldActionChangeFocus: baseShouldActionChangeFocus, ...rest } = StackRouter(routerOptions); return { ...rest, getStateForAction: ( lastState: StackNavigationState, action: ChatRouterNavigationAction, options: RouterConfigOptions, ) => { if (action.type === clearScreensActionType) { const { routeNames } = action.payload; if (!lastState) { return lastState; } return removeScreensFromStack(lastState, (route: Route<>) => routeNames.includes(route.name) ? 'remove' : 'keep', ); } else if (action.type === replaceWithThreadActionType) { const { threadInfo } = action.payload; if (!lastState) { return lastState; } const clearedState = removeScreensFromStack( lastState, (route: Route<>) => route.name === ChatThreadListRouteName ? 'keep' : 'remove', ); const navigateAction = CommonActions.navigate({ name: MessageListRouteName, key: `${MessageListRouteName}${threadInfo.id}`, params: { threadInfo }, }); return baseGetStateForAction(clearedState, navigateAction, options); } else if (action.type === clearThreadsActionType) { const threadIDs = new Set(action.payload.threadIDs); if (!lastState) { return lastState; } return removeScreensFromStack(lastState, (route: Route<>) => threadIDs.has(getThreadIDFromRoute(route)) ? 'remove' : 'keep', ); } else if (action.type === pushNewThreadActionType) { const { threadInfo } = action.payload; if (!lastState) { return lastState; } const clearedState = removeScreensFromStack( lastState, (route: Route<>) => route.name === ComposeThreadRouteName ? 'remove' : 'break', ); const navigateAction = CommonActions.navigate({ name: MessageListRouteName, key: `${MessageListRouteName}${threadInfo.id}`, params: { threadInfo }, }); return baseGetStateForAction(clearedState, navigateAction, options); } else { return baseGetStateForAction(lastState, action, options); } }, actionCreators: { ...baseActionCreators, clearScreens: (routeNames: $ReadOnlyArray) => ({ type: clearScreensActionType, payload: { routeNames, }, }), replaceWithThread: (threadInfo: ThreadInfo) => ({ type: replaceWithThreadActionType, payload: { threadInfo }, }: ReplaceWithThreadAction), clearThreads: (threadIDs: $ReadOnlyArray) => ({ type: clearThreadsActionType, payload: { threadIDs }, }), pushNewThread: (threadInfo: ThreadInfo) => ({ type: pushNewThreadActionType, payload: { threadInfo }, }: PushNewThreadAction), }, shouldActionChangeFocus: (action: GenericNavigationAction) => { if (action.type === replaceWithThreadActionType) { return true; } else if (action.type === pushNewThreadActionType) { return true; } else { return baseShouldActionChangeFocus(action); } }, }; } export default ChatRouter; diff --git a/native/chat/chat-thread-list-item.react.js b/native/chat/chat-thread-list-item.react.js index 6449104d3..a64094ac2 100644 --- a/native/chat/chat-thread-list-item.react.js +++ b/native/chat/chat-thread-list-item.react.js @@ -1,163 +1,163 @@ // @flow +import * as React from 'react'; +import { Text, View } from 'react-native'; + import type { ChatThreadItem } from 'lib/selectors/chat-selectors'; import type { ThreadInfo } from 'lib/types/thread-types'; import { shortAbsoluteDate } from 'lib/utils/date-utils'; -import * as React from 'react'; -import { Text, View } from 'react-native'; import Button from '../components/button.react'; import ColorSplotch from '../components/color-splotch.react'; import { SingleLine } from '../components/single-line.react'; import { useColors, useStyles } from '../themes/colors'; - import ChatThreadListSeeMoreSidebars from './chat-thread-list-see-more-sidebars.react'; import ChatThreadListSidebar from './chat-thread-list-sidebar.react'; import MessagePreview from './message-preview.react'; import SwipeableThread from './swipeable-thread.react'; type Props = {| +data: ChatThreadItem, +onPressItem: (threadInfo: ThreadInfo) => void, +onPressSeeMoreSidebars: (threadInfo: ThreadInfo) => void, +onSwipeableWillOpen: (threadInfo: ThreadInfo) => void, +currentlyOpenedSwipeableId: string, |}; function ChatThreadListItem({ data, onPressItem, onPressSeeMoreSidebars, onSwipeableWillOpen, currentlyOpenedSwipeableId, }: Props) { const styles = useStyles(unboundStyles); const colors = useColors(); const lastMessage = React.useMemo(() => { const mostRecentMessageInfo = data.mostRecentMessageInfo; if (!mostRecentMessageInfo) { return ( No messages ); } return ( ); }, [data.mostRecentMessageInfo, data.threadInfo, styles]); const sidebars = data.sidebars.map((sidebarItem) => { if (sidebarItem.type === 'sidebar') { const { type, ...sidebarInfo } = sidebarItem; return ( ); } else { return ( ); } }); const onPress = React.useCallback(() => { onPressItem(data.threadInfo); }, [onPressItem, data.threadInfo]); const lastActivity = shortAbsoluteDate(data.lastUpdatedTime); const unreadStyle = data.threadInfo.currentUser.unread ? styles.unread : null; return ( <> {sidebars} ); } const unboundStyles = { colorSplotch: { marginLeft: 10, marginTop: 2, }, container: { height: 60, paddingLeft: 10, paddingRight: 10, paddingTop: 5, backgroundColor: 'listBackground', }, lastActivity: { color: 'listForegroundTertiaryLabel', fontSize: 16, marginLeft: 10, }, noMessages: { color: 'listForegroundTertiaryLabel', flex: 1, fontSize: 16, fontStyle: 'italic', paddingLeft: 10, }, row: { flex: 1, flexDirection: 'row', justifyContent: 'space-between', }, threadName: { color: 'listForegroundSecondaryLabel', flex: 1, fontSize: 20, paddingLeft: 10, }, unread: { color: 'listForegroundLabel', fontWeight: 'bold', }, }; export default ChatThreadListItem; diff --git a/native/chat/chat-thread-list-see-more-sidebars.react.js b/native/chat/chat-thread-list-see-more-sidebars.react.js index 2d8c63dd2..a3821a256 100644 --- a/native/chat/chat-thread-list-see-more-sidebars.react.js +++ b/native/chat/chat-thread-list-see-more-sidebars.react.js @@ -1,67 +1,68 @@ // @flow -import type { ThreadInfo } from 'lib/types/thread-types'; import * as React from 'react'; import { Text } from 'react-native'; import Icon from 'react-native-vector-icons/Ionicons'; +import type { ThreadInfo } from 'lib/types/thread-types'; + import Button from '../components/button.react'; import { useColors, useStyles } from '../themes/colors'; type Props = {| +threadInfo: ThreadInfo, +unread: boolean, +onPress: (threadInfo: ThreadInfo) => void, |}; function ChatThreadListSeeMoreSidebars(props: Props) { const { onPress, threadInfo } = props; const onPressButton = React.useCallback(() => onPress(threadInfo), [ onPress, threadInfo, ]); const colors = useColors(); const styles = useStyles(unboundStyles); const unreadStyle = props.unread ? styles.unread : null; return ( ); } const unboundStyles = { unread: { color: 'listForegroundLabel', fontWeight: 'bold', }, button: { height: 30, flexDirection: 'row', display: 'flex', marginLeft: 25, marginRight: 10, alignItems: 'center', }, icon: { paddingLeft: 5, color: 'listForegroundSecondaryLabel', width: 35, }, text: { color: 'listForegroundSecondaryLabel', flex: 1, fontSize: 16, paddingLeft: 5, paddingBottom: 2, }, }; export default ChatThreadListSeeMoreSidebars; diff --git a/native/chat/chat-thread-list-sidebar.react.js b/native/chat/chat-thread-list-sidebar.react.js index 4a10f3818..6b5ea9453 100644 --- a/native/chat/chat-thread-list-sidebar.react.js +++ b/native/chat/chat-thread-list-sidebar.react.js @@ -1,35 +1,36 @@ // @flow -import type { ThreadInfo, SidebarInfo } from 'lib/types/thread-types'; import * as React from 'react'; +import type { ThreadInfo, SidebarInfo } from 'lib/types/thread-types'; + import SidebarItem from './sidebar-item.react'; import SwipeableThread from './swipeable-thread.react'; type Props = {| +sidebarInfo: SidebarInfo, +onPressItem: (threadInfo: ThreadInfo) => void, +onSwipeableWillOpen: (threadInfo: ThreadInfo) => void, +currentlyOpenedSwipeableId: string, |}; function ChatThreadListSidebar(props: Props) { const { sidebarInfo, onSwipeableWillOpen, currentlyOpenedSwipeableId, onPressItem, } = props; return ( ); } export default ChatThreadListSidebar; diff --git a/native/chat/chat-thread-list.react.js b/native/chat/chat-thread-list.react.js index e248773a4..d2962c255 100644 --- a/native/chat/chat-thread-list.react.js +++ b/native/chat/chat-thread-list.react.js @@ -1,357 +1,357 @@ // @flow import invariant from 'invariant'; +import _sum from 'lodash/fp/sum'; +import * as React from 'react'; +import { View, FlatList, Platform, TextInput } from 'react-native'; +import { FloatingAction } from 'react-native-floating-action'; +import IonIcon from 'react-native-vector-icons/Ionicons'; +import { createSelector } from 'reselect'; + import { type ChatThreadItem, chatListData, } from 'lib/selectors/chat-selectors'; import { threadSearchIndex as threadSearchIndexSelector } from 'lib/selectors/nav-selectors'; import SearchIndex from 'lib/shared/search-index'; import type { ThreadInfo } from 'lib/types/thread-types'; -import _sum from 'lodash/fp/sum'; -import * as React from 'react'; -import { View, FlatList, Platform, TextInput } from 'react-native'; -import { FloatingAction } from 'react-native-floating-action'; -import IonIcon from 'react-native-vector-icons/Ionicons'; -import { createSelector } from 'reselect'; import Search from '../components/search.react'; import type { TabNavigationProp } from '../navigation/app-navigator.react'; import { ComposeThreadRouteName, MessageListRouteName, SidebarListModalRouteName, HomeChatThreadListRouteName, BackgroundChatThreadListRouteName, type NavigationRoute, } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { type IndicatorStyle, indicatorStyleSelector, useStyles, } from '../themes/colors'; - import ChatThreadListItem from './chat-thread-list-item.react'; import type { ChatTopTabsNavigationProp, ChatNavigationProp, } from './chat.react'; const floatingActions = [ { text: 'Compose', icon: , name: 'compose', position: 1, }, ]; type Item = | ChatThreadItem | {| type: 'search', searchText: string |} | {| type: 'empty', emptyItem: React.ComponentType<{||}> |}; type BaseProps = {| +navigation: | ChatTopTabsNavigationProp<'HomeChatThreadList'> | ChatTopTabsNavigationProp<'BackgroundChatThreadList'>, +route: | NavigationRoute<'HomeChatThreadList'> | NavigationRoute<'BackgroundChatThreadList'>, +filterThreads: (threadItem: ThreadInfo) => boolean, +emptyItem?: React.ComponentType<{||}>, |}; type Props = {| ...BaseProps, // Redux state +chatListData: $ReadOnlyArray, +viewerID: ?string, +threadSearchIndex: SearchIndex, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, |}; type State = {| +searchText: string, +searchResults: Set, +openedSwipeableId: string, |}; type PropsAndState = {| ...Props, ...State |}; class ChatThreadList extends React.PureComponent { state: State = { searchText: '', searchResults: new Set(), openedSwipeableId: '', }; searchInput: ?React.ElementRef; flatList: ?FlatList; scrollPos = 0; componentDidMount() { const chatNavigation: ?ChatNavigationProp< 'ChatThreadList', > = this.props.navigation.dangerouslyGetParent(); invariant(chatNavigation, 'ChatNavigator should be within TabNavigator'); const tabNavigation: ?TabNavigationProp< 'Chat', > = chatNavigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { const chatNavigation: ?ChatNavigationProp< 'ChatThreadList', > = this.props.navigation.dangerouslyGetParent(); invariant(chatNavigation, 'ChatNavigator should be within TabNavigator'); const tabNavigation: ?TabNavigationProp< 'Chat', > = chatNavigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.removeListener('tabPress', this.onTabPress); } onTabPress = () => { if (!this.props.navigation.isFocused()) { return; } if (this.scrollPos > 0 && this.flatList) { this.flatList.scrollToOffset({ offset: 0, animated: true }); } else if (this.props.route.name === BackgroundChatThreadListRouteName) { this.props.navigation.navigate({ name: HomeChatThreadListRouteName }); } }; renderItem = (row: { item: Item }) => { const item = row.item; if (item.type === 'search') { return ( ); } if (item.type === 'empty') { const EmptyItem = item.emptyItem; return ; } return ( ); }; searchInputRef = (searchInput: ?React.ElementRef) => { this.searchInput = searchInput; }; static keyExtractor(item: Item) { if (item.threadInfo) { return item.threadInfo.id; } else if (item.emptyItem) { return 'empty'; } else { return 'search'; } } static getItemLayout(data: ?$ReadOnlyArray, index: number) { if (!data) { return { length: 0, offset: 0, index }; } const offset = ChatThreadList.heightOfItems( data.filter((_, i) => i < index), ); const item = data[index]; const length = item ? ChatThreadList.itemHeight(item) : 0; return { length, offset, index }; } static itemHeight(item: Item): number { if (item.type === 'search') { return Platform.OS === 'ios' ? 54.5 : 55; } // itemHeight for emptyItem might be wrong because of line wrapping // but we don't care because we'll only ever be rendering this item by itself // and it should always be on-screen if (item.type === 'empty') { return 123; } return 60 + item.sidebars.length * 30; } static heightOfItems(data: $ReadOnlyArray): number { return _sum(data.map(ChatThreadList.itemHeight)); } listDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.chatListData, (propsAndState: PropsAndState) => propsAndState.searchText, (propsAndState: PropsAndState) => propsAndState.searchResults, (propsAndState: PropsAndState) => propsAndState.emptyItem, ( reduxChatListData: $ReadOnlyArray, searchText: string, searchResults: Set, emptyItem?: React.ComponentType<{||}>, ): Item[] => { const chatItems = []; if (!searchText) { chatItems.push( ...reduxChatListData.filter((item) => this.props.filterThreads(item.threadInfo), ), ); } else { chatItems.push( ...reduxChatListData.filter((item) => searchResults.has(item.threadInfo.id), ), ); } if (emptyItem && chatItems.length === 0) { chatItems.push({ type: 'empty', emptyItem }); } return [{ type: 'search', searchText }, ...chatItems]; }, ); get listData() { return this.listDataSelector({ ...this.props, ...this.state }); } render() { let floatingAction = null; if (Platform.OS === 'android') { floatingAction = ( ); } // this.props.viewerID is in extraData since it's used by MessagePreview // within ChatThreadListItem return ( {floatingAction} ); } flatListRef = (flatList: ?FlatList) => { this.flatList = flatList; }; onScroll = (event: { +nativeEvent: { +contentOffset: { +y: number } } }) => { this.scrollPos = event.nativeEvent.contentOffset.y; }; onChangeSearchText = (searchText: string) => { const results = this.props.threadSearchIndex.getSearchResults(searchText); this.setState({ searchText, searchResults: new Set(results) }); }; onPressItem = (threadInfo: ThreadInfo) => { this.onChangeSearchText(''); if (this.searchInput) { this.searchInput.blur(); } this.props.navigation.navigate({ name: MessageListRouteName, params: { threadInfo }, key: `${MessageListRouteName}${threadInfo.id}`, }); }; onPressSeeMoreSidebars = (threadInfo: ThreadInfo) => { this.onChangeSearchText(''); if (this.searchInput) { this.searchInput.blur(); } this.props.navigation.navigate({ name: SidebarListModalRouteName, params: { threadInfo }, }); }; onSwipeableWillOpen = (threadInfo: ThreadInfo) => { this.setState((state) => ({ ...state, openedSwipeableId: threadInfo.id })); }; composeThread = () => { this.props.navigation.navigate({ name: ComposeThreadRouteName, params: {}, }); }; } const unboundStyles = { icon: { fontSize: 28, }, container: { flex: 1, }, search: { marginBottom: 8, marginHorizontal: 12, marginTop: Platform.OS === 'android' ? 10 : 8, }, flatList: { flex: 1, backgroundColor: 'listBackground', }, }; export default React.memo(function ConnectedChatThreadList( props: BaseProps, ) { const boundChatListData = useSelector(chatListData); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const threadSearchIndex = useSelector(threadSearchIndexSelector); const styles = useStyles(unboundStyles); const indicatorStyle = useSelector(indicatorStyleSelector); return ( ); }); diff --git a/native/chat/chat.react.js b/native/chat/chat.react.js index 3c1819feb..a178d40c5 100644 --- a/native/chat/chat.react.js +++ b/native/chat/chat.react.js @@ -1,261 +1,260 @@ // @flow import { createMaterialTopTabNavigator, type MaterialTopTabNavigationProp, } from '@react-navigation/material-top-tabs'; import { createNavigatorFactory, useNavigationBuilder, type StackNavigationState, type StackOptions, type StackNavigationEventMap, type StackNavigatorProps, type ExtraStackNavigatorProps, type StackHeaderProps as CoreStackHeaderProps, } from '@react-navigation/native'; import { StackView, type StackHeaderProps } from '@react-navigation/stack'; import invariant from 'invariant'; import * as React from 'react'; import { Platform, View } from 'react-native'; import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react'; import { InputStateContext } from '../input/input-state'; import HeaderBackButton from '../navigation/header-back-button.react'; import { ComposeThreadRouteName, DeleteThreadRouteName, ThreadSettingsRouteName, MessageListRouteName, ChatThreadListRouteName, HomeChatThreadListRouteName, BackgroundChatThreadListRouteName, type ScreenParamList, type ChatParamList, type ChatTopTabsParamList, } from '../navigation/route-names'; import { useStyles } from '../themes/colors'; - import BackgroundChatThreadList from './background-chat-thread-list.react'; import ChatHeader from './chat-header.react'; import ChatRouter, { type ChatRouterNavigationProp } from './chat-router'; import ComposeThreadButton from './compose-thread-button.react'; import ComposeThread from './compose-thread.react'; import HomeChatThreadList from './home-chat-thread-list.react'; import MessageListContainer from './message-list-container.react'; import MessageListHeaderTitle from './message-list-header-title.react'; import MessageStorePruner from './message-store-pruner.react'; import DeleteThread from './settings/delete-thread.react'; import ThreadSettings from './settings/thread-settings.react'; import ThreadScreenPruner from './thread-screen-pruner.react'; import ThreadSettingsButton from './thread-settings-button.react'; const unboundStyles = { keyboardAvoidingView: { flex: 1, }, view: { flex: 1, backgroundColor: 'listBackground', }, threadListHeaderStyle: { elevation: 0, shadowOffset: { width: 0, height: 0 }, borderBottomWidth: 0, }, }; export type ChatTopTabsNavigationProp< RouteName: $Keys = $Keys, > = MaterialTopTabNavigationProp; const homeChatThreadListOptions = { title: 'Home', }; const backgroundChatThreadListOptions = { title: 'Background', }; const ChatThreadsTopTab = createMaterialTopTabNavigator(); const ChatThreadsComponent = () => { return ( ); }; type ChatNavigatorProps = StackNavigatorProps>; function ChatNavigator({ initialRouteName, children, screenOptions, ...rest }: ChatNavigatorProps) { const { state, descriptors, navigation } = useNavigationBuilder(ChatRouter, { initialRouteName, children, screenOptions, }); // Clear ComposeThread screens after each message is sent. If a user goes to // ComposeThread to create a new thread, but finds an existing one and uses it // instead, we can assume the intent behind opening ComposeThread is resolved const inputState = React.useContext(InputStateContext); invariant(inputState, 'InputState should be set in ChatNavigator'); const clearComposeScreensAfterMessageSend = React.useCallback(() => { navigation.clearScreens([ComposeThreadRouteName]); }, [navigation]); React.useEffect(() => { inputState.registerSendCallback(clearComposeScreensAfterMessageSend); return () => { inputState.unregisterSendCallback(clearComposeScreensAfterMessageSend); }; }, [inputState, clearComposeScreensAfterMessageSend]); return ( ); } const createChatNavigator = createNavigatorFactory< StackNavigationState, StackOptions, StackNavigationEventMap, ChatRouterNavigationProp<>, ExtraStackNavigatorProps, >(ChatNavigator); const header = (props: CoreStackHeaderProps) => { // Flow has trouble reconciling identical types between different libdefs, // and flow-typed has no way for one libdef to depend on another const castProps: StackHeaderProps = (props: any); return ; }; const headerBackButton = (props) => ; const screenOptions = { header, headerLeft: headerBackButton, gestureEnabled: Platform.OS === 'ios', animationEnabled: Platform.OS !== 'web' && Platform.OS !== 'windows' && Platform.OS !== 'macos', }; const chatThreadListOptions = ({ navigation }) => ({ headerTitle: 'Threads', headerRight: Platform.OS === 'ios' ? () => : undefined, headerBackTitle: 'Back', headerStyle: unboundStyles.threadListHeaderStyle, }); const messageListOptions = ({ navigation, route }) => ({ // This is a render prop, not a component // eslint-disable-next-line react/display-name headerTitle: () => ( ), headerTitleContainerStyle: { marginHorizontal: Platform.select({ ios: 80, default: 0 }), flex: 1, }, headerRight: Platform.OS === 'android' ? // This is a render prop, not a component // eslint-disable-next-line react/display-name () => ( ) : undefined, headerBackTitle: 'Back', }); const composeThreadOptions = { headerTitle: 'Compose thread', headerBackTitle: 'Back', }; const threadSettingsOptions = ({ route }) => ({ headerTitle: route.params.threadInfo.uiName, headerBackTitle: 'Back', }); const deleteThreadOptions = { headerTitle: 'Delete thread', headerBackTitle: 'Back', }; export type ChatNavigationProp< RouteName: $Keys = $Keys, > = ChatRouterNavigationProp; const Chat = createChatNavigator< ScreenParamList, ChatParamList, ChatNavigationProp<>, >(); export default function ChatComponent() { const styles = useStyles(unboundStyles); const behavior = Platform.select({ android: 'height', default: 'padding', }); return ( ); } diff --git a/native/chat/compose-thread-button.react.js b/native/chat/compose-thread-button.react.js index 57d9ec093..10ca1b720 100644 --- a/native/chat/compose-thread-button.react.js +++ b/native/chat/compose-thread-button.react.js @@ -1,57 +1,57 @@ // @flow -import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; import * as React from 'react'; import { StyleSheet } from 'react-native'; import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import { connect } from 'lib/utils/redux-utils'; + import Button from '../components/button.react'; import { ComposeThreadRouteName } from '../navigation/route-names'; import type { AppState } from '../redux/redux-setup'; import { type Colors, colorsPropType, colorsSelector } from '../themes/colors'; - import type { ChatNavigationProp } from './chat.react'; type Props = {| navigate: $PropertyType, 'navigate'>, // Redux state colors: Colors, |}; class ComposeThreadButton extends React.PureComponent { static propTypes = { navigate: PropTypes.func.isRequired, colors: colorsPropType.isRequired, }; render() { const { link: linkColor } = this.props.colors; return ( ); } onPress = () => { this.props.navigate({ name: ComposeThreadRouteName, params: {}, }); }; } const styles = StyleSheet.create({ composeButton: { paddingHorizontal: 10, }, }); export default connect((state: AppState) => ({ colors: colorsSelector(state), }))(ComposeThreadButton); diff --git a/native/chat/compose-thread.react.js b/native/chat/compose-thread.react.js index ab64666f2..56a473f55 100644 --- a/native/chat/compose-thread.react.js +++ b/native/chat/compose-thread.react.js @@ -1,521 +1,521 @@ // @flow import invariant from 'invariant'; +import _filter from 'lodash/fp/filter'; +import _flow from 'lodash/fp/flow'; +import _sortBy from 'lodash/fp/sortBy'; +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { View, Text, Alert } from 'react-native'; +import { createSelector } from 'reselect'; + import { newThreadActionTypes, newThread } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { userInfoSelectorForPotentialMembers, userSearchIndexForPotentialMembers, } from 'lib/selectors/user-selectors'; import SearchIndex from 'lib/shared/search-index'; import { getPotentialMemberItems } from 'lib/shared/search-utils'; import { threadInFilterList, userIsMember } from 'lib/shared/thread-utils'; import { loadingStatusPropType } from 'lib/types/loading-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import { type ThreadInfo, threadInfoPropType, type ThreadType, threadTypes, threadTypePropType, type NewThreadRequest, type NewThreadResult, } from 'lib/types/thread-types'; import { type AccountUserInfo, accountUserInfoPropType, } from 'lib/types/user-types'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; -import _filter from 'lodash/fp/filter'; -import _flow from 'lodash/fp/flow'; -import _sortBy from 'lodash/fp/sortBy'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import { View, Text, Alert } from 'react-native'; -import { createSelector } from 'reselect'; import LinkButton from '../components/link-button.react'; import TagInput from '../components/tag-input.react'; import ThreadList from '../components/thread-list.react'; import ThreadVisibility from '../components/thread-visibility.react'; import UserList from '../components/user-list.react'; import type { NavigationRoute } from '../navigation/route-names'; import { MessageListRouteName } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { type Colors, colorsPropType, useColors, useStyles, } from '../themes/colors'; - import type { ChatNavigationProp } from './chat.react'; const tagInputProps = { placeholder: 'username', autoFocus: true, returnKeyType: 'go', }; export type ComposeThreadParams = {| threadType?: ThreadType, parentThreadInfo?: ThreadInfo, |}; type BaseProps = {| +navigation: ChatNavigationProp<'ComposeThread'>, +route: NavigationRoute<'ComposeThread'>, |}; type Props = {| ...BaseProps, // Redux state +parentThreadInfo: ?ThreadInfo, +loadingStatus: LoadingStatus, +otherUserInfos: { [id: string]: AccountUserInfo }, +userSearchIndex: SearchIndex, +threadInfos: { [id: string]: ThreadInfo }, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +newThread: (request: NewThreadRequest) => Promise, |}; type State = {| +usernameInputText: string, +userInfoInputArray: $ReadOnlyArray, |}; type PropsAndState = {| ...Props, ...State |}; class ComposeThread extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ setParams: PropTypes.func.isRequired, setOptions: PropTypes.func.isRequired, navigate: PropTypes.func.isRequired, pushNewThread: PropTypes.func.isRequired, }).isRequired, route: PropTypes.shape({ key: PropTypes.string.isRequired, params: PropTypes.shape({ threadType: threadTypePropType, parentThreadInfo: threadInfoPropType, }).isRequired, }).isRequired, parentThreadInfo: threadInfoPropType, loadingStatus: loadingStatusPropType.isRequired, otherUserInfos: PropTypes.objectOf(accountUserInfoPropType).isRequired, userSearchIndex: PropTypes.instanceOf(SearchIndex).isRequired, threadInfos: PropTypes.objectOf(threadInfoPropType).isRequired, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, dispatchActionPromise: PropTypes.func.isRequired, newThread: PropTypes.func.isRequired, }; state: State = { usernameInputText: '', userInfoInputArray: [], }; tagInput: ?TagInput; createThreadPressed = false; waitingOnThreadID: ?string; componentDidMount() { this.setLinkButton(true); } setLinkButton(enabled: boolean) { this.props.navigation.setOptions({ headerRight: () => ( ), }); } componentDidUpdate(prevProps: Props) { const oldReduxParentThreadInfo = prevProps.parentThreadInfo; const newReduxParentThreadInfo = this.props.parentThreadInfo; if ( newReduxParentThreadInfo && newReduxParentThreadInfo !== oldReduxParentThreadInfo ) { this.props.navigation.setParams({ parentThreadInfo: newReduxParentThreadInfo, }); } if ( this.waitingOnThreadID && this.props.threadInfos[this.waitingOnThreadID] && !prevProps.threadInfos[this.waitingOnThreadID] ) { const threadInfo = this.props.threadInfos[this.waitingOnThreadID]; this.props.navigation.pushNewThread(threadInfo); } } static getParentThreadInfo(props: { route: NavigationRoute<'ComposeThread'>, }): ?ThreadInfo { return props.route.params.parentThreadInfo; } userSearchResultsSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.usernameInputText, (propsAndState: PropsAndState) => propsAndState.otherUserInfos, (propsAndState: PropsAndState) => propsAndState.userSearchIndex, (propsAndState: PropsAndState) => propsAndState.userInfoInputArray, (propsAndState: PropsAndState) => ComposeThread.getParentThreadInfo(propsAndState), ( text: string, userInfos: { [id: string]: AccountUserInfo }, searchIndex: SearchIndex, userInfoInputArray: $ReadOnlyArray, parentThreadInfo: ?ThreadInfo, ) => getPotentialMemberItems( text, userInfos, searchIndex, userInfoInputArray.map((userInfo) => userInfo.id), parentThreadInfo, ), ); get userSearchResults() { return this.userSearchResultsSelector({ ...this.props, ...this.state }); } existingThreadsSelector = createSelector( (propsAndState: PropsAndState) => ComposeThread.getParentThreadInfo(propsAndState), (propsAndState: PropsAndState) => propsAndState.threadInfos, (propsAndState: PropsAndState) => propsAndState.userInfoInputArray, ( parentThreadInfo: ?ThreadInfo, threadInfos: { [id: string]: ThreadInfo }, userInfoInputArray: $ReadOnlyArray, ) => { const userIDs = userInfoInputArray.map((userInfo) => userInfo.id); if (userIDs.length === 0) { return []; } return _flow( _filter( (threadInfo: ThreadInfo) => threadInFilterList(threadInfo) && (!parentThreadInfo || threadInfo.parentThreadID === parentThreadInfo.id) && userIDs.every((userID) => userIsMember(threadInfo, userID)), ), _sortBy( ([ 'members.length', (threadInfo: ThreadInfo) => (threadInfo.name ? 1 : 0), ]: $ReadOnlyArray mixed)>), ), )(threadInfos); }, ); get existingThreads() { return this.existingThreadsSelector({ ...this.props, ...this.state }); } render() { let existingThreadsSection = null; const { existingThreads, userSearchResults } = this; if (existingThreads.length > 0) { existingThreadsSection = ( Existing threads ); } let parentThreadRow = null; const parentThreadInfo = ComposeThread.getParentThreadInfo(this.props); if (parentThreadInfo) { const threadType = this.props.route.params.threadType; invariant( threadType !== undefined && threadType !== null, `no threadType provided for ${parentThreadInfo.id}`, ); const threadVisibilityColor = this.props.colors.modalForegroundLabel; parentThreadRow = ( within {parentThreadInfo.uiName} ); } const inputProps = { ...tagInputProps, onSubmitEditing: this.onPressCreateThread, }; return ( {parentThreadRow} To: {existingThreadsSection} ); } tagInputRef = (tagInput: ?TagInput) => { this.tagInput = tagInput; }; onChangeTagInput = (userInfoInputArray: $ReadOnlyArray) => { this.setState({ userInfoInputArray }); }; tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; setUsernameInputText = (text: string) => { this.setState({ usernameInputText: text }); }; onUserSelect = (userID: string) => { for (let existingUserInfo of this.state.userInfoInputArray) { if (userID === existingUserInfo.id) { return; } } const userInfoInputArray = [ ...this.state.userInfoInputArray, this.props.otherUserInfos[userID], ]; this.setState({ userInfoInputArray, usernameInputText: '', }); }; onPressCreateThread = () => { if (this.createThreadPressed) { return; } if (this.state.userInfoInputArray.length === 0) { Alert.alert( 'Chatting to yourself?', 'Are you sure you want to create a thread containing only yourself?', [ { text: 'Cancel', style: 'cancel' }, { text: 'Confirm', onPress: this.dispatchNewChatThreadAction }, ], { cancelable: true }, ); } else { this.dispatchNewChatThreadAction(); } }; dispatchNewChatThreadAction = async () => { this.createThreadPressed = true; this.props.dispatchActionPromise( newThreadActionTypes, this.newChatThreadAction(), ); }; async newChatThreadAction() { this.setLinkButton(false); try { const threadTypeParam = this.props.route.params.threadType; const threadType = threadTypeParam ? threadTypeParam : threadTypes.CHAT_SECRET; const initialMemberIDs = this.state.userInfoInputArray.map( (userInfo: AccountUserInfo) => userInfo.id, ); const parentThreadInfo = ComposeThread.getParentThreadInfo(this.props); const result = await this.props.newThread({ type: threadType, parentThreadID: parentThreadInfo ? parentThreadInfo.id : null, initialMemberIDs, color: parentThreadInfo ? parentThreadInfo.color : null, }); this.waitingOnThreadID = result.newThreadID; return result; } catch (e) { this.createThreadPressed = false; this.setLinkButton(true); Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { invariant(this.tagInput, 'tagInput should be set'); this.tagInput.focus(); }; onUnknownErrorAlertAcknowledged = () => { this.setState({ usernameInputText: '' }, this.onErrorAcknowledged); }; onSelectExistingThread = (threadID: string) => { const threadInfo = this.props.threadInfos[threadID]; this.props.navigation.navigate({ name: MessageListRouteName, params: { threadInfo }, key: `${MessageListRouteName}${threadInfo.id}`, }); }; } const unboundStyles = { container: { flex: 1, }, existingThreadList: { backgroundColor: 'modalBackground', flex: 1, paddingRight: 12, }, existingThreads: { flex: 1, }, existingThreadsLabel: { color: 'modalForegroundSecondaryLabel', fontSize: 16, paddingLeft: 12, textAlign: 'center', }, existingThreadsRow: { backgroundColor: 'modalForeground', borderBottomWidth: 1, borderColor: 'modalForegroundBorder', borderTopWidth: 1, paddingVertical: 6, }, listItem: { color: 'modalForegroundLabel', }, parentThreadLabel: { color: 'modalSubtextLabel', fontSize: 16, paddingLeft: 6, }, parentThreadName: { color: 'modalForegroundLabel', fontSize: 16, paddingLeft: 6, }, parentThreadRow: { alignItems: 'center', backgroundColor: 'modalSubtext', flexDirection: 'row', paddingLeft: 12, paddingVertical: 6, }, tagInputContainer: { flex: 1, marginLeft: 8, paddingRight: 12, }, tagInputLabel: { color: 'modalForegroundSecondaryLabel', fontSize: 16, paddingLeft: 12, }, userList: { backgroundColor: 'modalBackground', flex: 1, paddingLeft: 35, paddingRight: 12, }, userSelectionRow: { alignItems: 'center', backgroundColor: 'modalForeground', borderBottomWidth: 1, borderColor: 'modalForegroundBorder', flexDirection: 'row', paddingVertical: 6, }, }; export default React.memo(function ConnectedComposeThread( props: BaseProps, ) { const parentThreadInfoID = props.route.params.parentThreadInfo?.id; const reduxParentThreadInfo = useSelector((state) => parentThreadInfoID ? threadInfoSelector(state)[parentThreadInfoID] : null, ); const loadingStatus = useSelector( createLoadingStatusSelector(newThreadActionTypes), ); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const threadInfos = useSelector(threadInfoSelector); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callNewThread = useServerCall(newThread); return ( ); }); diff --git a/native/chat/composed-message.react.js b/native/chat/composed-message.react.js index a99c22227..18b73b2f3 100644 --- a/native/chat/composed-message.react.js +++ b/native/chat/composed-message.react.js @@ -1,188 +1,188 @@ // @flow import invariant from 'invariant'; -import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; -import { createMessageReply } from 'lib/shared/message-utils'; -import { assertComposableMessageType } from 'lib/types/message-types'; 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 { useSelector } from '../redux/redux-utils'; import { type Colors, colorsPropType, 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 49adb9a17..4453d28eb 100644 --- a/native/chat/failed-send.react.js +++ b/native/chat/failed-send.react.js @@ -1,170 +1,170 @@ // @flow import invariant from 'invariant'; -import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; -import { messageID } from 'lib/shared/message-utils'; -import { messageTypes, type RawMessageInfo } from 'lib/types/message-types'; 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 { 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/home-chat-thread-list.react.js b/native/chat/home-chat-thread-list.react.js index 43df1f264..3067629db 100644 --- a/native/chat/home-chat-thread-list.react.js +++ b/native/chat/home-chat-thread-list.react.js @@ -1,23 +1,23 @@ // @flow -import { threadInHomeChatList } from 'lib/shared/thread-utils'; import * as React from 'react'; -import type { NavigationRoute } from '../navigation/route-names'; +import { threadInHomeChatList } from 'lib/shared/thread-utils'; +import type { NavigationRoute } from '../navigation/route-names'; import ChatThreadList from './chat-thread-list.react'; import type { ChatTopTabsNavigationProp } from './chat.react'; type HomeChatThreadListProps = {| navigation: ChatTopTabsNavigationProp<'HomeChatThreadList'>, route: NavigationRoute<'HomeChatThreadList'>, |}; export default function HomeChatThreadList(props: HomeChatThreadListProps) { return ( ); } diff --git a/native/chat/inline-multimedia.react.js b/native/chat/inline-multimedia.react.js index 80ac254bb..47013f3ca 100644 --- a/native/chat/inline-multimedia.react.js +++ b/native/chat/inline-multimedia.react.js @@ -1,102 +1,103 @@ // @flow -import type { MediaInfo } from 'lib/types/media-types'; import * as React from 'react'; import { View, StyleSheet } from 'react-native'; import * as Progress from 'react-native-progress'; import Icon from 'react-native-vector-icons/Feather'; +import type { MediaInfo } from 'lib/types/media-types'; + import GestureTouchableOpacity from '../components/gesture-touchable-opacity.react'; import type { PendingMultimediaUpload } from '../input/input-state'; import { KeyboardContext } from '../keyboard/keyboard-state'; import Multimedia from '../media/multimedia.react'; const formatProgressText = (progress: number) => `${Math.floor(progress * 100)}%`; type Props = {| +mediaInfo: MediaInfo, +onPress: () => void, +onLongPress: () => void, +postInProgress: boolean, +pendingUpload: ?PendingMultimediaUpload, +spinnerColor: string, |}; function InlineMultimedia(props: Props) { const { mediaInfo, pendingUpload, postInProgress } = props; let failed = mediaInfo.id.startsWith('localUpload') && !postInProgress; let progressPercent = 1; if (pendingUpload) { ({ progressPercent, failed } = pendingUpload); } let progressIndicator; if (failed) { progressIndicator = ( ); } else if (progressPercent !== 1) { progressIndicator = ( ); } const keyboardState = React.useContext(KeyboardContext); const keyboardShowing = keyboardState?.keyboardShowing; return ( {progressIndicator} ); } const styles = StyleSheet.create({ centerContainer: { alignItems: 'center', bottom: 0, justifyContent: 'center', left: 0, position: 'absolute', right: 0, top: 0, }, expand: { flex: 1, }, progressIndicatorText: { color: 'black', fontSize: 21, }, uploadError: { color: 'white', textShadowColor: '#000', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 1, }, }); export default InlineMultimedia; diff --git a/native/chat/inner-text-message.react.js b/native/chat/inner-text-message.react.js index 352f9c1d2..2768b1af3 100644 --- a/native/chat/inner-text-message.react.js +++ b/native/chat/inner-text-message.react.js @@ -1,139 +1,139 @@ // @flow import invariant from 'invariant'; -import { colorIsDark } from 'lib/shared/thread-utils'; import * as React from 'react'; import { View, StyleSheet } from 'react-native'; +import { colorIsDark } from 'lib/shared/thread-utils'; + import GestureTouchableOpacity from '../components/gesture-touchable-opacity.react'; import { KeyboardContext } from '../keyboard/keyboard-state'; import Markdown from '../markdown/markdown.react'; import { useSelector } from '../redux/redux-utils'; import { useColors, colors } from '../themes/colors'; - import { composedMessageMaxWidthSelector } from './composed-message-width'; import { MessageListContext } from './message-list-types'; import { allCorners, filterCorners, getRoundedContainerStyle, } from './rounded-corners'; import type { ChatTextMessageInfoItemWithHeight } from './text-message.react'; function useTextMessageMarkdownRules(useDarkStyle: boolean) { const messageListContext = React.useContext(MessageListContext); invariant(messageListContext, 'DummyTextNode should have MessageListContext'); return messageListContext.getTextMessageMarkdownRules(useDarkStyle); } function dummyNodeForTextMessageHeightMeasurement(text: string) { return {text}; } type DummyTextNodeProps = {| ...React.ElementConfig, +children: string, |}; function DummyTextNode(props: DummyTextNodeProps) { const { children, style, ...rest } = props; const maxWidth = useSelector((state) => composedMessageMaxWidthSelector(state), ); const viewStyle = [props.style, styles.dummyMessage, { maxWidth }]; const rules = useTextMessageMarkdownRules(false); return ( {children} ); } type Props = {| +item: ChatTextMessageInfoItemWithHeight, +onPress: () => void, +messageRef?: (message: ?React.ElementRef) => void, |}; function InnerTextMessage(props: Props) { const { item } = props; const { text, creator } = item.messageInfo; const { isViewer } = creator; const activeTheme = useSelector((state) => state.globalThemeInfo.activeTheme); const boundColors = useColors(); let messageStyle = {}, textStyle = {}, darkColor; if (isViewer) { const threadColor = item.threadInfo.color; messageStyle.backgroundColor = `#${threadColor}`; darkColor = colorIsDark(threadColor); } else { messageStyle.backgroundColor = boundColors.listChatBubble; darkColor = activeTheme === 'dark'; } textStyle.color = darkColor ? colors.dark.listForegroundLabel : colors.light.listForegroundLabel; const cornerStyle = getRoundedContainerStyle(filterCorners(allCorners, item)); if (!__DEV__) { // We don't force view height in dev mode because we // want to measure it in Message to see if it's correct messageStyle.height = item.contentHeight; } const keyboardState = React.useContext(KeyboardContext); const keyboardShowing = keyboardState?.keyboardShowing; const rules = useTextMessageMarkdownRules(darkColor); const message = ( {text} ); // We need to set onLayout in order to allow .measure() to be on the ref const onLayout = React.useCallback(() => {}, []); const { messageRef } = props; if (!messageRef) { return message; } return ( {message} ); } const styles = StyleSheet.create({ dummyMessage: { paddingHorizontal: 12, paddingVertical: 6, }, message: { overflow: 'hidden', paddingHorizontal: 12, paddingVertical: 6, }, text: { fontFamily: 'Arial', fontSize: 18, }, }); export { InnerTextMessage, dummyNodeForTextMessageHeightMeasurement }; diff --git a/native/chat/message-header.react.js b/native/chat/message-header.react.js index cec0cc259..2f4ce039b 100644 --- a/native/chat/message-header.react.js +++ b/native/chat/message-header.react.js @@ -1,82 +1,82 @@ // @flow -import { stringForUser } from 'lib/shared/user-utils'; import * as React from 'react'; import { View } from 'react-native'; +import { stringForUser } from 'lib/shared/user-utils'; + import { SingleLine } from '../components/single-line.react'; import { useStyles } from '../themes/colors'; - import { clusterEndHeight } from './composed-message.react'; import type { ChatMessageInfoItemWithHeight } from './message.react'; import type { DisplayType } from './timestamp.react'; import { Timestamp, timestampHeight } from './timestamp.react'; type Props = {| +item: ChatMessageInfoItemWithHeight, +focused: boolean, +display: DisplayType, |}; function MessageHeader(props: Props) { const styles = useStyles(unboundStyles); const { item, focused, display } = props; const { creator, time } = item.messageInfo; const { isViewer } = creator; const modalDisplay = display === 'modal'; let authorName = null; if (!isViewer && (modalDisplay || item.startsCluster)) { const style = [styles.authorName]; if (modalDisplay) { style.push(styles.modal); } authorName = ( {stringForUser(creator)} ); } const timestamp = modalDisplay || item.startsConversation ? ( ) : null; let style = null; if (focused && !modalDisplay) { let topMargin = 0; if (!item.startsCluster && !item.messageInfo.creator.isViewer) { topMargin += authorNameHeight + clusterEndHeight; } if (!item.startsConversation) { topMargin += timestampHeight; } style = { marginTop: topMargin }; } return ( {timestamp} {authorName} ); } const authorNameHeight = 25; const unboundStyles = { authorName: { bottom: 0, color: 'listBackgroundSecondaryLabel', fontSize: 14, height: authorNameHeight, marginLeft: 12, marginRight: 7, paddingHorizontal: 12, paddingVertical: 4, }, modal: { // high contrast framed against OverlayNavigator-dimmed background color: 'white', }, }; export { MessageHeader, authorNameHeight }; diff --git a/native/chat/message-list-container.react.js b/native/chat/message-list-container.react.js index 8fd860adc..13047d35e 100644 --- a/native/chat/message-list-container.react.js +++ b/native/chat/message-list-container.react.js @@ -1,300 +1,300 @@ // @flow import invariant from 'invariant'; +import * as React from 'react'; +import { View } from 'react-native'; + import { type ChatMessageItem, messageListData, } from 'lib/selectors/chat-selectors'; import { possiblyPendingThreadInfoSelector } from 'lib/selectors/thread-selectors'; import { messageID } from 'lib/shared/message-utils'; import { messageTypes } from 'lib/types/message-types'; import type { ThreadInfo } from 'lib/types/thread-types'; -import * as React from 'react'; -import { View } from 'react-native'; import ContentLoading from '../components/content-loading.react'; import NodeHeightMeasurer from '../components/node-height-measurer.react'; import { type InputState, InputStateContext } from '../input/input-state'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context'; import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { type Colors, useColors, useStyles } from '../themes/colors'; - import ChatInputBar from './chat-input-bar.react'; import { chatMessageItemKey } from './chat-list.react'; import type { ChatNavigationProp } from './chat.react'; import { composedMessageMaxWidthSelector } from './composed-message-width'; import { dummyNodeForTextMessageHeightMeasurement } from './inner-text-message.react'; import { MessageListContext, useMessageListContext, } from './message-list-types'; import MessageList from './message-list.react'; import type { ChatMessageInfoItemWithHeight } from './message.react'; import { multimediaMessageContentSizes } from './multimedia-message.react'; import { dummyNodeForRobotextMessageHeightMeasurement } from './robotext-message.react'; export type ChatMessageItemWithHeight = | {| itemType: 'loader' |} | ChatMessageInfoItemWithHeight; type BaseProps = {| +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, |}; type Props = {| ...BaseProps, // Redux state +threadInfo: ?ThreadInfo, +messageListData: $ReadOnlyArray, +composedMessageMaxWidth: number, +colors: Colors, +styles: typeof unboundStyles, // withInputState +inputState: ?InputState, // withOverlayContext +overlayContext: ?OverlayContextType, |}; type State = {| +listDataWithHeights: ?$ReadOnlyArray, |}; class MessageListContainer extends React.PureComponent { state: State = { listDataWithHeights: null, }; pendingListDataWithHeights: ?$ReadOnlyArray; static getThreadInfo(props: Props): ThreadInfo { const { threadInfo } = props; if (threadInfo) { return threadInfo; } return props.route.params.threadInfo; } get frozen() { const { overlayContext } = this.props; invariant( overlayContext, 'MessageListContainer should have OverlayContext', ); return overlayContext.scrollBlockingModalStatus !== 'closed'; } componentDidUpdate(prevProps: Props) { const oldReduxThreadInfo = prevProps.threadInfo; const newReduxThreadInfo = this.props.threadInfo; if (newReduxThreadInfo && newReduxThreadInfo !== oldReduxThreadInfo) { this.props.navigation.setParams({ threadInfo: newReduxThreadInfo }); } const oldListData = prevProps.messageListData; const newListData = this.props.messageListData; if (!newListData && oldListData) { this.setState({ listDataWithHeights: null }); } if (!this.frozen && this.pendingListDataWithHeights) { this.setState({ listDataWithHeights: this.pendingListDataWithHeights }); this.pendingListDataWithHeights = undefined; } } render() { const threadInfo = MessageListContainer.getThreadInfo(this.props); const { listDataWithHeights } = this.state; let messageList; if (listDataWithHeights) { messageList = ( ); } else { messageList = ( ); } return ( {messageList} ); } heightMeasurerID = (item: ChatMessageItem) => { return chatMessageItemKey(item); }; heightMeasurerKey = (item: ChatMessageItem) => { if (item.itemType !== 'message') { return null; } const { messageInfo } = item; if (messageInfo.type === messageTypes.TEXT) { return messageInfo.text; } else if (item.robotext && typeof item.robotext === 'string') { return item.robotext; } return null; }; heightMeasurerDummy = (item: ChatMessageItem) => { invariant( item.itemType === 'message', 'NodeHeightMeasurer asked for dummy for non-message item', ); const { messageInfo } = item; if (messageInfo.type === messageTypes.TEXT) { return dummyNodeForTextMessageHeightMeasurement(messageInfo.text); } else if (item.robotext && typeof item.robotext === 'string') { return dummyNodeForRobotextMessageHeightMeasurement(item.robotext); } invariant(false, 'NodeHeightMeasurer asked for dummy for non-text message'); }; heightMeasurerMergeItem = (item: ChatMessageItem, height: ?number) => { if (item.itemType !== 'message') { return item; } const { messageInfo } = item; const threadInfo = MessageListContainer.getThreadInfo(this.props); if ( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA ) { const { inputState } = this.props; // Conditional due to Flow... const localMessageInfo = item.localMessageInfo ? item.localMessageInfo : null; const id = messageID(messageInfo); const pendingUploads = inputState && inputState.pendingUploads && inputState.pendingUploads[id]; const sizes = multimediaMessageContentSizes( messageInfo, this.props.composedMessageMaxWidth, ); return { itemType: 'message', messageShapeType: 'multimedia', messageInfo, localMessageInfo, threadInfo, startsConversation: item.startsConversation, startsCluster: item.startsCluster, endsCluster: item.endsCluster, pendingUploads, ...sizes, }; } invariant(height !== null && height !== undefined, 'height should be set'); if (messageInfo.type === messageTypes.TEXT) { // Conditional due to Flow... const localMessageInfo = item.localMessageInfo ? item.localMessageInfo : null; return { itemType: 'message', messageShapeType: 'text', messageInfo, localMessageInfo, threadInfo, startsConversation: item.startsConversation, startsCluster: item.startsCluster, endsCluster: item.endsCluster, contentHeight: height, }; } else { invariant( typeof item.robotext === 'string', "Flow can't handle our fancy types :(", ); return { itemType: 'message', messageShapeType: 'robotext', messageInfo, threadInfo, startsConversation: item.startsConversation, startsCluster: item.startsCluster, endsCluster: item.endsCluster, robotext: item.robotext, contentHeight: height, }; } }; allHeightsMeasured = ( listDataWithHeights: $ReadOnlyArray, ) => { if (this.frozen) { this.pendingListDataWithHeights = listDataWithHeights; } else { this.setState({ listDataWithHeights }); } }; } const unboundStyles = { container: { backgroundColor: 'listBackground', flex: 1, }, }; export default React.memo(function ConnectedMessageListContainer( props: BaseProps, ) { const threadInfo = useSelector( possiblyPendingThreadInfoSelector(props.route.params.threadInfo), ); const threadID = threadInfo?.id ?? props.route.params.threadInfo.id; const boundMessageListData = useSelector(messageListData(threadID)); const composedMessageMaxWidth = useSelector(composedMessageMaxWidthSelector); const colors = useColors(); const styles = useStyles(unboundStyles); const inputState = React.useContext(InputStateContext); const overlayContext = React.useContext(OverlayContext); const messageListContext = useMessageListContext(threadID); return ( ); }); diff --git a/native/chat/message-list-header-title.react.js b/native/chat/message-list-header-title.react.js index 68e501d5b..c58b5fc01 100644 --- a/native/chat/message-list-header-title.react.js +++ b/native/chat/message-list-header-title.react.js @@ -1,109 +1,109 @@ // @flow import { HeaderTitle } from '@react-navigation/stack'; -import { threadIsPersonalAndPending } from 'lib/shared/thread-utils'; -import type { ThreadInfo } from 'lib/types/thread-types'; -import { threadInfoPropType } from 'lib/types/thread-types'; -import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; import * as React from 'react'; import { View, Platform } from 'react-native'; import Icon from 'react-native-vector-icons/Ionicons'; +import { threadIsPersonalAndPending } from 'lib/shared/thread-utils'; +import type { ThreadInfo } from 'lib/types/thread-types'; +import { threadInfoPropType } from 'lib/types/thread-types'; +import { connect } from 'lib/utils/redux-utils'; + import Button from '../components/button.react'; import { ThreadSettingsRouteName } from '../navigation/route-names'; import type { AppState } from '../redux/redux-setup'; import { styleSelector } from '../themes/colors'; - import type { ChatNavigationProp } from './chat.react'; type Props = {| threadInfo: ThreadInfo, navigate: $PropertyType, 'navigate'>, // Redux state styles: typeof styles, |}; class MessageListHeaderTitle extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, navigate: PropTypes.func.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, }; render() { let icon, fakeIcon; const areSettingsDisabled = threadIsPersonalAndPending( this.props.threadInfo, ); if (Platform.OS === 'ios' && !areSettingsDisabled) { icon = ( ); fakeIcon = ( ); } return ( ); } onPress = () => { const threadInfo = this.props.threadInfo; this.props.navigate({ name: ThreadSettingsRouteName, params: { threadInfo }, key: `${ThreadSettingsRouteName}${threadInfo.id}`, }); }; } const styles = { button: { flex: 1, }, container: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: Platform.OS === 'android' ? 'flex-start' : 'center', }, fakeIcon: { paddingRight: 7, paddingTop: 3, flex: 1, minWidth: 25, opacity: 0, }, forwardIcon: { paddingLeft: 7, paddingTop: 3, color: 'link', flex: 1, minWidth: 25, }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ styles: stylesSelector(state), }))(MessageListHeaderTitle); diff --git a/native/chat/message-list-types.js b/native/chat/message-list-types.js index d20cd09c2..43f7cf0c6 100644 --- a/native/chat/message-list-types.js +++ b/native/chat/message-list-types.js @@ -1,54 +1,55 @@ // @flow -import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; -import { type UserInfo, userInfoPropType } from 'lib/types/user-types'; import PropTypes from 'prop-types'; import * as React from 'react'; +import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; +import { type UserInfo, userInfoPropType } from 'lib/types/user-types'; + import type { MarkdownRules } from '../markdown/rules.react'; import { useTextMessageRulesFunc } from '../markdown/rules.react'; export type MessageListParams = {| threadInfo: ThreadInfo, pendingPersonalThreadUserInfo?: UserInfo, |}; const messageListRoutePropType = PropTypes.shape({ key: PropTypes.string.isRequired, params: PropTypes.shape({ threadInfo: threadInfoPropType.isRequired, pendingPersonalThreadUserInfo: userInfoPropType, }).isRequired, }); const messageListNavPropType = PropTypes.shape({ navigate: PropTypes.func.isRequired, setParams: PropTypes.func.isRequired, setOptions: PropTypes.func.isRequired, dangerouslyGetParent: PropTypes.func.isRequired, isFocused: PropTypes.func.isRequired, popToTop: PropTypes.func.isRequired, }); export type MessageListContextType = {| +getTextMessageMarkdownRules: (useDarkStyle: boolean) => MarkdownRules, |}; const MessageListContext = React.createContext(); function useMessageListContext(threadID: string) { const getTextMessageMarkdownRules = useTextMessageRulesFunc(threadID); return React.useMemo( () => ({ getTextMessageMarkdownRules, }), [getTextMessageMarkdownRules], ); } export { messageListRoutePropType, messageListNavPropType, MessageListContext, useMessageListContext, }; diff --git a/native/chat/message-list.react.js b/native/chat/message-list.react.js index d6581b67b..eb9169b70 100644 --- a/native/chat/message-list.react.js +++ b/native/chat/message-list.react.js @@ -1,434 +1,434 @@ // @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 DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; -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 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 dbcb76854..76022dbff 100644 --- a/native/chat/message-preview.react.js +++ b/native/chat/message-preview.react.js @@ -1,99 +1,100 @@ // @flow +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 { connect } from 'lib/utils/redux-utils'; import { firstLine } from 'lib/utils/string-utils'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import { Text } from 'react-native'; 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; 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)) { const userString = stringForUser(messageInfo.creator); const username = `${userString}: `; usernameText = ( {username} ); } const firstMessageLine = firstLine(messageInfo.text); return ( {usernameText} {firstMessageLine} ); } else { 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-store-pruner.react.js b/native/chat/message-store-pruner.react.js index 26dc1f153..347a6bb75 100644 --- a/native/chat/message-store-pruner.react.js +++ b/native/chat/message-store-pruner.react.js @@ -1,67 +1,68 @@ // @flow -import { messageStorePruneActionType } from 'lib/actions/message-actions'; import * as React from 'react'; import { useDispatch } from 'react-redux'; +import { messageStorePruneActionType } from 'lib/actions/message-actions'; + import { NavContext } from '../navigation/navigation-context'; import { useSelector } from '../redux/redux-utils'; import { nextMessagePruneTimeSelector, pruneThreadIDsSelector, } from '../selectors/message-selectors'; function MessageStorePruner() { const nextMessagePruneTime = useSelector(nextMessagePruneTimeSelector); const prevNextMessagePruneTimeRef = React.useRef(nextMessagePruneTime); const foreground = useSelector((state) => state.foreground); const frozen = useSelector((state) => state.frozen); const navContext = React.useContext(NavContext); const pruneThreadIDs = useSelector((state) => pruneThreadIDsSelector({ redux: state, navContext, }), ); const prunedRef = React.useRef(false); const dispatch = useDispatch(); React.useEffect(() => { if ( prunedRef.current && nextMessagePruneTime !== prevNextMessagePruneTimeRef.current ) { prunedRef.current = false; } prevNextMessagePruneTimeRef.current = nextMessagePruneTime; if (frozen || prunedRef.current) { return; } if (nextMessagePruneTime === null || nextMessagePruneTime === undefined) { return; } const timeUntilExpiration = nextMessagePruneTime - Date.now(); if (timeUntilExpiration > 0) { return; } const threadIDs = pruneThreadIDs(); if (threadIDs.length === 0) { return; } prunedRef.current = true; dispatch({ type: messageStorePruneActionType, payload: { threadIDs }, }); // We include foreground so this effect will be called on foreground }, [nextMessagePruneTime, frozen, foreground, pruneThreadIDs, dispatch]); return null; } export default MessageStorePruner; diff --git a/native/chat/message.react.js b/native/chat/message.react.js index da26d42ea..8a0ba3407 100644 --- a/native/chat/message.react.js +++ b/native/chat/message.react.js @@ -1,182 +1,182 @@ // @flow -import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; -import { messageKey } from 'lib/shared/message-utils'; 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 { 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 d42bc0476..8fb951a22 100644 --- a/native/chat/multimedia-message-multimedia.react.js +++ b/native/chat/multimedia-message-multimedia.react.js @@ -1,342 +1,342 @@ // @flow import invariant from 'invariant'; -import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; -import { messageKey } from 'lib/shared/message-utils'; -import { type MediaInfo, mediaInfoPropType } from 'lib/types/media-types'; 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 PendingMultimediaUpload, pendingMultimediaUploadPropType, } 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 { 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 2b66724a9..40b00fffd 100644 --- a/native/chat/multimedia-message.react.js +++ b/native/chat/multimedia-message.react.js @@ -1,307 +1,307 @@ // @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 PropTypes from 'prop-types'; -import * as React from 'react'; -import { StyleSheet, View } from 'react-native'; import type { MessagePendingUploads } from '../input/input-state'; import type { NavigationRoute } from '../navigation/route-names'; import { type VerticalBounds, verticalBoundsPropType, } 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 313238faa..e454ac75d 100644 --- a/native/chat/multimedia-tooltip-button.react.js +++ b/native/chat/multimedia-tooltip-button.react.js @@ -1,139 +1,139 @@ // @flow -import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; -import { messageID } from 'lib/shared/message-utils'; -import { mediaInfoPropType } from 'lib/types/media-types'; 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 { 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/chat/multimedia-tooltip-modal.react.js b/native/chat/multimedia-tooltip-modal.react.js index ef43134ee..ec89c23b9 100644 --- a/native/chat/multimedia-tooltip-modal.react.js +++ b/native/chat/multimedia-tooltip-modal.react.js @@ -1,41 +1,40 @@ // @flow import type { MediaInfo } from 'lib/types/media-types'; import { intentionalSaveMedia } from '../media/save-media'; import { createTooltip, tooltipHeight, type TooltipParams, type TooltipRoute, } from '../navigation/tooltip.react'; - import type { ChatMultimediaMessageInfoItem } from './multimedia-message.react'; import MultimediaTooltipButton from './multimedia-tooltip-button.react'; export type MultimediaTooltipModalParams = TooltipParams<{| +item: ChatMultimediaMessageInfoItem, +mediaInfo: MediaInfo, +verticalOffset: number, |}>; function onPressSave(route: TooltipRoute<'MultimediaTooltipModal'>) { const { mediaInfo, item } = route.params; const { id: uploadID, uri } = mediaInfo; const { id: messageServerID, localID: messageLocalID } = item.messageInfo; const ids = { uploadID, messageServerID, messageLocalID }; return intentionalSaveMedia(uri, ids); } const spec = { entries: [{ id: 'save', text: 'Save', onPress: onPressSave }], }; const MultimediaTooltipModal = createTooltip<'MultimediaTooltipModal'>( MultimediaTooltipButton, spec, ); const multimediaTooltipHeight = tooltipHeight(spec.entries.length); export { MultimediaTooltipModal, multimediaTooltipHeight }; diff --git a/native/chat/relationship-prompt.react.js b/native/chat/relationship-prompt.react.js index 9bc66e5f9..b8ce5843d 100644 --- a/native/chat/relationship-prompt.react.js +++ b/native/chat/relationship-prompt.react.js @@ -1,211 +1,212 @@ // @flow import invariant from 'invariant'; +import * as React from 'react'; +import { Alert, Text, View } from 'react-native'; + import { updateRelationships as serverUpdateRelationships, updateRelationshipsActionTypes, } from 'lib/actions/relationship-actions'; import type { RelationshipAction } from 'lib/types/relationship-types'; import { relationshipActions, userRelationshipStatus, } from 'lib/types/relationship-types'; import type { ThreadInfo } from 'lib/types/thread-types'; import type { UserInfo } from 'lib/types/user-types'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils'; -import * as React from 'react'; -import { Alert, Text, View } from 'react-native'; import Button from '../components/button.react'; import { useSelector } from '../redux/redux-utils'; import { useStyles } from '../themes/colors'; type Props = {| +pendingPersonalThreadUserInfo: ?UserInfo, +threadInfo: ThreadInfo, |}; export default React.memo(function RelationshipPrompt({ pendingPersonalThreadUserInfo, threadInfo, }: Props) { // We're fetching the info from state because we need the most recent // relationship status. Additionally, member info does not contain info // about relationship. const otherUserInfo = useSelector((state) => { const currentUserID = state.currentUserInfo?.id; const otherUserID = threadInfo.members .map((member) => member.id) .find((id) => id !== currentUserID) ?? pendingPersonalThreadUserInfo?.id; const { userInfos } = state.userStore; return otherUserID && userInfos[otherUserID] ? userInfos[otherUserID] : pendingPersonalThreadUserInfo; }); const callUpdateRelationships = useServerCall(serverUpdateRelationships); const updateRelationship = React.useCallback( async (action: RelationshipAction) => { try { invariant(otherUserInfo, 'Other user info should be present'); return await callUpdateRelationships({ action, userIDs: [otherUserInfo.id], }); } catch (e) { Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }]); throw e; } }, [callUpdateRelationships, otherUserInfo], ); const dispatchActionPromise = useDispatchActionPromise(); const onButtonPress = React.useCallback( (action: RelationshipAction) => { invariant( otherUserInfo, 'User info should be present when a button is clicked', ); dispatchActionPromise( updateRelationshipsActionTypes, updateRelationship(action), ); }, [dispatchActionPromise, otherUserInfo, updateRelationship], ); const blockUser = React.useCallback( () => onButtonPress(relationshipActions.BLOCK), [onButtonPress], ); const unblockUser = React.useCallback( () => onButtonPress(relationshipActions.UNBLOCK), [onButtonPress], ); const friendUser = React.useCallback( () => onButtonPress(relationshipActions.FRIEND), [onButtonPress], ); const unfriendUser = React.useCallback( () => onButtonPress(relationshipActions.UNFRIEND), [onButtonPress], ); const styles = useStyles(unboundStyles); if ( !otherUserInfo || !otherUserInfo.username || otherUserInfo.relationshipStatus === userRelationshipStatus.FRIEND ) { return null; } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.BLOCKED_VIEWER ) { return ( ); } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.BOTH_BLOCKED || otherUserInfo.relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ) { return ( ); } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED ) { return ( ); } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.REQUEST_SENT ) { return ( ); } return ( ); }); const unboundStyles = { container: { paddingVertical: 10, paddingHorizontal: 5, backgroundColor: 'panelBackground', flexDirection: 'row', }, button: { padding: 10, borderRadius: 5, flex: 1, marginHorizontal: 5, }, greenButton: { backgroundColor: 'greenButton', }, redButton: { backgroundColor: 'redButton', }, buttonText: { fontSize: 16, textAlign: 'center', }, }; diff --git a/native/chat/robotext-message.react.js b/native/chat/robotext-message.react.js index dc133dd0c..131821605 100644 --- a/native/chat/robotext-message.react.js +++ b/native/chat/robotext-message.react.js @@ -1,208 +1,208 @@ // @flow import invariant from 'invariant'; +import * as React from 'react'; +import { Text, TouchableWithoutFeedback, View } from 'react-native'; + import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { messageKey, splitRobotext, parseRobotextEntity, robotextToRawString, } from 'lib/shared/message-utils'; import type { RobotextMessageInfo } from 'lib/types/message-types'; import type { ThreadInfo } from 'lib/types/thread-types'; -import * as React from 'react'; -import { Text, TouchableWithoutFeedback, View } from 'react-native'; import { KeyboardContext } from '../keyboard/keyboard-state'; import Markdown from '../markdown/markdown.react'; import { inlineMarkdownRules } from '../markdown/rules.react'; import { MessageListRouteName } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { useStyles } from '../themes/colors'; - import type { ChatNavigationProp } from './chat.react'; import { Timestamp } from './timestamp.react'; export type ChatRobotextMessageInfoItemWithHeight = {| itemType: 'message', messageShapeType: 'robotext', messageInfo: RobotextMessageInfo, threadInfo: ThreadInfo, startsConversation: boolean, startsCluster: boolean, endsCluster: boolean, robotext: string, contentHeight: number, |}; function robotextMessageItemHeight( item: ChatRobotextMessageInfoItemWithHeight, ) { return item.contentHeight; } function dummyNodeForRobotextMessageHeightMeasurement(robotext: string) { return ( {robotextToRawString(robotext)} ); } type Props = {| ...React.ElementConfig, +item: ChatRobotextMessageInfoItemWithHeight, +navigation: ChatNavigationProp<'MessageList'>, +focused: boolean, +toggleFocus: (messageKey: string) => void, |}; function RobotextMessage(props: Props) { const { item, navigation, focused, toggleFocus, ...viewProps } = props; const activeTheme = useSelector((state) => state.globalThemeInfo.activeTheme); const styles = useStyles(unboundStyles); let timestamp = null; if (focused || item.startsConversation) { timestamp = ( ); } const robotext = item.robotext; const robotextParts = splitRobotext(robotext); const textParts = []; let keyIndex = 0; for (let splitPart of robotextParts) { if (splitPart === '') { continue; } if (splitPart.charAt(0) !== '<') { const darkColor = activeTheme === 'dark'; const key = `text${keyIndex++}`; textParts.push( {decodeURI(splitPart)} , ); continue; } const { rawText, entityType, id } = parseRobotextEntity(splitPart); if (entityType === 't' && id !== item.messageInfo.threadID) { textParts.push( , ); } else if (entityType === 'c') { textParts.push(); } else { textParts.push(rawText); } } const viewStyle = [styles.robotextContainer]; if (!__DEV__) { // We don't force view height in dev mode because we // want to measure it in Message to see if it's correct viewStyle.push({ height: item.contentHeight }); } const keyboardState = React.useContext(KeyboardContext); const key = messageKey(item.messageInfo); const onPress = React.useCallback(() => { const didDismiss = keyboardState && keyboardState.dismissKeyboardIfShowing(); if (!didDismiss) { toggleFocus(key); } }, [keyboardState, toggleFocus, key]); return ( {timestamp} {textParts} ); } type ThreadEntityProps = {| +id: string, +name: string, +navigation: ChatNavigationProp<'MessageList'>, |}; function ThreadEntity(props: ThreadEntityProps) { const threadID = props.id; const threadInfo = useSelector( (state) => threadInfoSelector(state)[threadID], ); const styles = useStyles(unboundStyles); const { navigate } = props.navigation; const onPressThread = React.useCallback(() => { invariant(threadInfo, 'onPressThread should have threadInfo'); navigate({ name: MessageListRouteName, params: { threadInfo }, key: `${MessageListRouteName}${threadInfo.id}`, }); }, [threadInfo, navigate]); if (!threadInfo) { return {props.name}; } return ( {props.name} ); } function ColorEntity(props: {| +color: string |}) { const colorStyle = { color: props.color }; return {props.color}; } const unboundStyles = { link: { color: 'link', }, robotextContainer: { paddingTop: 6, paddingBottom: 11, paddingHorizontal: 24, }, robotext: { color: 'listForegroundSecondaryLabel', fontFamily: 'Arial', fontSize: 15, textAlign: 'center', }, dummyRobotext: { fontFamily: 'Arial', fontSize: 15, textAlign: 'center', }, }; export { robotextMessageItemHeight, dummyNodeForRobotextMessageHeightMeasurement, RobotextMessage, }; diff --git a/native/chat/settings/add-users-modal.react.js b/native/chat/settings/add-users-modal.react.js index 87fb57503..f183e80cd 100644 --- a/native/chat/settings/add-users-modal.react.js +++ b/native/chat/settings/add-users-modal.react.js @@ -1,356 +1,357 @@ // @flow import invariant from 'invariant'; +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { View, Text, ActivityIndicator, Alert } from 'react-native'; +import { createSelector } from 'reselect'; + import { changeThreadSettingsActionTypes, changeThreadSettings, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { userInfoSelectorForPotentialMembers, userSearchIndexForPotentialMembers, } from 'lib/selectors/user-selectors'; import SearchIndex from 'lib/shared/search-index'; import { getPotentialMemberItems } from 'lib/shared/search-utils'; import { threadActualMembers } from 'lib/shared/thread-utils'; import { loadingStatusPropType } from 'lib/types/loading-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import { type ThreadInfo, threadInfoPropType, type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types'; import { type AccountUserInfo, accountUserInfoPropType, } from 'lib/types/user-types'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import { View, Text, ActivityIndicator, Alert } from 'react-native'; -import { createSelector } from 'reselect'; import Button from '../../components/button.react'; import Modal from '../../components/modal.react'; import TagInput from '../../components/tag-input.react'; import UserList from '../../components/user-list.react'; import type { RootNavigationProp } from '../../navigation/root-navigator.react'; import type { NavigationRoute } from '../../navigation/route-names'; import { useSelector } from '../../redux/redux-utils'; import { useStyles } from '../../themes/colors'; const tagInputProps = { placeholder: 'Select users to add', autoFocus: true, returnKeyType: 'go', }; export type AddUsersModalParams = {| presentedFrom: string, threadInfo: ThreadInfo, |}; type BaseProps = {| +navigation: RootNavigationProp<'AddUsersModal'>, +route: NavigationRoute<'AddUsersModal'>, |}; type Props = {| ...BaseProps, // Redux state +parentThreadInfo: ?ThreadInfo, +otherUserInfos: { [id: string]: AccountUserInfo }, +userSearchIndex: SearchIndex, +changeThreadSettingsLoadingStatus: LoadingStatus, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeThreadSettings: ( request: UpdateThreadRequest, ) => Promise, |}; type State = {| +usernameInputText: string, +userInfoInputArray: $ReadOnlyArray, |}; type PropsAndState = {| ...Props, ...State |}; class AddUsersModal extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ goBackOnce: PropTypes.func.isRequired, }).isRequired, route: PropTypes.shape({ params: PropTypes.shape({ threadInfo: threadInfoPropType.isRequired, }).isRequired, }).isRequired, parentThreadInfo: threadInfoPropType, otherUserInfos: PropTypes.objectOf(accountUserInfoPropType).isRequired, userSearchIndex: PropTypes.instanceOf(SearchIndex).isRequired, changeThreadSettingsLoadingStatus: loadingStatusPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, dispatchActionPromise: PropTypes.func.isRequired, changeThreadSettings: PropTypes.func.isRequired, }; state: State = { usernameInputText: '', userInfoInputArray: [], }; tagInput: ?TagInput = null; userSearchResultsSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.usernameInputText, (propsAndState: PropsAndState) => propsAndState.otherUserInfos, (propsAndState: PropsAndState) => propsAndState.userSearchIndex, (propsAndState: PropsAndState) => propsAndState.userInfoInputArray, (propsAndState: PropsAndState) => propsAndState.route.params.threadInfo, (propsAndState: PropsAndState) => propsAndState.parentThreadInfo, ( text: string, userInfos: { [id: string]: AccountUserInfo }, searchIndex: SearchIndex, userInfoInputArray: $ReadOnlyArray, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) => { const excludeUserIDs = userInfoInputArray .map((userInfo) => userInfo.id) .concat(threadActualMembers(threadInfo.members)); return getPotentialMemberItems( text, userInfos, searchIndex, excludeUserIDs, parentThreadInfo, ); }, ); get userSearchResults() { return this.userSearchResultsSelector({ ...this.props, ...this.state }); } render() { let addButton = null; const inputLength = this.state.userInfoInputArray.length; if (inputLength > 0) { let activityIndicator = null; if (this.props.changeThreadSettingsLoadingStatus === 'loading') { activityIndicator = ( ); } const addButtonText = `Add (${inputLength})`; addButton = ( ); } let cancelButton; if (this.props.changeThreadSettingsLoadingStatus !== 'loading') { cancelButton = ( ); } else { cancelButton = ; } const inputProps = { ...tagInputProps, onSubmitEditing: this.onPressAdd, }; return ( {cancelButton} {addButton} ); } close = () => { this.props.navigation.goBackOnce(); }; tagInputRef = (tagInput: ?TagInput) => { this.tagInput = tagInput; }; onChangeTagInput = (userInfoInputArray: $ReadOnlyArray) => { if (this.props.changeThreadSettingsLoadingStatus === 'loading') { return; } this.setState({ userInfoInputArray }); }; tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; setUsernameInputText = (text: string) => { if (this.props.changeThreadSettingsLoadingStatus === 'loading') { return; } this.setState({ usernameInputText: text }); }; onUserSelect = (userID: string) => { if (this.props.changeThreadSettingsLoadingStatus === 'loading') { return; } for (let existingUserInfo of this.state.userInfoInputArray) { if (userID === existingUserInfo.id) { return; } } const userInfoInputArray = [ ...this.state.userInfoInputArray, this.props.otherUserInfos[userID], ]; this.setState({ userInfoInputArray, usernameInputText: '', }); }; onPressAdd = () => { if (this.state.userInfoInputArray.length === 0) { return; } this.props.dispatchActionPromise( changeThreadSettingsActionTypes, this.addUsersToThread(), ); }; async addUsersToThread() { try { const newMemberIDs = this.state.userInfoInputArray.map( (userInfo) => userInfo.id, ); const result = await this.props.changeThreadSettings({ threadID: this.props.route.params.threadInfo.id, changes: { newMemberIDs }, }); this.close(); return result; } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { invariant(this.tagInput, 'nameInput should be set'); this.tagInput.focus(); }; onUnknownErrorAlertAcknowledged = () => { this.setState( { userInfoInputArray: [], usernameInputText: '', }, this.onErrorAcknowledged, ); }; } const unboundStyles = { activityIndicator: { paddingRight: 6, }, addButton: { backgroundColor: 'greenButton', borderRadius: 3, flexDirection: 'row', paddingHorizontal: 10, paddingVertical: 4, }, addText: { color: 'white', fontSize: 18, }, buttons: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 12, }, cancelButton: { backgroundColor: 'modalButton', borderRadius: 3, paddingHorizontal: 10, paddingVertical: 4, }, cancelText: { color: 'modalButtonLabel', fontSize: 18, }, }; export default React.memo(function ConnectedAddUsersModal( props: BaseProps, ) { const { parentThreadID } = props.route.params.threadInfo; const parentThreadInfo = useSelector((state) => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const changeThreadSettingsLoadingStatus = useSelector( createLoadingStatusSelector(changeThreadSettingsActionTypes), ); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useServerCall(changeThreadSettings); return ( ); }); diff --git a/native/chat/settings/color-picker-modal.react.js b/native/chat/settings/color-picker-modal.react.js index 0fe90d156..407c23314 100644 --- a/native/chat/settings/color-picker-modal.react.js +++ b/native/chat/settings/color-picker-modal.react.js @@ -1,173 +1,174 @@ // @flow +import * as React from 'react'; +import { TouchableHighlight, Alert } from 'react-native'; +import Icon from 'react-native-vector-icons/FontAwesome'; +import { useSelector } from 'react-redux'; + import { changeThreadSettingsActionTypes, changeThreadSettings, } from 'lib/actions/thread-actions'; import { type ThreadInfo, type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; -import * as React from 'react'; -import { TouchableHighlight, Alert } from 'react-native'; -import Icon from 'react-native-vector-icons/FontAwesome'; -import { useSelector } from 'react-redux'; import ColorPicker from '../../components/color-picker.react'; import Modal from '../../components/modal.react'; import type { RootNavigationProp } from '../../navigation/root-navigator.react'; import type { NavigationRoute } from '../../navigation/route-names'; import { type Colors, useStyles, useColors } from '../../themes/colors'; export type ColorPickerModalParams = {| presentedFrom: string, color: string, threadInfo: ThreadInfo, setColor: (color: string) => void, |}; type BaseProps = {| +navigation: RootNavigationProp<'ColorPickerModal'>, +route: NavigationRoute<'ColorPickerModal'>, |}; type Props = {| ...BaseProps, // Redux state +colors: Colors, +styles: typeof unboundStyles, +windowWidth: number, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeThreadSettings: ( request: UpdateThreadRequest, ) => Promise, |}; class ColorPickerModal extends React.PureComponent { render() { const { color, threadInfo } = this.props.route.params; // Based on the assumption we are always in portrait, // and consequently width is the lowest dimensions const modalStyle = { height: this.props.windowWidth - 5 }; return ( ); } close = () => { this.props.navigation.goBackOnce(); }; onColorSelected = (color: string) => { const colorEditValue = color.substr(1); this.props.route.params.setColor(colorEditValue); this.close(); this.props.dispatchActionPromise( changeThreadSettingsActionTypes, this.editColor(colorEditValue), { customKeyName: `${changeThreadSettingsActionTypes.started}:color` }, ); }; async editColor(newColor: string) { const threadID = this.props.route.params.threadInfo.id; try { return await this.props.changeThreadSettings({ threadID, changes: { color: newColor }, }); } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onErrorAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { const { threadInfo, setColor } = this.props.route.params; setColor(threadInfo.color); }; } const unboundStyles = { closeButton: { borderRadius: 3, height: 18, position: 'absolute', right: 5, top: 5, width: 18, }, closeButtonIcon: { color: 'modalBackgroundSecondaryLabel', left: 3, position: 'absolute', }, colorPicker: { bottom: 10, left: 10, position: 'absolute', right: 10, top: 10, }, colorPickerContainer: { backgroundColor: 'modalBackground', borderRadius: 5, flex: 0, marginHorizontal: 15, marginVertical: 20, }, }; export default React.memo(function ConnectedColorPickerModal( props: BaseProps, ) { const styles = useStyles(unboundStyles); const colors = useColors(); const windowWidth = useSelector((state) => state.dimensions.width); const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useServerCall(changeThreadSettings); return ( ); }); diff --git a/native/chat/settings/compose-subthread-modal.react.js b/native/chat/settings/compose-subthread-modal.react.js index 7019848ad..92728c90f 100644 --- a/native/chat/settings/compose-subthread-modal.react.js +++ b/native/chat/settings/compose-subthread-modal.react.js @@ -1,165 +1,166 @@ // @flow +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { Text } from 'react-native'; +import IonIcon from 'react-native-vector-icons/Ionicons'; +import Icon from 'react-native-vector-icons/MaterialIcons'; + import { threadTypeDescriptions } from 'lib/shared/thread-utils'; import { type ThreadInfo, threadInfoPropType, threadTypes, } from 'lib/types/thread-types'; import { connect } from 'lib/utils/redux-utils'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import { Text } from 'react-native'; -import IonIcon from 'react-native-vector-icons/Ionicons'; -import Icon from 'react-native-vector-icons/MaterialIcons'; import Button from '../../components/button.react'; import Modal from '../../components/modal.react'; import type { RootNavigationProp } from '../../navigation/root-navigator.react'; import type { NavigationRoute } from '../../navigation/route-names'; import { ComposeThreadRouteName } from '../../navigation/route-names'; import type { AppState } from '../../redux/redux-setup'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../../themes/colors'; export type ComposeSubthreadModalParams = {| presentedFrom: string, threadInfo: ThreadInfo, |}; type Props = {| navigation: RootNavigationProp<'ComposeSubthreadModal'>, route: NavigationRoute<'ComposeSubthreadModal'>, // Redux state colors: Colors, styles: typeof styles, |}; class ComposeSubthreadModal extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ navigate: PropTypes.func.isRequired, }).isRequired, route: PropTypes.shape({ params: PropTypes.shape({ presentedFrom: PropTypes.string.isRequired, threadInfo: threadInfoPropType.isRequired, }).isRequired, }).isRequired, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, }; render() { return ( Thread type ); } onPressOpen = () => { const threadInfo = this.props.route.params.threadInfo; this.props.navigation.navigate({ name: ComposeThreadRouteName, params: { threadType: threadTypes.CHAT_NESTED_OPEN, parentThreadInfo: threadInfo, }, key: `${ComposeThreadRouteName}|${threadInfo.id}|${threadTypes.CHAT_NESTED_OPEN}`, }); }; onPressSecret = () => { const threadInfo = this.props.route.params.threadInfo; this.props.navigation.navigate({ name: ComposeThreadRouteName, params: { threadType: threadTypes.CHAT_SECRET, parentThreadInfo: threadInfo, }, key: `${ComposeThreadRouteName}|${threadInfo.id}|${threadTypes.CHAT_SECRET}`, }); }; } const styles = { forwardIcon: { color: 'link', paddingLeft: 10, }, modal: { flex: 0, }, option: { alignItems: 'center', flexDirection: 'row', paddingHorizontal: 10, paddingVertical: 20, }, optionExplanation: { color: 'modalBackgroundLabel', flex: 1, fontSize: 14, paddingLeft: 20, textAlign: 'center', }, optionText: { color: 'modalBackgroundLabel', fontSize: 20, paddingLeft: 5, }, visibility: { color: 'modalBackgroundLabel', fontSize: 24, textAlign: 'center', }, visibilityIcon: { color: 'modalBackgroundLabel', paddingRight: 3, }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ colors: colorsSelector(state), styles: stylesSelector(state), }))(ComposeSubthreadModal); diff --git a/native/chat/settings/delete-thread.react.js b/native/chat/settings/delete-thread.react.js index 6ed61403e..a5707ecb1 100644 --- a/native/chat/settings/delete-thread.react.js +++ b/native/chat/settings/delete-thread.react.js @@ -1,312 +1,313 @@ // @flow import invariant from 'invariant'; +import * as React from 'react'; +import { + Text, + View, + TextInput, + ScrollView, + Alert, + ActivityIndicator, +} from 'react-native'; + import { deleteThreadActionTypes, deleteThread, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { identifyInvalidatedThreads } from 'lib/shared/thread-utils'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { ThreadInfo, LeaveThreadPayload } from 'lib/types/thread-types'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; -import * as React from 'react'; -import { - Text, - View, - TextInput, - ScrollView, - Alert, - ActivityIndicator, -} from 'react-native'; import Button from '../../components/button.react'; import { clearThreadsActionType } from '../../navigation/action-types'; import { NavContext, type NavAction, } from '../../navigation/navigation-context'; import type { NavigationRoute } from '../../navigation/route-names'; import { useSelector } from '../../redux/redux-utils'; import { type Colors, useColors, useStyles } from '../../themes/colors'; import type { GlobalTheme } from '../../types/themes'; import type { ChatNavigationProp } from '../chat.react'; export type DeleteThreadParams = {| +threadInfo: ThreadInfo, |}; type BaseProps = {| +navigation: ChatNavigationProp<'DeleteThread'>, +route: NavigationRoute<'DeleteThread'>, |}; type Props = {| ...BaseProps, // Redux state +threadInfo: ?ThreadInfo, +loadingStatus: LoadingStatus, +activeTheme: ?GlobalTheme, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +deleteThread: ( threadID: string, currentAccountPassword: string, ) => Promise, // withNavContext +navDispatch: (action: NavAction) => void, |}; type State = {| +password: string, |}; class DeleteThread extends React.PureComponent { state: State = { password: '', }; mounted = false; passwordInput: ?React.ElementRef; static getThreadInfo(props: Props): ThreadInfo { const { threadInfo } = props; if (threadInfo) { return threadInfo; } return props.route.params.threadInfo; } componentDidMount() { this.mounted = true; } componentWillUnmount() { this.mounted = false; } guardedSetState(change, callback) { if (this.mounted) { this.setState(change, callback); } } componentDidUpdate(prevProps: Props) { const oldReduxThreadInfo = prevProps.threadInfo; const newReduxThreadInfo = this.props.threadInfo; if (newReduxThreadInfo && newReduxThreadInfo !== oldReduxThreadInfo) { this.props.navigation.setParams({ threadInfo: newReduxThreadInfo }); } } render() { const buttonContent = this.props.loadingStatus === 'loading' ? ( ) : ( Delete thread ); const threadInfo = DeleteThread.getThreadInfo(this.props); const { panelForegroundTertiaryLabel } = this.props.colors; return ( {`The thread "${threadInfo.uiName}" will be permanently deleted. `} There is no way to reverse this. PASSWORD ); } onChangePasswordText = (newPassword: string) => { this.guardedSetState({ password: newPassword }); }; passwordInputRef = (passwordInput: ?React.ElementRef) => { this.passwordInput = passwordInput; }; focusPasswordInput = () => { invariant(this.passwordInput, 'passwordInput should be set'); this.passwordInput.focus(); }; submitDeletion = () => { this.props.dispatchActionPromise( deleteThreadActionTypes, this.deleteThread(), ); }; async deleteThread() { const threadInfo = DeleteThread.getThreadInfo(this.props); const { navDispatch } = this.props; navDispatch({ type: clearThreadsActionType, payload: { threadIDs: [threadInfo.id] }, }); try { const result = await this.props.deleteThread( threadInfo.id, this.state.password, ); const invalidated = identifyInvalidatedThreads( result.updatesResult.newUpdates, ); navDispatch({ type: clearThreadsActionType, payload: { threadIDs: [...invalidated] }, }); return result; } catch (e) { if ( e.message === 'invalid_credentials' || e.message === 'invalid_parameters' ) { Alert.alert( 'Incorrect password', 'The password you entered is incorrect', [{ text: 'OK', onPress: this.onErrorAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onErrorAlertAcknowledged }], { cancelable: false }, ); } } } onErrorAlertAcknowledged = () => { this.guardedSetState({ password: '' }, this.focusPasswordInput); }; } const unboundStyles = { deleteButton: { backgroundColor: 'redButton', borderRadius: 5, flex: 1, marginHorizontal: 24, marginVertical: 12, padding: 12, }, deleteText: { color: 'white', fontSize: 18, textAlign: 'center', }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, input: { color: 'panelForegroundLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, paddingVertical: 0, borderBottomColor: 'transparent', }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, flexDirection: 'row', justifyContent: 'space-between', marginBottom: 24, paddingHorizontal: 24, paddingVertical: 12, }, warningText: { color: 'panelForegroundLabel', fontSize: 16, marginBottom: 24, marginHorizontal: 24, textAlign: 'center', }, }; const loadingStatusSelector = createLoadingStatusSelector( deleteThreadActionTypes, ); export default React.memo(function ConnectedDeleteThread( props: BaseProps, ) { const threadID = props.route.params.threadInfo.id; const threadInfo = useSelector( (state) => threadInfoSelector(state)[threadID], ); const loadingStatus = useSelector(loadingStatusSelector); const activeTheme = useSelector((state) => state.globalThemeInfo.activeTheme); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callDeleteThread = useServerCall(deleteThread); const navContext = React.useContext(NavContext); invariant(navContext, 'NavContext should be set in DeleteThread'); const navDispatch = navContext.dispatch; return ( ); }); diff --git a/native/chat/settings/save-setting-button.react.js b/native/chat/settings/save-setting-button.react.js index efd8ad0bf..d066adc90 100644 --- a/native/chat/settings/save-setting-button.react.js +++ b/native/chat/settings/save-setting-button.react.js @@ -1,43 +1,44 @@ // @flow -import { connect } from 'lib/utils/redux-utils'; import * as React from 'react'; import { TouchableOpacity } from 'react-native'; import Icon from 'react-native-vector-icons/Ionicons'; +import { connect } from 'lib/utils/redux-utils'; + import type { AppState } from '../../redux/redux-setup'; import { styleSelector } from '../../themes/colors'; type Props = {| onPress: () => void, // Redux state styles: typeof styles, |}; function SaveSettingButton(props: Props) { return ( ); } const styles = { container: { width: 26, }, editIcon: { color: 'greenButton', position: 'absolute', right: 0, top: -3, }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ styles: stylesSelector(state), }))(SaveSettingButton); diff --git a/native/chat/settings/thread-settings-category.react.js b/native/chat/settings/thread-settings-category.react.js index f54dd0d32..cd34e73ef 100644 --- a/native/chat/settings/thread-settings-category.react.js +++ b/native/chat/settings/thread-settings-category.react.js @@ -1,114 +1,115 @@ // @flow import invariant from 'invariant'; -import { connect } from 'lib/utils/redux-utils'; import * as React from 'react'; import { View, Text, Platform } from 'react-native'; +import { connect } from 'lib/utils/redux-utils'; + import type { AppState } from '../../redux/redux-setup'; import { styleSelector } from '../../themes/colors'; export type CategoryType = 'full' | 'outline' | 'unpadded'; type HeaderProps = {| type: CategoryType, title: string, // Redux state styles: typeof styles, |}; function ThreadSettingsCategoryHeader(props: HeaderProps) { let contentStyle, paddingStyle; if (props.type === 'full') { contentStyle = props.styles.fullHeader; paddingStyle = props.styles.fullHeaderPadding; } else if (props.type === 'outline') { // nothing } else if (props.type === 'unpadded') { contentStyle = props.styles.fullHeader; } else { invariant(false, 'invalid ThreadSettingsCategory type'); } return ( {props.title.toUpperCase()} ); } type FooterProps = {| type: CategoryType, // Redux state styles: typeof styles, |}; function ThreadSettingsCategoryFooter(props: FooterProps) { let contentStyle, paddingStyle; if (props.type === 'full') { contentStyle = props.styles.fullFooter; paddingStyle = props.styles.fullFooterPadding; } else if (props.type === 'outline') { // nothing } else if (props.type === 'unpadded') { contentStyle = props.styles.fullFooter; } else { invariant(false, 'invalid ThreadSettingsCategory type'); } return ( ); } const paddingHeight = Platform.select({ android: 6.5, default: 6, }); const styles = { footer: { marginBottom: 16, }, fullFooter: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, fullFooterPadding: { backgroundColor: 'panelForeground', height: paddingHeight, }, fullHeader: { borderBottomWidth: 1, borderColor: 'panelForegroundBorder', }, fullHeaderPadding: { backgroundColor: 'panelForeground', height: paddingHeight, margin: 0, }, header: { marginTop: 16, }, title: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingLeft: 24, }, }; const stylesSelector = styleSelector(styles); const WrappedThreadSettingsCategoryHeader = connect((state: AppState) => ({ styles: stylesSelector(state), }))(ThreadSettingsCategoryHeader); const WrappedThreadSettingsCategoryFooter = connect((state: AppState) => ({ styles: stylesSelector(state), }))(ThreadSettingsCategoryFooter); export { WrappedThreadSettingsCategoryHeader as ThreadSettingsCategoryHeader, WrappedThreadSettingsCategoryFooter as ThreadSettingsCategoryFooter, }; diff --git a/native/chat/settings/thread-settings-child-thread.react.js b/native/chat/settings/thread-settings-child-thread.react.js index dd731fe14..143c32c00 100644 --- a/native/chat/settings/thread-settings-child-thread.react.js +++ b/native/chat/settings/thread-settings-child-thread.react.js @@ -1,88 +1,88 @@ // @flow -import type { ThreadInfo } from 'lib/types/thread-types'; import * as React from 'react'; import { View, Platform } from 'react-native'; +import type { ThreadInfo } from 'lib/types/thread-types'; + import Button from '../../components/button.react'; import ColorSplotch from '../../components/color-splotch.react'; import { SingleLine } from '../../components/single-line.react'; import ThreadIcon from '../../components/thread-icon.react'; import { MessageListRouteName } from '../../navigation/route-names'; import { useColors, useStyles } from '../../themes/colors'; - import type { ThreadSettingsNavigate } from './thread-settings.react'; type Props = {| +threadInfo: ThreadInfo, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, |}; function ThreadSettingsChildThread(props: Props) { const { navigate, threadInfo } = props; const onPress = React.useCallback(() => { navigate({ name: MessageListRouteName, params: { threadInfo }, key: `${MessageListRouteName}${threadInfo.id}`, }); }, [navigate, threadInfo]); const styles = useStyles(unboundStyles); const colors = useColors(); const firstItem = props.firstListItem ? null : styles.topBorder; const lastItem = props.lastListItem ? styles.lastButton : null; return ( ); } const unboundStyles = { button: { flex: 1, flexDirection: 'row', paddingVertical: 8, paddingLeft: 12, paddingRight: 10, alignItems: 'center', }, topBorder: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, container: { backgroundColor: 'panelForeground', flex: 1, paddingHorizontal: 12, }, lastButton: { paddingBottom: Platform.OS === 'ios' ? 12 : 10, paddingTop: 8, }, leftSide: { flex: 1, flexDirection: 'row', alignItems: 'center', }, text: { flex: 1, color: 'link', fontSize: 16, paddingLeft: 8, }, }; export default ThreadSettingsChildThread; diff --git a/native/chat/settings/thread-settings-color.react.js b/native/chat/settings/thread-settings-color.react.js index fe9b2d1df..fa0cbfe05 100644 --- a/native/chat/settings/thread-settings-color.react.js +++ b/native/chat/settings/thread-settings-color.react.js @@ -1,140 +1,140 @@ // @flow +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { Text, ActivityIndicator, View, Platform } from 'react-native'; + import { changeThreadSettingsActionTypes } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { loadingStatusPropType } from 'lib/types/loading-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import { Text, ActivityIndicator, View, Platform } from 'react-native'; import ColorSplotch from '../../components/color-splotch.react'; import EditSettingButton from '../../components/edit-setting-button.react'; import { ColorPickerModalRouteName } from '../../navigation/route-names'; import { useSelector } from '../../redux/redux-utils'; import { type Colors, colorsPropType, useColors, useStyles, } from '../../themes/colors'; - import type { ThreadSettingsNavigate } from './thread-settings.react'; type BaseProps = {| +threadInfo: ThreadInfo, +colorEditValue: string, +setColorEditValue: (color: string) => void, +canChangeSettings: boolean, +navigate: ThreadSettingsNavigate, +threadSettingsRouteKey: string, |}; type Props = {| ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, |}; class ThreadSettingsColor extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, colorEditValue: PropTypes.string.isRequired, setColorEditValue: PropTypes.func.isRequired, canChangeSettings: PropTypes.bool.isRequired, navigate: PropTypes.func.isRequired, threadSettingsRouteKey: PropTypes.string.isRequired, loadingStatus: loadingStatusPropType.isRequired, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, }; render() { let colorButton; if (this.props.loadingStatus !== 'loading') { colorButton = ( ); } else { colorButton = ( ); } return ( Color {colorButton} ); } onPressEditColor = () => { this.props.navigate({ name: ColorPickerModalRouteName, params: { presentedFrom: this.props.threadSettingsRouteKey, color: this.props.colorEditValue, threadInfo: this.props.threadInfo, setColor: this.props.setColorEditValue, }, }); }; } const unboundStyles = { colorLine: { lineHeight: Platform.select({ android: 22, default: 25 }), }, colorRow: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingBottom: 8, paddingHorizontal: 24, paddingTop: 4, }, currentValue: { flex: 1, paddingLeft: 4, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, }; const loadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:color`, ); export default React.memo(function ConnectedThreadSettingsColor( props: BaseProps, ) { const loadingStatus = useSelector(loadingStatusSelector); const colors = useColors(); const styles = useStyles(unboundStyles); return ( ); }); diff --git a/native/chat/settings/thread-settings-delete-thread.react.js b/native/chat/settings/thread-settings-delete-thread.react.js index b948a6c7c..952824e7b 100644 --- a/native/chat/settings/thread-settings-delete-thread.react.js +++ b/native/chat/settings/thread-settings-delete-thread.react.js @@ -1,64 +1,64 @@ // @flow -import type { ThreadInfo } from 'lib/types/thread-types'; import * as React from 'react'; import { Text, View } from 'react-native'; +import type { ThreadInfo } from 'lib/types/thread-types'; + import Button from '../../components/button.react'; import { DeleteThreadRouteName } from '../../navigation/route-names'; import { useColors, useStyles } from '../../themes/colors'; import type { ViewStyle } from '../../types/styles'; - import type { ThreadSettingsNavigate } from './thread-settings.react'; type Props = {| +threadInfo: ThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, |}; function ThreadSettingsDeleteThread(props: Props) { const { navigate, threadInfo } = props; const onPress = React.useCallback(() => { navigate({ name: DeleteThreadRouteName, params: { threadInfo }, key: `${DeleteThreadRouteName}${threadInfo.id}`, }); }, [navigate, threadInfo]); const colors = useColors(); const { panelIosHighlightUnderlay } = colors; const styles = useStyles(unboundStyles); return ( ); } const unboundStyles = { button: { flexDirection: 'row', paddingHorizontal: 12, paddingVertical: 10, }, container: { backgroundColor: 'panelForeground', paddingHorizontal: 12, }, text: { color: 'redText', flex: 1, fontSize: 16, }, }; export default ThreadSettingsDeleteThread; diff --git a/native/chat/settings/thread-settings-description.react.js b/native/chat/settings/thread-settings-description.react.js index 78c29c4d3..8a1f72aa8 100644 --- a/native/chat/settings/thread-settings-description.react.js +++ b/native/chat/settings/thread-settings-description.react.js @@ -1,300 +1,300 @@ // @flow import invariant from 'invariant'; +import * as React from 'react'; +import { Text, Alert, ActivityIndicator, TextInput, View } from 'react-native'; +import Icon from 'react-native-vector-icons/FontAwesome'; + import { changeThreadSettingsActionTypes, changeThreadSettings, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { threadHasPermission } from 'lib/shared/thread-utils'; import type { LoadingStatus } from 'lib/types/loading-types'; import { type ThreadInfo, threadPermissions, type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; -import * as React from 'react'; -import { Text, Alert, ActivityIndicator, TextInput, View } from 'react-native'; -import Icon from 'react-native-vector-icons/FontAwesome'; import Button from '../../components/button.react'; import EditSettingButton from '../../components/edit-setting-button.react'; import { useSelector } from '../../redux/redux-utils'; import { type Colors, useStyles, useColors } from '../../themes/colors'; import type { LayoutEvent, ContentSizeChangeEvent, } from '../../types/react-native'; - import SaveSettingButton from './save-setting-button.react'; import { ThreadSettingsCategoryHeader, ThreadSettingsCategoryFooter, } from './thread-settings-category.react'; type BaseProps = {| +threadInfo: ThreadInfo, +descriptionEditValue: ?string, +setDescriptionEditValue: (value: ?string, callback?: () => void) => void, +descriptionTextHeight: ?number, +setDescriptionTextHeight: (number: number) => void, +canChangeSettings: boolean, |}; type Props = {| ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeThreadSettings: ( update: UpdateThreadRequest, ) => Promise, |}; class ThreadSettingsDescription extends React.PureComponent { textInput: ?React.ElementRef; render() { if ( this.props.descriptionEditValue !== null && this.props.descriptionEditValue !== undefined ) { let button; if (this.props.loadingStatus !== 'loading') { button = ; } else { button = ( ); } const textInputStyle = {}; if ( this.props.descriptionTextHeight !== undefined && this.props.descriptionTextHeight !== null ) { textInputStyle.height = this.props.descriptionTextHeight; } return ( {button} ); } if (this.props.threadInfo.description) { return ( {this.props.threadInfo.description} ); } const canEditThread = threadHasPermission( this.props.threadInfo, threadPermissions.EDIT_THREAD, ); const { panelIosHighlightUnderlay } = this.props.colors; if (canEditThread) { return ( ); } return null; } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; onLayoutText = (event: LayoutEvent) => { this.props.setDescriptionTextHeight(event.nativeEvent.layout.height); }; onTextInputContentSizeChange = (event: ContentSizeChangeEvent) => { this.props.setDescriptionTextHeight(event.nativeEvent.contentSize.height); }; onPressEdit = () => { this.props.setDescriptionEditValue(this.props.threadInfo.description); }; onSubmit = () => { invariant( this.props.descriptionEditValue !== null && this.props.descriptionEditValue !== undefined, 'should be set', ); const description = this.props.descriptionEditValue.trim(); if (description === this.props.threadInfo.description) { this.props.setDescriptionEditValue(null); return; } const editDescriptionPromise = this.editDescription(description); this.props.dispatchActionPromise( changeThreadSettingsActionTypes, editDescriptionPromise, { customKeyName: `${changeThreadSettingsActionTypes.started}:description`, }, ); editDescriptionPromise.then(() => { this.props.setDescriptionEditValue(null); }); }; async editDescription(newDescription: string) { try { return await this.props.changeThreadSettings({ threadID: this.props.threadInfo.id, changes: { description: newDescription }, }); } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onErrorAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { this.props.setDescriptionEditValue( this.props.threadInfo.description, () => { invariant(this.textInput, 'textInput should be set'); this.textInput.focus(); }, ); }; } const unboundStyles = { addDescriptionButton: { flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 10, }, addDescriptionText: { color: 'panelForegroundTertiaryLabel', flex: 1, fontSize: 16, }, editIcon: { color: 'panelForegroundTertiaryLabel', paddingLeft: 10, textAlign: 'right', }, outlineCategory: { backgroundColor: 'panelSecondaryForeground', borderColor: 'panelSecondaryForegroundBorder', borderRadius: 1, borderStyle: 'dashed', borderWidth: 1, marginLeft: -1, marginRight: -1, }, row: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 4, }, text: { color: 'panelForegroundSecondaryLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, margin: 0, padding: 0, borderBottomColor: 'transparent', }, }; const loadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:description`, ); export default React.memo( function ConnectedThreadSettingsDescription(props: BaseProps) { const loadingStatus = useSelector(loadingStatusSelector); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useServerCall(changeThreadSettings); return ( ); }, ); diff --git a/native/chat/settings/thread-settings-home-notifs.react.js b/native/chat/settings/thread-settings-home-notifs.react.js index 052ca33d8..b87a70748 100644 --- a/native/chat/settings/thread-settings-home-notifs.react.js +++ b/native/chat/settings/thread-settings-home-notifs.react.js @@ -1,120 +1,121 @@ // @flow +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { Text, View, Switch } from 'react-native'; + import { updateSubscriptionActionTypes, updateSubscription, } from 'lib/actions/user-actions'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResult, } from 'lib/types/subscription-types'; import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import { Text, View, Switch } from 'react-native'; import { useStyles } from '../../themes/colors'; type BaseProps = {| +threadInfo: ThreadInfo, |}; type Props = {| ...BaseProps, // Redux state +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +updateSubscription: ( subscriptionUpdate: SubscriptionUpdateRequest, ) => Promise, |}; type State = {| +currentValue: boolean, |}; class ThreadSettingsHomeNotifs extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, dispatchActionPromise: PropTypes.func.isRequired, updateSubscription: PropTypes.func.isRequired, }; constructor(props: Props) { super(props); this.state = { currentValue: !props.threadInfo.currentUser.subscription.home, }; } render() { return ( Background ); } onValueChange = (value: boolean) => { this.setState({ currentValue: value }); this.props.dispatchActionPromise( updateSubscriptionActionTypes, this.props.updateSubscription({ threadID: this.props.threadInfo.id, updatedFields: { home: !value, }, }), ); }; } const unboundStyles = { currentValue: { alignItems: 'flex-end', flex: 1, margin: 0, paddingLeft: 4, paddingRight: 0, paddingVertical: 0, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, row: { alignItems: 'center', backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 3, }, }; export default React.memo(function ConnectedThreadSettingsHomeNotifs( props: BaseProps, ) { const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateSubscription = useServerCall(updateSubscription); return ( ); }); diff --git a/native/chat/settings/thread-settings-leave-thread.react.js b/native/chat/settings/thread-settings-leave-thread.react.js index 820ab5529..ea670d083 100644 --- a/native/chat/settings/thread-settings-leave-thread.react.js +++ b/native/chat/settings/thread-settings-leave-thread.react.js @@ -1,175 +1,176 @@ // @flow import invariant from 'invariant'; +import * as React from 'react'; +import { Text, Alert, ActivityIndicator, View } from 'react-native'; + import { leaveThreadActionTypes, leaveThread, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { otherUsersButNoOtherAdmins } from 'lib/selectors/thread-selectors'; import { identifyInvalidatedThreads } from 'lib/shared/thread-utils'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { ThreadInfo, LeaveThreadPayload } from 'lib/types/thread-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; -import * as React from 'react'; -import { Text, Alert, ActivityIndicator, View } from 'react-native'; import Button from '../../components/button.react'; import { clearThreadsActionType } from '../../navigation/action-types'; import { NavContext, type NavContextType, } from '../../navigation/navigation-context'; import { useSelector } from '../../redux/redux-utils'; import { type Colors, useColors, useStyles } from '../../themes/colors'; import type { ViewStyle } from '../../types/styles'; type BaseProps = {| +threadInfo: ThreadInfo, +buttonStyle: ViewStyle, |}; type Props = {| ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +otherUsersButNoOtherAdmins: boolean, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +leaveThread: (threadID: string) => Promise, // withNavContext +navContext: ?NavContextType, |}; class ThreadSettingsLeaveThread extends React.PureComponent { render() { const { panelIosHighlightUnderlay, panelForegroundSecondaryLabel, } = this.props.colors; const loadingIndicator = this.props.loadingStatus === 'loading' ? ( ) : null; return ( ); } onPress = () => { if (this.props.otherUsersButNoOtherAdmins) { Alert.alert( 'Need another admin', 'Make somebody else an admin before you leave!', undefined, { cancelable: true }, ); return; } Alert.alert( 'Confirm action', 'Are you sure you want to leave this thread?', [ { text: 'Cancel', style: 'cancel' }, { text: 'OK', onPress: this.onConfirmLeaveThread }, ], { cancelable: true }, ); }; onConfirmLeaveThread = () => { this.props.dispatchActionPromise( leaveThreadActionTypes, this.leaveThread(), ); }; async leaveThread() { const threadID = this.props.threadInfo.id; const { navContext } = this.props; invariant(navContext, 'navContext should exist in leaveThread'); navContext.dispatch({ type: clearThreadsActionType, payload: { threadIDs: [threadID] }, }); try { const result = await this.props.leaveThread(threadID); const invalidated = identifyInvalidatedThreads( result.updatesResult.newUpdates, ); navContext.dispatch({ type: clearThreadsActionType, payload: { threadIDs: [...invalidated] }, }); return result; } catch (e) { Alert.alert('Unknown error', 'Uhh... try again?', undefined, { cancelable: true, }); throw e; } } } const unboundStyles = { button: { flexDirection: 'row', paddingHorizontal: 12, paddingVertical: 10, }, container: { backgroundColor: 'panelForeground', paddingHorizontal: 12, }, text: { color: 'redText', flex: 1, fontSize: 16, }, }; const loadingStatusSelector = createLoadingStatusSelector( leaveThreadActionTypes, ); export default React.memo( function ConnectedThreadSettingsLeaveThread(props: BaseProps) { const loadingStatus = useSelector(loadingStatusSelector); const otherUsersButNoOtherAdminsValue = useSelector( otherUsersButNoOtherAdmins(props.threadInfo.id), ); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callLeaveThread = useServerCall(leaveThread); const navContext = React.useContext(NavContext); return ( ); }, ); diff --git a/native/chat/settings/thread-settings-list-action.react.js b/native/chat/settings/thread-settings-list-action.react.js index 731ace903..00ee03857 100644 --- a/native/chat/settings/thread-settings-list-action.react.js +++ b/native/chat/settings/thread-settings-list-action.react.js @@ -1,170 +1,171 @@ // @flow -import { connect } from 'lib/utils/redux-utils'; import * as React from 'react'; import { View, Text, Platform } from 'react-native'; import type { IoniconsGlyphs } from 'react-native-vector-icons/Ionicons'; import Icon from 'react-native-vector-icons/Ionicons'; +import { connect } from 'lib/utils/redux-utils'; + import Button from '../../components/button.react'; import type { AppState } from '../../redux/redux-setup'; import { styleSelector } from '../../themes/colors'; import type { ViewStyle, TextStyle } from '../../types/styles'; type ListActionProps = {| onPress: () => void, text: string, iconName: IoniconsGlyphs, iconSize: number, iconStyle?: TextStyle, buttonStyle?: ViewStyle, styles: typeof styles, |}; function ThreadSettingsListAction(props: ListActionProps) { return ( ); } type SeeMoreProps = {| onPress: () => void, // Redux state styles: typeof styles, |}; function ThreadSettingsSeeMore(props: SeeMoreProps) { return ( ); } type AddMemberProps = {| onPress: () => void, // Redux state styles: typeof styles, |}; function ThreadSettingsAddMember(props: AddMemberProps) { return ( ); } type AddChildThreadProps = {| onPress: () => void, // Redux state styles: typeof styles, |}; function ThreadSettingsAddSubthread(props: AddChildThreadProps) { return ( ); } const styles = { addSubthreadButton: { paddingTop: Platform.OS === 'ios' ? 4 : 1, }, addIcon: { color: '#009900', }, addItemRow: { backgroundColor: 'panelForeground', paddingHorizontal: 12, }, addMemberButton: { paddingTop: Platform.OS === 'ios' ? 4 : 1, }, container: { flex: 1, flexDirection: 'row', paddingHorizontal: 12, paddingVertical: 8, justifyContent: 'center', }, icon: { lineHeight: 20, }, seeMoreButton: { paddingBottom: Platform.OS === 'ios' ? 4 : 2, paddingTop: Platform.OS === 'ios' ? 2 : 0, }, seeMoreContents: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, seeMoreIcon: { color: 'link', position: 'absolute', right: 10, top: Platform.OS === 'android' ? 17 : 15, }, seeMoreRow: { backgroundColor: 'panelForeground', paddingHorizontal: 12, }, text: { color: 'link', flex: 1, fontSize: 16, fontStyle: 'italic', }, }; const stylesSelector = styleSelector(styles); const WrappedThreadSettingsSeeMore = connect((state: AppState) => ({ styles: stylesSelector(state), }))(ThreadSettingsSeeMore); const WrappedThreadSettingsAddMember = connect((state: AppState) => ({ styles: stylesSelector(state), }))(ThreadSettingsAddMember); const WrappedThreadSettingsAddSubthread = connect((state: AppState) => ({ styles: stylesSelector(state), }))(ThreadSettingsAddSubthread); export { WrappedThreadSettingsSeeMore as ThreadSettingsSeeMore, WrappedThreadSettingsAddMember as ThreadSettingsAddMember, WrappedThreadSettingsAddSubthread as ThreadSettingsAddSubthread, }; diff --git a/native/chat/settings/thread-settings-member-tooltip-modal.react.js b/native/chat/settings/thread-settings-member-tooltip-modal.react.js index 784682d29..faf0ef916 100644 --- a/native/chat/settings/thread-settings-member-tooltip-modal.react.js +++ b/native/chat/settings/thread-settings-member-tooltip-modal.react.js @@ -1,118 +1,118 @@ // @flow import invariant from 'invariant'; +import { Alert } from 'react-native'; + import { removeUsersFromThreadActionTypes, removeUsersFromThread, changeThreadMemberRolesActionTypes, changeThreadMemberRoles, } from 'lib/actions/thread-actions'; import { memberIsAdmin, roleIsAdminRole } from 'lib/shared/thread-utils'; import { stringForUser } from 'lib/shared/user-utils'; import type { ThreadInfo, RelativeMemberInfo } from 'lib/types/thread-types'; import type { DispatchFunctions, ActionFunc, BoundServerCall, } from 'lib/utils/action-utils'; -import { Alert } from 'react-native'; import { createTooltip, type TooltipParams, type TooltipRoute, } from '../../navigation/tooltip.react'; - import ThreadSettingsMemberTooltipButton from './thread-settings-member-tooltip-button.react'; export type ThreadSettingsMemberTooltipModalParams = TooltipParams<{| +memberInfo: RelativeMemberInfo, +threadInfo: ThreadInfo, |}>; function onRemoveUser( route: TooltipRoute<'ThreadSettingsMemberTooltipModal'>, dispatchFunctions: DispatchFunctions, bindServerCall: (serverCall: ActionFunc) => BoundServerCall, ) { const { memberInfo, threadInfo } = route.params; const boundRemoveUsersFromThread = bindServerCall(removeUsersFromThread); const onConfirmRemoveUser = () => { const customKeyName = `${removeUsersFromThreadActionTypes.started}:${memberInfo.id}`; dispatchFunctions.dispatchActionPromise( removeUsersFromThreadActionTypes, boundRemoveUsersFromThread(threadInfo.id, [memberInfo.id]), { customKeyName }, ); }; const userText = stringForUser(memberInfo); Alert.alert( 'Confirm removal', `Are you sure you want to remove ${userText} from this thread?`, [ { text: 'Cancel', style: 'cancel' }, { text: 'OK', onPress: onConfirmRemoveUser }, ], { cancelable: true }, ); } function onToggleAdmin( route: TooltipRoute<'ThreadSettingsMemberTooltipModal'>, dispatchFunctions: DispatchFunctions, bindServerCall: (serverCall: ActionFunc) => BoundServerCall, ) { const { memberInfo, threadInfo } = route.params; const isCurrentlyAdmin = memberIsAdmin(memberInfo, threadInfo); const boundChangeThreadMemberRoles = bindServerCall(changeThreadMemberRoles); const onConfirmMakeAdmin = () => { let newRole = null; for (let roleID in threadInfo.roles) { const role = threadInfo.roles[roleID]; if (isCurrentlyAdmin && role.isDefault) { newRole = role.id; break; } else if (!isCurrentlyAdmin && roleIsAdminRole(role)) { newRole = role.id; break; } } invariant(newRole !== null, 'Could not find new role'); const customKeyName = `${changeThreadMemberRolesActionTypes.started}:${memberInfo.id}`; dispatchFunctions.dispatchActionPromise( changeThreadMemberRolesActionTypes, boundChangeThreadMemberRoles(threadInfo.id, [memberInfo.id], newRole), { customKeyName }, ); }; const userText = stringForUser(memberInfo); const actionClause = isCurrentlyAdmin ? `remove ${userText} as an admin` : `make ${userText} an admin`; Alert.alert( 'Confirm action', `Are you sure you want to ${actionClause} of this thread?`, [ { text: 'Cancel', style: 'cancel' }, { text: 'OK', onPress: onConfirmMakeAdmin }, ], { cancelable: true }, ); } const spec = { entries: [ { id: 'remove_user', text: 'Remove user', onPress: onRemoveUser }, { id: 'remove_admin', text: 'Remove admin', onPress: onToggleAdmin }, { id: 'make_admin', text: 'Make admin', onPress: onToggleAdmin }, ], }; const ThreadSettingsMemberTooltipModal = createTooltip< 'ThreadSettingsMemberTooltipModal', >(ThreadSettingsMemberTooltipButton, spec); export default ThreadSettingsMemberTooltipModal; diff --git a/native/chat/settings/thread-settings-member.react.js b/native/chat/settings/thread-settings-member.react.js index 46dca035c..9c676cf62 100644 --- a/native/chat/settings/thread-settings-member.react.js +++ b/native/chat/settings/thread-settings-member.react.js @@ -1,311 +1,311 @@ // @flow import invariant from 'invariant'; +import * as React from 'react'; +import { + View, + Text, + Platform, + ActivityIndicator, + TouchableOpacity, +} from 'react-native'; + import { removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { threadHasPermission, memberIsAdmin, memberHasAdminPowers, } from 'lib/shared/thread-utils'; import { stringForUser } from 'lib/shared/user-utils'; import type { LoadingStatus } from 'lib/types/loading-types'; import { type ThreadInfo, type RelativeMemberInfo, threadPermissions, } from 'lib/types/thread-types'; -import * as React from 'react'; -import { - View, - Text, - Platform, - ActivityIndicator, - TouchableOpacity, -} from 'react-native'; import PencilIcon from '../../components/pencil-icon.react'; import { SingleLine } from '../../components/single-line.react'; import { type KeyboardState, KeyboardContext, } from '../../keyboard/keyboard-state'; import { OverlayContext, type OverlayContextType, } from '../../navigation/overlay-context'; import { ThreadSettingsMemberTooltipModalRouteName } from '../../navigation/route-names'; import { useSelector } from '../../redux/redux-utils'; import { type Colors, useColors, useStyles } from '../../themes/colors'; import type { VerticalBounds } from '../../types/layout-types'; - import type { ThreadSettingsNavigate } from './thread-settings.react'; type BaseProps = {| +memberInfo: RelativeMemberInfo, +threadInfo: ThreadInfo, +canEdit: boolean, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +threadSettingsRouteKey: string, |}; type Props = {| ...BaseProps, // Redux state +removeUserLoadingStatus: LoadingStatus, +changeRoleLoadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, // withKeyboardState +keyboardState: ?KeyboardState, // withOverlayContext +overlayContext: ?OverlayContextType, |}; class ThreadSettingsMember extends React.PureComponent { editButton: ?React.ElementRef; visibleEntryIDs() { const role = this.props.memberInfo.role; if (!this.props.canEdit || !role) { return []; } const canRemoveMembers = threadHasPermission( this.props.threadInfo, threadPermissions.REMOVE_MEMBERS, ); const canChangeRoles = threadHasPermission( this.props.threadInfo, threadPermissions.CHANGE_ROLE, ); const result = []; if ( canRemoveMembers && !this.props.memberInfo.isViewer && (canChangeRoles || (this.props.threadInfo.roles[role] && this.props.threadInfo.roles[role].isDefault)) ) { result.push('remove_user'); } if (canChangeRoles && this.props.memberInfo.username) { result.push( memberIsAdmin(this.props.memberInfo, this.props.threadInfo) ? 'remove_admin' : 'make_admin', ); } return result; } render() { const userText = stringForUser(this.props.memberInfo); let userInfo = null; if (this.props.memberInfo.username) { userInfo = ( {userText} ); } else { userInfo = ( {userText} ); } let editButton = null; if ( this.props.removeUserLoadingStatus === 'loading' || this.props.changeRoleLoadingStatus === 'loading' ) { editButton = ( ); } else if (this.visibleEntryIDs().length !== 0) { editButton = ( ); } let roleInfo = null; if (memberIsAdmin(this.props.memberInfo, this.props.threadInfo)) { roleInfo = ( admin ); } else if (memberHasAdminPowers(this.props.memberInfo)) { roleInfo = ( parent admin ); } const firstItem = this.props.firstListItem ? null : this.props.styles.topBorder; const lastItem = this.props.lastListItem ? this.props.styles.lastInnerContainer : null; return ( {userInfo} {editButton} {roleInfo} ); } editButtonRef = (editButton: ?React.ElementRef) => { this.editButton = editButton; }; onEditButtonLayout = () => {}; onPressEdit = () => { if (this.dismissKeyboardIfShowing()) { return; } const { editButton, props: { verticalBounds }, } = this; if (!editButton || !verticalBounds) { return; } const { overlayContext } = this.props; invariant( overlayContext, 'ThreadSettingsMember should have OverlayContext', ); overlayContext.setScrollBlockingModalStatus('open'); editButton.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; this.props.navigate({ name: ThreadSettingsMemberTooltipModalRouteName, params: { presentedFrom: this.props.threadSettingsRouteKey, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs: this.visibleEntryIDs(), memberInfo: this.props.memberInfo, threadInfo: this.props.threadInfo, }, }); }); }; dismissKeyboardIfShowing = () => { const { keyboardState } = this.props; return !!(keyboardState && keyboardState.dismissKeyboardIfShowing()); }; } const unboundStyles = { anonymous: { color: 'panelForegroundTertiaryLabel', fontStyle: 'italic', }, container: { backgroundColor: 'panelForeground', flex: 1, paddingHorizontal: 12, }, editButton: { paddingLeft: 10, }, topBorder: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, innerContainer: { flex: 1, paddingHorizontal: 12, paddingVertical: 8, }, lastInnerContainer: { paddingBottom: Platform.OS === 'ios' ? 12 : 10, }, role: { color: 'panelForegroundTertiaryLabel', flex: 1, fontSize: 14, paddingTop: 4, }, row: { flex: 1, flexDirection: 'row', }, username: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, lineHeight: 20, }, }; export default React.memo(function ConnectedThreadSettingsMember( props: BaseProps, ) { const memberID = props.memberInfo.id; const removeUserLoadingStatus = useSelector((state) => createLoadingStatusSelector( removeUsersFromThreadActionTypes, `${removeUsersFromThreadActionTypes.started}:${memberID}`, )(state), ); const changeRoleLoadingStatus = useSelector((state) => createLoadingStatusSelector( changeThreadMemberRolesActionTypes, `${changeThreadMemberRolesActionTypes.started}:${memberID}`, )(state), ); const colors = useColors(); const styles = useStyles(unboundStyles); const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); return ( ); }); diff --git a/native/chat/settings/thread-settings-name.react.js b/native/chat/settings/thread-settings-name.react.js index 9c615b5aa..89e45a923 100644 --- a/native/chat/settings/thread-settings-name.react.js +++ b/native/chat/settings/thread-settings-name.react.js @@ -1,245 +1,245 @@ // @flow import invariant from 'invariant'; +import * as React from 'react'; +import { Text, Alert, ActivityIndicator, TextInput, View } from 'react-native'; + import { changeThreadSettingsActionTypes, changeThreadSettings, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import type { LoadingStatus } from 'lib/types/loading-types'; import { type ThreadInfo, type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; -import * as React from 'react'; -import { Text, Alert, ActivityIndicator, TextInput, View } from 'react-native'; import EditSettingButton from '../../components/edit-setting-button.react'; import { useSelector } from '../../redux/redux-utils'; import { type Colors, useStyles, useColors } from '../../themes/colors'; import type { LayoutEvent, ContentSizeChangeEvent, } from '../../types/react-native'; - import SaveSettingButton from './save-setting-button.react'; type BaseProps = {| +threadInfo: ThreadInfo, +nameEditValue: ?string, +setNameEditValue: (value: ?string, callback?: () => void) => void, +nameTextHeight: ?number, +setNameTextHeight: (number: number) => void, +canChangeSettings: boolean, |}; type Props = {| ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeThreadSettings: ( update: UpdateThreadRequest, ) => Promise, |}; class ThreadSettingsName extends React.PureComponent { textInput: ?React.ElementRef; render() { return ( Name {this.renderContent()} ); } renderContent() { if ( this.props.nameEditValue === null || this.props.nameEditValue === undefined ) { return ( {this.props.threadInfo.uiName} ); } let button; if (this.props.loadingStatus !== 'loading') { button = ; } else { button = ( ); } const textInputStyle = {}; if ( this.props.nameTextHeight !== undefined && this.props.nameTextHeight !== null ) { textInputStyle.height = this.props.nameTextHeight; } return ( {button} ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; onLayoutText = (event: LayoutEvent) => { this.props.setNameTextHeight(event.nativeEvent.layout.height); }; onTextInputContentSizeChange = (event: ContentSizeChangeEvent) => { this.props.setNameTextHeight(event.nativeEvent.contentSize.height); }; threadEditName() { return this.props.threadInfo.name ? this.props.threadInfo.name : ''; } onPressEdit = () => { this.props.setNameEditValue(this.threadEditName()); }; onSubmit = () => { invariant( this.props.nameEditValue !== null && this.props.nameEditValue !== undefined, 'should be set', ); const name = this.props.nameEditValue.trim(); if (name === this.threadEditName()) { this.props.setNameEditValue(null); return; } const editNamePromise = this.editName(name); this.props.dispatchActionPromise( changeThreadSettingsActionTypes, editNamePromise, { customKeyName: `${changeThreadSettingsActionTypes.started}:name` }, ); editNamePromise.then(() => { this.props.setNameEditValue(null); }); }; async editName(newName: string) { try { return await this.props.changeThreadSettings({ threadID: this.props.threadInfo.id, changes: { name: newName }, }); } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onErrorAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { this.props.setNameEditValue(this.threadEditName(), () => { invariant(this.textInput, 'textInput should be set'); this.textInput.focus(); }); }; } const unboundStyles = { currentValue: { color: 'panelForegroundSecondaryLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, margin: 0, paddingLeft: 4, paddingRight: 0, paddingVertical: 0, borderBottomColor: 'transparent', }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, row: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 8, }, }; const loadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:name`, ); export default React.memo(function ConnectedThreadSettingsName( props: BaseProps, ) { const styles = useStyles(unboundStyles); const colors = useColors(); const loadingStatus = useSelector(loadingStatusSelector); const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useServerCall(changeThreadSettings); return ( ); }); diff --git a/native/chat/settings/thread-settings-parent.react.js b/native/chat/settings/thread-settings-parent.react.js index 3038c96c4..d1a3737ad 100644 --- a/native/chat/settings/thread-settings-parent.react.js +++ b/native/chat/settings/thread-settings-parent.react.js @@ -1,145 +1,145 @@ // @flow import invariant from 'invariant'; -import { threadInfoSelector } from 'lib/selectors/thread-selectors'; -import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; -import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; import * as React from 'react'; import { Text, View, Platform } from 'react-native'; +import { threadInfoSelector } from 'lib/selectors/thread-selectors'; +import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; +import { connect } from 'lib/utils/redux-utils'; + import Button from '../../components/button.react'; import { SingleLine } from '../../components/single-line.react'; import { MessageListRouteName } from '../../navigation/route-names'; import type { AppState } from '../../redux/redux-setup'; import { styleSelector } from '../../themes/colors'; - import type { ThreadSettingsNavigate } from './thread-settings.react'; type Props = {| threadInfo: ThreadInfo, navigate: ThreadSettingsNavigate, // Redux state parentThreadInfo?: ?ThreadInfo, styles: typeof styles, |}; class ThreadSettingsParent extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, navigate: PropTypes.func.isRequired, parentThreadInfo: threadInfoPropType, styles: PropTypes.objectOf(PropTypes.object).isRequired, }; render() { let parent; if (this.props.parentThreadInfo) { parent = ( ); } else if (this.props.threadInfo.parentThreadID) { parent = ( Secret parent ); } else { parent = ( No parent ); } return ( Parent {parent} ); } onPressParentThread = () => { const threadInfo = this.props.parentThreadInfo; invariant(threadInfo, 'should be set'); this.props.navigate({ name: MessageListRouteName, params: { threadInfo }, key: `${MessageListRouteName}${threadInfo.id}`, }); }; } const styles = { currentValue: { flex: 1, paddingLeft: 4, paddingTop: Platform.OS === 'ios' ? 5 : 4, }, currentValueText: { color: 'panelForegroundSecondaryLabel', fontFamily: 'Arial', fontSize: 16, margin: 0, paddingRight: 0, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, paddingVertical: 4, width: 96, }, noParent: { fontStyle: 'italic', paddingLeft: 2, }, parentThreadLink: { color: 'link', }, row: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, }, }; const stylesSelector = styleSelector(styles); export default connect( (state: AppState, ownProps: { threadInfo: ThreadInfo }) => { const parsedThreadInfos = threadInfoSelector(state); const parentThreadInfo: ?ThreadInfo = ownProps.threadInfo.parentThreadID ? parsedThreadInfos[ownProps.threadInfo.parentThreadID] : null; return { parentThreadInfo, styles: stylesSelector(state), }; }, )(ThreadSettingsParent); diff --git a/native/chat/settings/thread-settings-promote-sidebar.react.js b/native/chat/settings/thread-settings-promote-sidebar.react.js index 0e3746aa9..5fe23a57d 100644 --- a/native/chat/settings/thread-settings-promote-sidebar.react.js +++ b/native/chat/settings/thread-settings-promote-sidebar.react.js @@ -1,132 +1,133 @@ // @flow +import * as React from 'react'; +import { Text, Alert, ActivityIndicator, View } from 'react-native'; + import { changeThreadSettingsActionTypes, changeThreadSettings, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import type { LoadingStatus } from 'lib/types/loading-types'; import { type ThreadInfo, type UpdateThreadRequest, type ChangeThreadSettingsPayload, threadTypes, } from 'lib/types/thread-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; -import * as React from 'react'; -import { Text, Alert, ActivityIndicator, View } from 'react-native'; import Button from '../../components/button.react'; import { useSelector } from '../../redux/redux-utils'; import { type Colors, useColors, useStyles } from '../../themes/colors'; import type { ViewStyle } from '../../types/styles'; type BaseProps = {| +threadInfo: ThreadInfo, +buttonStyle: ViewStyle, |}; type Props = {| ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeThreadSettings: ( request: UpdateThreadRequest, ) => Promise, |}; class ThreadSettingsPromoteSubthread extends React.PureComponent { render() { const { panelIosHighlightUnderlay, panelForegroundSecondaryLabel, } = this.props.colors; const loadingIndicator = this.props.loadingStatus === 'loading' ? ( ) : null; return ( ); } onPress = () => { this.props.dispatchActionPromise( changeThreadSettingsActionTypes, this.changeThreadSettings(), ); }; async changeThreadSettings() { const threadID = this.props.threadInfo.id; try { return await this.props.changeThreadSettings({ threadID, changes: { type: threadTypes.CHAT_NESTED_OPEN }, }); } catch (e) { Alert.alert('Unknown error', 'Uhh... try again?', undefined, { cancelable: true, }); throw e; } } } const unboundStyles = { button: { flexDirection: 'row', paddingHorizontal: 12, paddingVertical: 10, }, container: { backgroundColor: 'panelForeground', paddingHorizontal: 12, }, text: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, }, }; const loadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, ); export default React.memo( function ConnectedThreadSettingsPromoteSubthread(props: BaseProps) { const loadingStatus = useSelector(loadingStatusSelector); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useServerCall(changeThreadSettings); return ( ); }, ); diff --git a/native/chat/settings/thread-settings-push-notifs.react.js b/native/chat/settings/thread-settings-push-notifs.react.js index b1086cdc1..994f399b8 100644 --- a/native/chat/settings/thread-settings-push-notifs.react.js +++ b/native/chat/settings/thread-settings-push-notifs.react.js @@ -1,120 +1,121 @@ // @flow +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { Text, View, Switch } from 'react-native'; + import { updateSubscriptionActionTypes, updateSubscription, } from 'lib/actions/user-actions'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResult, } from 'lib/types/subscription-types'; import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import { Text, View, Switch } from 'react-native'; import { useStyles } from '../../themes/colors'; type BaseProps = {| +threadInfo: ThreadInfo, |}; type Props = {| ...BaseProps, // Redux state +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +updateSubscription: ( subscriptionUpdate: SubscriptionUpdateRequest, ) => Promise, |}; type State = {| +currentValue: boolean, |}; class ThreadSettingsPushNotifs extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, dispatchActionPromise: PropTypes.func.isRequired, updateSubscription: PropTypes.func.isRequired, }; constructor(props: Props) { super(props); this.state = { currentValue: props.threadInfo.currentUser.subscription.pushNotifs, }; } render() { return ( Push notifs ); } onValueChange = (value: boolean) => { this.setState({ currentValue: value }); this.props.dispatchActionPromise( updateSubscriptionActionTypes, this.props.updateSubscription({ threadID: this.props.threadInfo.id, updatedFields: { pushNotifs: value, }, }), ); }; } const unboundStyles = { currentValue: { alignItems: 'flex-end', flex: 1, margin: 0, paddingLeft: 4, paddingRight: 0, paddingVertical: 0, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, row: { alignItems: 'center', backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 3, }, }; export default React.memo(function ConnectedThreadSettingsPushNotifs( props: BaseProps, ) { const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateSubscription = useServerCall(updateSubscription); return ( ); }); diff --git a/native/chat/settings/thread-settings-visibility.react.js b/native/chat/settings/thread-settings-visibility.react.js index f372fc675..882503e1c 100644 --- a/native/chat/settings/thread-settings-visibility.react.js +++ b/native/chat/settings/thread-settings-visibility.react.js @@ -1,42 +1,43 @@ // @flow -import type { ThreadInfo } from 'lib/types/thread-types'; import * as React from 'react'; import { Text, View } from 'react-native'; +import type { ThreadInfo } from 'lib/types/thread-types'; + import ThreadVisibility from '../../components/thread-visibility.react'; import { useStyles, useColors } from '../../themes/colors'; type Props = {| +threadInfo: ThreadInfo, |}; function ThreadSettingsVisibility(props: Props) { const styles = useStyles(unboundStyles); const colors = useColors(); return ( Visibility ); } const unboundStyles = { label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, row: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 8, }, }; export default ThreadSettingsVisibility; diff --git a/native/chat/settings/thread-settings.react.js b/native/chat/settings/thread-settings.react.js index b8216a4bc..3dfbf5113 100644 --- a/native/chat/settings/thread-settings.react.js +++ b/native/chat/settings/thread-settings.react.js @@ -1,1073 +1,1073 @@ // @flow import invariant from 'invariant'; +import * as React from 'react'; +import { View, FlatList, Platform } from 'react-native'; +import { createSelector } from 'reselect'; + import { changeThreadSettingsActionTypes, leaveThreadActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { threadInfoSelector, childThreadInfos, } from 'lib/selectors/thread-selectors'; import { relativeMemberInfoSelectorForMembersOfThread } from 'lib/selectors/user-selectors'; import { threadHasPermission, viewerIsMember, threadInChatList, } from 'lib/shared/thread-utils'; import threadWatcher from 'lib/shared/thread-watcher'; import { type ThreadInfo, type RelativeMemberInfo, threadPermissions, threadTypes, } from 'lib/types/thread-types'; -import * as React from 'react'; -import { View, FlatList, Platform } from 'react-native'; -import { createSelector } from 'reselect'; import { type KeyboardState, KeyboardContext, } from '../../keyboard/keyboard-state'; import type { TabNavigationProp } from '../../navigation/app-navigator.react'; import { OverlayContext, type OverlayContextType, } from '../../navigation/overlay-context'; import type { NavigationRoute } from '../../navigation/route-names'; import { AddUsersModalRouteName, ComposeSubthreadModalRouteName, } from '../../navigation/route-names'; import type { AppState } from '../../redux/redux-setup'; import { useSelector } from '../../redux/redux-utils'; import { useStyles, type IndicatorStyle, useIndicatorStyle, } from '../../themes/colors'; import type { VerticalBounds } from '../../types/layout-types'; import type { ViewStyle } from '../../types/styles'; import type { ChatNavigationProp } from '../chat.react'; - import type { CategoryType } from './thread-settings-category.react'; import { ThreadSettingsCategoryHeader, ThreadSettingsCategoryFooter, } from './thread-settings-category.react'; import ThreadSettingsChildThread from './thread-settings-child-thread.react'; import ThreadSettingsColor from './thread-settings-color.react'; import ThreadSettingsDeleteThread from './thread-settings-delete-thread.react'; import ThreadSettingsDescription from './thread-settings-description.react'; import ThreadSettingsHomeNotifs from './thread-settings-home-notifs.react'; import ThreadSettingsLeaveThread from './thread-settings-leave-thread.react'; import { ThreadSettingsSeeMore, ThreadSettingsAddMember, ThreadSettingsAddSubthread, } from './thread-settings-list-action.react'; import ThreadSettingsMember from './thread-settings-member.react'; import ThreadSettingsName from './thread-settings-name.react'; import ThreadSettingsParent from './thread-settings-parent.react'; import ThreadSettingsPromoteSidebar from './thread-settings-promote-sidebar.react'; import ThreadSettingsPushNotifs from './thread-settings-push-notifs.react'; import ThreadSettingsVisibility from './thread-settings-visibility.react'; const itemPageLength = 5; export type ThreadSettingsParams = {| threadInfo: ThreadInfo, |}; export type ThreadSettingsNavigate = $PropertyType< ChatNavigationProp<'ThreadSettings'>, 'navigate', >; type ChatSettingsItem = | {| +itemType: 'header', +key: string, +title: string, +categoryType: CategoryType, |} | {| +itemType: 'footer', +key: string, +categoryType: CategoryType, |} | {| +itemType: 'name', +key: string, +threadInfo: ThreadInfo, +nameEditValue: ?string, +nameTextHeight: ?number, +canChangeSettings: boolean, |} | {| +itemType: 'color', +key: string, +threadInfo: ThreadInfo, +colorEditValue: string, +canChangeSettings: boolean, +navigate: ThreadSettingsNavigate, +threadSettingsRouteKey: string, |} | {| +itemType: 'description', +key: string, +threadInfo: ThreadInfo, +descriptionEditValue: ?string, +descriptionTextHeight: ?number, +canChangeSettings: boolean, |} | {| +itemType: 'parent', +key: string, +threadInfo: ThreadInfo, +navigate: ThreadSettingsNavigate, |} | {| +itemType: 'visibility', +key: string, +threadInfo: ThreadInfo, |} | {| +itemType: 'pushNotifs', +key: string, +threadInfo: ThreadInfo, |} | {| +itemType: 'homeNotifs', +key: string, +threadInfo: ThreadInfo, |} | {| +itemType: 'seeMore', +key: string, +onPress: () => void, |} | {| +itemType: 'childThread', +key: string, +threadInfo: ThreadInfo, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, |} | {| +itemType: 'addSubthread', +key: string, |} | {| +itemType: 'member', +key: string, +memberInfo: RelativeMemberInfo, +threadInfo: ThreadInfo, +canEdit: boolean, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +threadSettingsRouteKey: string, |} | {| +itemType: 'addMember', +key: string, |} | {| +itemType: 'promoteSidebar' | 'leaveThread' | 'deleteThread', +key: string, +threadInfo: ThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, |}; type BaseProps = {| +navigation: ChatNavigationProp<'ThreadSettings'>, +route: NavigationRoute<'ThreadSettings'>, |}; type Props = {| ...BaseProps, // Redux state +threadInfo: ?ThreadInfo, +threadMembers: $ReadOnlyArray, +childThreadInfos: ?$ReadOnlyArray, +somethingIsSaving: boolean, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, |}; type State = {| +numMembersShowing: number, +numSubthreadsShowing: number, +numSidebarsShowing: number, +nameEditValue: ?string, +descriptionEditValue: ?string, +nameTextHeight: ?number, +descriptionTextHeight: ?number, +colorEditValue: string, +verticalBounds: ?VerticalBounds, |}; type PropsAndState = {| ...Props, ...State |}; class ThreadSettings extends React.PureComponent { flatListContainer: ?React.ElementRef; constructor(props: Props) { super(props); const threadInfo = props.threadInfo; invariant(threadInfo, 'ThreadInfo should exist when ThreadSettings opened'); this.state = { numMembersShowing: itemPageLength, numSubthreadsShowing: itemPageLength, numSidebarsShowing: itemPageLength, nameEditValue: null, descriptionEditValue: null, nameTextHeight: null, descriptionTextHeight: null, colorEditValue: threadInfo.color, verticalBounds: null, }; } static getThreadInfo(props: { threadInfo: ?ThreadInfo, route: NavigationRoute<'ThreadSettings'>, ... }): ThreadInfo { const { threadInfo } = props; if (threadInfo) { return threadInfo; } return props.route.params.threadInfo; } static scrollDisabled(props: Props) { const { overlayContext } = props; invariant(overlayContext, 'ThreadSettings should have OverlayContext'); return overlayContext.scrollBlockingModalStatus !== 'closed'; } componentDidMount() { const threadInfo = ThreadSettings.getThreadInfo(this.props); if (!threadInChatList(threadInfo)) { threadWatcher.watchID(threadInfo.id); } const tabNavigation: ?TabNavigationProp< 'Chat', > = this.props.navigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { const threadInfo = ThreadSettings.getThreadInfo(this.props); if (!threadInChatList(threadInfo)) { threadWatcher.removeID(threadInfo.id); } const tabNavigation: ?TabNavigationProp< 'Chat', > = this.props.navigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.removeListener('tabPress', this.onTabPress); } onTabPress = () => { if (this.props.navigation.isFocused() && !this.props.somethingIsSaving) { this.props.navigation.popToTop(); } }; componentDidUpdate(prevProps: Props) { const oldReduxThreadInfo = prevProps.threadInfo; const newReduxThreadInfo = this.props.threadInfo; if (newReduxThreadInfo && newReduxThreadInfo !== oldReduxThreadInfo) { this.props.navigation.setParams({ threadInfo: newReduxThreadInfo }); } const oldNavThreadInfo = ThreadSettings.getThreadInfo(prevProps); const newNavThreadInfo = ThreadSettings.getThreadInfo(this.props); if (oldNavThreadInfo.id !== newNavThreadInfo.id) { if (!threadInChatList(oldNavThreadInfo)) { threadWatcher.removeID(oldNavThreadInfo.id); } if (!threadInChatList(newNavThreadInfo)) { threadWatcher.watchID(newNavThreadInfo.id); } } if ( newNavThreadInfo.color !== oldNavThreadInfo.color && this.state.colorEditValue === oldNavThreadInfo.color ) { this.setState({ colorEditValue: newNavThreadInfo.color }); } const scrollIsDisabled = ThreadSettings.scrollDisabled(this.props); const scrollWasDisabled = ThreadSettings.scrollDisabled(prevProps); if (!scrollWasDisabled && scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: false }); } else if (scrollWasDisabled && !scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: true }); } } threadBasicsListDataSelector = createSelector( (propsAndState: PropsAndState) => ThreadSettings.getThreadInfo(propsAndState), (propsAndState: PropsAndState) => propsAndState.nameEditValue, (propsAndState: PropsAndState) => propsAndState.nameTextHeight, (propsAndState: PropsAndState) => propsAndState.colorEditValue, (propsAndState: PropsAndState) => propsAndState.descriptionEditValue, (propsAndState: PropsAndState) => propsAndState.descriptionTextHeight, (propsAndState: PropsAndState) => !propsAndState.somethingIsSaving, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.route.key, ( threadInfo: ThreadInfo, nameEditValue: ?string, nameTextHeight: ?number, colorEditValue: string, descriptionEditValue: ?string, descriptionTextHeight: ?number, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, ) => { const canEditThread = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD, ); const canChangeSettings = canEditThread && canStartEditing; const listData: ChatSettingsItem[] = []; listData.push({ itemType: 'header', key: 'basicsHeader', title: 'Basics', categoryType: 'full', }); listData.push({ itemType: 'name', key: 'name', threadInfo, nameEditValue, nameTextHeight, canChangeSettings, }); listData.push({ itemType: 'color', key: 'color', threadInfo, colorEditValue, canChangeSettings, navigate, threadSettingsRouteKey: routeKey, }); listData.push({ itemType: 'footer', key: 'basicsFooter', categoryType: 'full', }); if ( (descriptionEditValue !== null && descriptionEditValue !== undefined) || threadInfo.description || canEditThread ) { listData.push({ itemType: 'description', key: 'description', threadInfo, descriptionEditValue, descriptionTextHeight, canChangeSettings, }); } const isMember = viewerIsMember(threadInfo); if (isMember) { listData.push({ itemType: 'header', key: 'subscriptionHeader', title: 'Subscription', categoryType: 'full', }); listData.push({ itemType: 'pushNotifs', key: 'pushNotifs', threadInfo, }); listData.push({ itemType: 'homeNotifs', key: 'homeNotifs', threadInfo, }); listData.push({ itemType: 'footer', key: 'subscriptionFooter', categoryType: 'full', }); } listData.push({ itemType: 'header', key: 'privacyHeader', title: 'Privacy', categoryType: 'full', }); listData.push({ itemType: 'parent', key: 'parent', threadInfo, navigate, }); listData.push({ itemType: 'visibility', key: 'visibility', threadInfo, }); listData.push({ itemType: 'footer', key: 'privacyFooter', categoryType: 'full', }); return listData; }, ); subthreadsListDataSelector = createSelector( (propsAndState: PropsAndState) => ThreadSettings.getThreadInfo(propsAndState), (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSubthreadsShowing, ( threadInfo: ThreadInfo, navigate: ThreadSettingsNavigate, childThreads: ?$ReadOnlyArray, numSubthreadsShowing: number, ) => { const listData: ChatSettingsItem[] = []; const subthreads = childThreads?.filter( (childThreadInfo) => childThreadInfo.type !== threadTypes.SIDEBAR, ) ?? []; const canCreateSubthreads = threadHasPermission( threadInfo, threadPermissions.CREATE_SUBTHREADS, ); if (subthreads.length === 0 && !canCreateSubthreads) { return listData; } listData.push({ itemType: 'header', key: 'subthreadHeader', title: 'Subthreads', categoryType: 'unpadded', }); if (canCreateSubthreads) { listData.push({ itemType: 'addSubthread', key: 'addSubthread', }); } const numItems = Math.min(numSubthreadsShowing, subthreads.length); for (let i = 0; i < numItems; i++) { const subthreadInfo = subthreads[i]; listData.push({ itemType: 'childThread', key: `childThread${subthreadInfo.id}`, threadInfo: subthreadInfo, navigate, firstListItem: i === 0 && !canCreateSubthreads, lastListItem: i === numItems - 1 && numItems === subthreads.length, }); } if (numItems < subthreads.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreSubthreads', onPress: this.onPressSeeMoreSubthreads, }); } listData.push({ itemType: 'footer', key: 'subthreadFooter', categoryType: 'unpadded', }); return listData; }, ); sidebarsListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSidebarsShowing, ( navigate: ThreadSettingsNavigate, childThreads: ?$ReadOnlyArray, numSidebarsShowing: number, ) => { const listData: ChatSettingsItem[] = []; const sidebars = childThreads?.filter( (childThreadInfo) => childThreadInfo.type === threadTypes.SIDEBAR, ) ?? []; if (sidebars.length === 0) { return listData; } listData.push({ itemType: 'header', key: 'sidebarHeader', title: 'Sidebars', categoryType: 'unpadded', }); const numItems = Math.min(numSidebarsShowing, sidebars.length); for (let i = 0; i < numItems; i++) { const sidebarInfo = sidebars[i]; listData.push({ itemType: 'childThread', key: `childThread${sidebarInfo.id}`, threadInfo: sidebarInfo, navigate, firstListItem: i === 0, lastListItem: i === numItems - 1 && numItems === sidebars.length, }); } if (numItems < sidebars.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreSidebars', onPress: this.onPressSeeMoreSidebars, }); } listData.push({ itemType: 'footer', key: 'sidebarFooter', categoryType: 'unpadded', }); return listData; }, ); threadMembersListDataSelector = createSelector( (propsAndState: PropsAndState) => ThreadSettings.getThreadInfo(propsAndState), (propsAndState: PropsAndState) => !propsAndState.somethingIsSaving, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.route.key, (propsAndState: PropsAndState) => propsAndState.threadMembers, (propsAndState: PropsAndState) => propsAndState.numMembersShowing, (propsAndState: PropsAndState) => propsAndState.verticalBounds, ( threadInfo: ThreadInfo, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, threadMembers: $ReadOnlyArray, numMembersShowing: number, verticalBounds: ?VerticalBounds, ) => { const listData: ChatSettingsItem[] = []; const canAddMembers = threadHasPermission( threadInfo, threadPermissions.ADD_MEMBERS, ); if (threadMembers.length === 0 && !canAddMembers) { return listData; } listData.push({ itemType: 'header', key: 'memberHeader', title: 'Members', categoryType: 'unpadded', }); if (canAddMembers) { listData.push({ itemType: 'addMember', key: 'addMember', }); } const numItems = Math.min(numMembersShowing, threadMembers.length); for (let i = 0; i < numItems; i++) { const memberInfo = threadMembers[i]; listData.push({ itemType: 'member', key: `member${memberInfo.id}`, memberInfo, threadInfo, canEdit: canStartEditing, navigate, firstListItem: i === 0 && !canAddMembers, lastListItem: i === numItems - 1 && numItems === threadMembers.length, verticalBounds, threadSettingsRouteKey: routeKey, }); } if (numItems < threadMembers.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreMembers', onPress: this.onPressSeeMoreMembers, }); } listData.push({ itemType: 'footer', key: 'memberFooter', categoryType: 'unpadded', }); return listData; }, ); actionsListDataSelector = createSelector( (propsAndState: PropsAndState) => ThreadSettings.getThreadInfo(propsAndState), (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.styles, ( threadInfo: ThreadInfo, navigate: ThreadSettingsNavigate, styles: typeof unboundStyles, ) => { const buttons = []; const canChangeThreadType = threadHasPermission( threadInfo, threadPermissions.EDIT_PERMISSIONS, ); const canPromoteSidebar = threadInfo.type === threadTypes.SIDEBAR && canChangeThreadType; if (canPromoteSidebar) { buttons.push({ itemType: 'promoteSidebar', key: 'promoteSidebar', threadInfo, navigate, }); } const canLeaveThread = threadHasPermission( threadInfo, threadPermissions.LEAVE_THREAD, ); if (viewerIsMember(threadInfo) && canLeaveThread) { buttons.push({ itemType: 'leaveThread', key: 'leaveThread', threadInfo, navigate, }); } const canDeleteThread = threadHasPermission( threadInfo, threadPermissions.DELETE_THREAD, ); if (canDeleteThread) { buttons.push({ itemType: 'deleteThread', key: 'deleteThread', threadInfo, navigate, }); } const listData: ChatSettingsItem[] = []; if (buttons.length === 0) { return listData; } listData.push({ itemType: 'header', key: 'actionsHeader', title: 'Actions', categoryType: 'unpadded', }); for (let i = 0; i < buttons.length; i++) { listData.push({ ...buttons[i], buttonStyle: [ i === 0 ? null : styles.nonTopButton, i === buttons.length - 1 ? styles.lastButton : null, ], }); } listData.push({ itemType: 'footer', key: 'actionsFooter', categoryType: 'unpadded', }); return listData; }, ); listDataSelector = createSelector( this.threadBasicsListDataSelector, this.subthreadsListDataSelector, this.sidebarsListDataSelector, this.threadMembersListDataSelector, this.actionsListDataSelector, ( threadBasicsListData: ChatSettingsItem[], subthreadsListData: ChatSettingsItem[], sidebarsListData: ChatSettingsItem[], threadMembersListData: ChatSettingsItem[], actionsListData: ChatSettingsItem[], ) => [ ...threadBasicsListData, ...subthreadsListData, ...sidebarsListData, ...threadMembersListData, ...actionsListData, ], ); get listData() { return this.listDataSelector({ ...this.props, ...this.state }); } render() { return ( ); } 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({ verticalBounds: { height, y: pageY } }); }); }; renderItem = (row: { item: ChatSettingsItem }) => { const item = row.item; if (item.itemType === 'header') { return ( ); } else if (item.itemType === 'footer') { return ; } else if (item.itemType === 'name') { return ( ); } else if (item.itemType === 'color') { return ( ); } else if (item.itemType === 'description') { return ( ); } else if (item.itemType === 'parent') { return ( ); } else if (item.itemType === 'visibility') { return ; } else if (item.itemType === 'pushNotifs') { return ; } else if (item.itemType === 'homeNotifs') { return ; } else if (item.itemType === 'seeMore') { return ; } else if (item.itemType === 'childThread') { return ( ); } else if (item.itemType === 'addSubthread') { return ( ); } else if (item.itemType === 'member') { return ( ); } else if (item.itemType === 'addMember') { return ; } else if (item.itemType === 'leaveThread') { return ( ); } else if (item.itemType === 'deleteThread') { return ( ); } else if (item.itemType === 'promoteSidebar') { return ( ); } else { invariant(false, `unexpected ThreadSettings item type ${item.itemType}`); } }; setNameEditValue = (value: ?string, callback?: () => void) => { this.setState({ nameEditValue: value }, callback); }; setNameTextHeight = (height: number) => { this.setState({ nameTextHeight: height }); }; setColorEditValue = (color: string) => { this.setState({ colorEditValue: color }); }; setDescriptionEditValue = (value: ?string, callback?: () => void) => { this.setState({ descriptionEditValue: value }, callback); }; setDescriptionTextHeight = (height: number) => { this.setState({ descriptionTextHeight: height }); }; onPressComposeSubthread = () => { const threadInfo = ThreadSettings.getThreadInfo(this.props); this.props.navigation.navigate(ComposeSubthreadModalRouteName, { presentedFrom: this.props.route.key, threadInfo, }); }; onPressAddMember = () => { const threadInfo = ThreadSettings.getThreadInfo(this.props); this.props.navigation.navigate(AddUsersModalRouteName, { presentedFrom: this.props.route.key, threadInfo, }); }; onPressSeeMoreMembers = () => { this.setState((prevState) => ({ numMembersShowing: prevState.numMembersShowing + itemPageLength, })); }; onPressSeeMoreSubthreads = () => { this.setState((prevState) => ({ numSubthreadsShowing: prevState.numSubthreadsShowing + itemPageLength, })); }; onPressSeeMoreSidebars = () => { this.setState((prevState) => ({ numSidebarsShowing: prevState.numSidebarsShowing + itemPageLength, })); }; } const unboundStyles = { container: { backgroundColor: 'panelBackground', flex: 1, }, flatList: { paddingVertical: 16, }, nonTopButton: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, lastButton: { paddingBottom: Platform.OS === 'ios' ? 14 : 12, }, }; const editNameLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:name`, ); const editColorLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:color`, ); const editDescriptionLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:description`, ); const leaveThreadLoadingStatusSelector = createLoadingStatusSelector( leaveThreadActionTypes, ); const somethingIsSaving = ( state: AppState, threadMembers: $ReadOnlyArray, ) => { if ( editNameLoadingStatusSelector(state) === 'loading' || editColorLoadingStatusSelector(state) === 'loading' || editDescriptionLoadingStatusSelector(state) === 'loading' || leaveThreadLoadingStatusSelector(state) === 'loading' ) { return true; } for (let threadMember of threadMembers) { const removeUserLoadingStatus = createLoadingStatusSelector( removeUsersFromThreadActionTypes, `${removeUsersFromThreadActionTypes.started}:${threadMember.id}`, )(state); if (removeUserLoadingStatus === 'loading') { return true; } const changeRoleLoadingStatus = createLoadingStatusSelector( changeThreadMemberRolesActionTypes, `${changeThreadMemberRolesActionTypes.started}:${threadMember.id}`, )(state); if (changeRoleLoadingStatus === 'loading') { return true; } } return false; }; export default React.memo(function ConnectedThreadSettings( props: BaseProps, ) { const threadID = props.route.params.threadInfo.id; const threadInfo = useSelector( (state) => threadInfoSelector(state)[threadID], ); const threadMembers = useSelector( relativeMemberInfoSelectorForMembersOfThread(threadID), ); const boundChildThreadInfos = useSelector( (state) => childThreadInfos(state)[threadID], ); const boundSomethingIsSaving = useSelector((state) => somethingIsSaving(state, threadMembers), ); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const overlayContext = React.useContext(OverlayContext); const keyboardState = React.useContext(KeyboardContext); return ( ); }); diff --git a/native/chat/sidebar-item.react.js b/native/chat/sidebar-item.react.js index 02a560d85..ed9cfbafe 100644 --- a/native/chat/sidebar-item.react.js +++ b/native/chat/sidebar-item.react.js @@ -1,83 +1,84 @@ // @flow -import type { ThreadInfo, SidebarInfo } from 'lib/types/thread-types'; -import { shortAbsoluteDate } from 'lib/utils/date-utils'; import * as React from 'react'; import { Text } from 'react-native'; import Icon from 'react-native-vector-icons/Entypo'; +import type { ThreadInfo, SidebarInfo } from 'lib/types/thread-types'; +import { shortAbsoluteDate } from 'lib/utils/date-utils'; + import Button from '../components/button.react'; import { SingleLine } from '../components/single-line.react'; import { useColors, useStyles } from '../themes/colors'; import type { ViewStyle } from '../types/styles'; type Props = {| +sidebarInfo: SidebarInfo, +onPressItem: (threadInfo: ThreadInfo) => void, +style?: ?ViewStyle, |}; function SidebarItem(props: Props) { const { lastUpdatedTime } = props.sidebarInfo; const lastActivity = shortAbsoluteDate(lastUpdatedTime); const { threadInfo } = props.sidebarInfo; const styles = useStyles(unboundStyles); const unreadStyle = threadInfo.currentUser.unread ? styles.unread : null; const { onPressItem } = props; const onPress = React.useCallback(() => onPressItem(threadInfo), [ threadInfo, onPressItem, ]); const colors = useColors(); return ( ); } const unboundStyles = { unread: { color: 'listForegroundLabel', fontWeight: 'bold', }, sidebar: { height: 30, flexDirection: 'row', display: 'flex', marginLeft: 25, marginRight: 10, alignItems: 'center', }, icon: { paddingLeft: 5, color: 'listForegroundSecondaryLabel', width: 35, }, name: { color: 'listForegroundSecondaryLabel', flex: 1, fontSize: 16, paddingLeft: 5, paddingBottom: 2, }, lastActivity: { color: 'listForegroundTertiaryLabel', fontSize: 14, marginLeft: 10, }, }; export default SidebarItem; diff --git a/native/chat/sidebar-list-modal.react.js b/native/chat/sidebar-list-modal.react.js index a6983f0af..84aee5a68 100644 --- a/native/chat/sidebar-list-modal.react.js +++ b/native/chat/sidebar-list-modal.react.js @@ -1,162 +1,162 @@ // @flow +import * as React from 'react'; +import { TextInput, FlatList, StyleSheet } from 'react-native'; + import { sidebarInfoSelector } from 'lib/selectors/thread-selectors'; import SearchIndex from 'lib/shared/search-index'; import { threadSearchText } from 'lib/shared/thread-utils'; import type { ThreadInfo, SidebarInfo } from 'lib/types/thread-types'; -import * as React from 'react'; -import { TextInput, FlatList, StyleSheet } from 'react-native'; import Modal from '../components/modal.react'; import Search from '../components/search.react'; import type { RootNavigationProp } from '../navigation/root-navigator.react'; import type { NavigationRoute } from '../navigation/route-names'; import { MessageListRouteName } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { useIndicatorStyle } from '../themes/colors'; import { waitForModalInputFocus } from '../utils/timers'; - import SidebarItem from './sidebar-item.react'; export type SidebarListModalParams = {| +threadInfo: ThreadInfo, |}; function keyExtractor(sidebarInfo: SidebarInfo) { return sidebarInfo.threadInfo.id; } function getItemLayout(data: ?$ReadOnlyArray, index: number) { return { length: 24, offset: 24 * index, index }; } type Props = {| +navigation: RootNavigationProp<'SidebarListModal'>, +route: NavigationRoute<'SidebarListModal'>, |}; function SidebarListModal(props: Props) { const threadID = props.route.params.threadInfo.id; const sidebarInfos = useSelector( (state) => sidebarInfoSelector(state)[threadID] ?? [], ); const [searchState, setSearchState] = React.useState({ text: '', results: new Set(), }); const listData = React.useMemo(() => { if (!searchState.text) { return sidebarInfos; } return sidebarInfos.filter(({ threadInfo }) => searchState.results.has(threadInfo.id), ); }, [sidebarInfos, searchState]); const userInfos = useSelector((state) => state.userStore.userInfos); const searchIndex = React.useMemo(() => { const index = new SearchIndex(); for (const sidebarInfo of sidebarInfos) { const { threadInfo } = sidebarInfo; index.addEntry(threadInfo.id, threadSearchText(threadInfo, userInfos)); } return index; }, [sidebarInfos, userInfos]); React.useEffect(() => { setSearchState((curState) => ({ ...curState, results: new Set(searchIndex.getSearchResults(curState.text)), })); }, [searchIndex]); const onChangeSearchText = React.useCallback( (searchText: string) => setSearchState({ text: searchText, results: new Set(searchIndex.getSearchResults(searchText)), }), [searchIndex], ); const searchTextInputRef = React.useRef(); const setSearchTextInputRef = React.useCallback( async (textInput: ?React.ElementRef) => { searchTextInputRef.current = textInput; if (!textInput) { return; } await waitForModalInputFocus(); if (searchTextInputRef.current) { searchTextInputRef.current.focus(); } }, [], ); const { navigation } = props; const { navigate } = navigation; const onPressItem = React.useCallback( (threadInfo: ThreadInfo) => { setSearchState({ text: '', results: new Set(), }); if (searchTextInputRef.current) { searchTextInputRef.current.blur(); } navigate({ name: MessageListRouteName, params: { threadInfo }, key: `${MessageListRouteName}${threadInfo.id}`, }); }, [navigate], ); const renderItem = React.useCallback( (row: { item: SidebarInfo, ... }) => { return ( ); }, [onPressItem], ); const indicatorStyle = useIndicatorStyle(); return ( ); } const styles = StyleSheet.create({ search: { marginBottom: 8, }, sidebar: { marginLeft: 0, marginRight: 5, }, }); export default SidebarListModal; diff --git a/native/chat/swipeable-thread.react.js b/native/chat/swipeable-thread.react.js index 0f29cbb25..f04f9c100 100644 --- a/native/chat/swipeable-thread.react.js +++ b/native/chat/swipeable-thread.react.js @@ -1,117 +1,118 @@ // @flow import { useNavigation } from '@react-navigation/native'; +import * as React from 'react'; +import MaterialIcon from 'react-native-vector-icons/MaterialCommunityIcons'; + import { setThreadUnreadStatus, setThreadUnreadStatusActionTypes, } from 'lib/actions/activity-actions'; import type { SetThreadUnreadStatusPayload, SetThreadUnreadStatusRequest, } from 'lib/types/activity-types'; import type { ThreadInfo } from 'lib/types/thread-types'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils'; -import * as React from 'react'; -import MaterialIcon from 'react-native-vector-icons/MaterialCommunityIcons'; import Swipeable from '../components/swipeable'; import { useColors } from '../themes/colors'; type Props = {| +threadInfo: ThreadInfo, +mostRecentNonLocalMessage: ?string, +onSwipeableWillOpen: (threadInfo: ThreadInfo) => void, +currentlyOpenedSwipeableId?: string, +iconSize: number, +children: React.Node, |}; function SwipeableThread(props: Props) { const swipeable = React.useRef(); const navigation = useNavigation(); React.useEffect(() => { return navigation.addListener('blur', () => { if (swipeable.current) { swipeable.current.close(); } }); }, [navigation, swipeable]); const { threadInfo, currentlyOpenedSwipeableId } = props; React.useEffect(() => { if (swipeable.current && threadInfo.id !== currentlyOpenedSwipeableId) { swipeable.current.close(); } }, [currentlyOpenedSwipeableId, swipeable, threadInfo.id]); const { onSwipeableWillOpen } = props; const onSwipeableRightWillOpen = React.useCallback(() => { onSwipeableWillOpen(threadInfo); }, [onSwipeableWillOpen, threadInfo]); const colors = useColors(); const { mostRecentNonLocalMessage, iconSize } = props; const updateUnreadStatus: ( request: SetThreadUnreadStatusRequest, ) => Promise = useServerCall( setThreadUnreadStatus, ); const dispatchActionPromise = useDispatchActionPromise(); const swipeableActions = React.useMemo(() => { const isUnread = threadInfo.currentUser.unread; const toggleUnreadStatus = () => { const request = { unread: !isUnread, threadID: threadInfo.id, latestMessage: mostRecentNonLocalMessage, }; dispatchActionPromise( setThreadUnreadStatusActionTypes, updateUnreadStatus(request), undefined, { threadID: threadInfo.id, unread: !isUnread, }, ); if (swipeable.current) { swipeable.current.close(); } }; return [ { key: 'action1', onPress: toggleUnreadStatus, color: isUnread ? colors.redButton : colors.greenButton, content: ( ), }, ]; }, [ colors, threadInfo, mostRecentNonLocalMessage, iconSize, updateUnreadStatus, dispatchActionPromise, ]); return ( {props.children} ); } export default SwipeableThread; diff --git a/native/chat/text-message-tooltip-button.react.js b/native/chat/text-message-tooltip-button.react.js index 4cf4d9739..2186d14f3 100644 --- a/native/chat/text-message-tooltip-button.react.js +++ b/native/chat/text-message-tooltip-button.react.js @@ -1,56 +1,55 @@ // @flow import * as React from 'react'; import Animated from 'react-native-reanimated'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import type { TooltipRoute } from '../navigation/tooltip.react'; import { useSelector } from '../redux/redux-utils'; - import { InnerTextMessage } from './inner-text-message.react'; import { MessageHeader } from './message-header.react'; import { MessageListContext, useMessageListContext, } from './message-list-types'; /* eslint-disable import/no-named-as-default-member */ const { Value } = Animated; /* eslint-enable import/no-named-as-default-member */ type Props = {| +navigation: AppNavigationProp<'TextMessageTooltipModal'>, +route: TooltipRoute<'TextMessageTooltipModal'>, +progress: Value, |}; function TextMessageTooltipButton(props: Props) { const { progress } = props; const windowWidth = useSelector((state) => state.dimensions.width); const { initialCoordinates } = props.route.params; const headerStyle = React.useMemo(() => { const bottom = initialCoordinates.height; return { opacity: progress, position: 'absolute', left: -initialCoordinates.x, width: windowWidth, bottom, }; }, [progress, windowWidth, initialCoordinates]); const { item } = props.route.params; const threadID = item.threadInfo.id; const messageListContext = useMessageListContext(threadID); const { navigation } = props; return ( ); } export default TextMessageTooltipButton; diff --git a/native/chat/text-message-tooltip-modal.react.js b/native/chat/text-message-tooltip-modal.react.js index 5c0a71aa8..f9ff46418 100644 --- a/native/chat/text-message-tooltip-modal.react.js +++ b/native/chat/text-message-tooltip-modal.react.js @@ -1,62 +1,62 @@ // @flow import Clipboard from '@react-native-community/clipboard'; import invariant from 'invariant'; + import { createMessageReply } from 'lib/shared/message-utils'; import type { DispatchFunctions, ActionFunc, BoundServerCall, } from 'lib/utils/action-utils'; import type { InputState } from '../input/input-state'; import { displayActionResultModal } from '../navigation/action-result-modal'; import { createTooltip, tooltipHeight, type TooltipParams, type TooltipRoute, } from '../navigation/tooltip.react'; - import TextMessageTooltipButton from './text-message-tooltip-button.react'; import type { ChatTextMessageInfoItemWithHeight } from './text-message.react'; export type TextMessageTooltipModalParams = TooltipParams<{| +item: ChatTextMessageInfoItemWithHeight, |}>; const confirmCopy = () => displayActionResultModal('copied!'); function onPressCopy(route: TooltipRoute<'TextMessageTooltipModal'>) { Clipboard.setString(route.params.item.messageInfo.text); setTimeout(confirmCopy); } function onPressReply( route: TooltipRoute<'TextMessageTooltipModal'>, dispatchFunctions: DispatchFunctions, bindServerCall: (serverCall: ActionFunc) => BoundServerCall, inputState: ?InputState, ) { invariant( inputState, 'inputState should be set in TextMessageTooltipModal.onPressReply', ); inputState.addReply(createMessageReply(route.params.item.messageInfo.text)); } const spec = { entries: [ { id: 'copy', text: 'Copy', onPress: onPressCopy }, { id: 'reply', text: 'Reply', onPress: onPressReply }, ], }; const TextMessageTooltipModal = createTooltip<'TextMessageTooltipModal'>( TextMessageTooltipButton, spec, ); const textMessageTooltipHeight = tooltipHeight(spec.entries.length); export { TextMessageTooltipModal, textMessageTooltipHeight }; diff --git a/native/chat/text-message.react.js b/native/chat/text-message.react.js index 163fad87a..4b1f5e49a 100644 --- a/native/chat/text-message.react.js +++ b/native/chat/text-message.react.js @@ -1,209 +1,209 @@ // @flow import invariant from 'invariant'; +import * as React from 'react'; +import { View } from 'react-native'; + import { messageKey } from 'lib/shared/message-utils'; import type { TextMessageInfo, LocalMessageInfo, } from 'lib/types/message-types'; import type { ThreadInfo } from 'lib/types/thread-types'; -import * as React from 'react'; -import { View } from 'react-native'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state'; import { MarkdownLinkContext } from '../markdown/markdown-link-context'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context'; import type { NavigationRoute } from '../navigation/route-names'; import { TextMessageTooltipModalRouteName } from '../navigation/route-names'; import type { VerticalBounds } from '../types/layout-types'; - import type { ChatNavigationProp } from './chat.react'; import { ComposedMessage, clusterEndHeight } from './composed-message.react'; import { failedSendHeight } from './failed-send.react'; import { InnerTextMessage } from './inner-text-message.react'; import { authorNameHeight } from './message-header.react'; import textMessageSendFailed from './text-message-send-failed'; import { textMessageTooltipHeight } from './text-message-tooltip-modal.react'; export type ChatTextMessageInfoItemWithHeight = {| itemType: 'message', messageShapeType: 'text', messageInfo: TextMessageInfo, localMessageInfo: ?LocalMessageInfo, threadInfo: ThreadInfo, startsConversation: boolean, startsCluster: boolean, endsCluster: boolean, contentHeight: number, |}; function textMessageItemHeight(item: ChatTextMessageInfoItemWithHeight) { const { messageInfo, contentHeight, startsCluster, endsCluster } = item; const { isViewer } = messageInfo.creator; let height = 5 + contentHeight; // 5 from marginBottom in ComposedMessage if (!isViewer && startsCluster) { height += authorNameHeight; } if (endsCluster) { height += clusterEndHeight; } if (textMessageSendFailed(item)) { height += failedSendHeight; } return height; } type BaseProps = {| ...React.ElementConfig, +item: ChatTextMessageInfoItemWithHeight, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, +focused: boolean, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, |}; type Props = {| ...BaseProps, // withKeyboardState +keyboardState: ?KeyboardState, // withOverlayContext +overlayContext: ?OverlayContextType, // MarkdownLinkContext +linkPressActive: boolean, |}; class TextMessage extends React.PureComponent { message: ?React.ElementRef; render() { const { item, navigation, route, focused, toggleFocus, verticalBounds, keyboardState, overlayContext, linkPressActive, ...viewProps } = this.props; return ( ); } messageRef = (message: ?React.ElementRef) => { this.message = message; }; onPress = () => { if (this.dismissKeyboardIfShowing()) { return; } const { message, props: { verticalBounds, linkPressActive }, } = this; if (!message || !verticalBounds || linkPressActive) { return; } const { focused, toggleFocus, item } = this.props; if (!focused) { toggleFocus(messageKey(item.messageInfo)); } const { overlayContext } = this.props; invariant(overlayContext, 'TextMessage should have OverlayContext'); overlayContext.setScrollBlockingModalStatus('open'); message.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; const messageTop = pageY; const messageBottom = pageY + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const belowMargin = 20; const belowSpace = textMessageTooltipHeight + belowMargin; const { isViewer } = item.messageInfo.creator; const aboveMargin = isViewer ? 30 : 50; const aboveSpace = textMessageTooltipHeight + aboveMargin; let location = 'below', margin = belowMargin; if ( messageBottom + belowSpace > boundsBottom && messageTop - aboveSpace > boundsTop ) { location = 'above'; margin = aboveMargin; } this.props.navigation.navigate({ name: TextMessageTooltipModalRouteName, params: { presentedFrom: this.props.route.key, initialCoordinates: coordinates, verticalBounds, location, margin, item, }, }); }); }; dismissKeyboardIfShowing = () => { const { keyboardState } = this.props; return !!(keyboardState && keyboardState.dismissKeyboardIfShowing()); }; } const ConnectedTextMessage = React.memo( function ConnectedTextMessage(props: BaseProps) { const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); const [linkPressActive, setLinkPressActive] = React.useState(false); const markdownLinkContext = React.useMemo( () => ({ setLinkPressActive, }), [setLinkPressActive], ); return ( ); }, ); export { ConnectedTextMessage as TextMessage, textMessageItemHeight }; diff --git a/native/chat/thread-screen-pruner.react.js b/native/chat/thread-screen-pruner.react.js index e3de0f2a7..23a9fc149 100644 --- a/native/chat/thread-screen-pruner.react.js +++ b/native/chat/thread-screen-pruner.react.js @@ -1,82 +1,83 @@ // @flow -import { threadIsPending } from 'lib/shared/thread-utils'; import * as React from 'react'; import { Alert } from 'react-native'; +import { threadIsPending } from 'lib/shared/thread-utils'; + import { clearThreadsActionType } from '../navigation/action-types'; import { useActiveThread } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import { getStateFromNavigatorRoute, getThreadIDFromRoute, } from '../navigation/navigation-utils'; import type { AppState } from '../redux/redux-setup'; import { useSelector } from '../redux/redux-utils'; const ThreadScreenPruner = React.memo<{||}>(() => { const rawThreadInfos = useSelector( (state: AppState) => state.threadStore.threadInfos, ); const navContext = React.useContext(NavContext); const chatRoute = React.useMemo(() => { if (!navContext) { return null; } const { state } = navContext; const appState = getStateFromNavigatorRoute(state.routes[0]); const tabState = getStateFromNavigatorRoute(appState.routes[0]); return getStateFromNavigatorRoute(tabState.routes[1]); }, [navContext]); const inStackThreadIDs = React.useMemo(() => { const threadIDs = new Set(); if (!chatRoute) { return threadIDs; } for (let route of chatRoute.routes) { const threadID = getThreadIDFromRoute(route); if (threadID && !threadIsPending(threadID)) { threadIDs.add(threadID); } } return threadIDs; }, [chatRoute]); const pruneThreadIDs = React.useMemo(() => { const threadIDs = []; for (let threadID of inStackThreadIDs) { if (!rawThreadInfos[threadID]) { threadIDs.push(threadID); } } return threadIDs; }, [inStackThreadIDs, rawThreadInfos]); const activeThreadID = useActiveThread(); React.useEffect(() => { if (pruneThreadIDs.length === 0 || !navContext) { return; } if (activeThreadID && pruneThreadIDs.includes(activeThreadID)) { Alert.alert( 'Thread invalidated', 'You no longer have permission to view this thread :(', [{ text: 'OK' }], { cancelable: true }, ); } navContext.dispatch({ type: clearThreadsActionType, payload: { threadIDs: pruneThreadIDs }, }); }, [pruneThreadIDs, navContext, activeThreadID]); return null; }); ThreadScreenPruner.displayName = 'ThreadScreenPruner'; export default ThreadScreenPruner; diff --git a/native/chat/thread-settings-button.react.js b/native/chat/thread-settings-button.react.js index 5411bdc81..32f631f63 100644 --- a/native/chat/thread-settings-button.react.js +++ b/native/chat/thread-settings-button.react.js @@ -1,57 +1,57 @@ // @flow -import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; -import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; import * as React from 'react'; import Icon from 'react-native-vector-icons/Ionicons'; +import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; +import { connect } from 'lib/utils/redux-utils'; + import Button from '../components/button.react'; import { ThreadSettingsRouteName } from '../navigation/route-names'; import type { AppState } from '../redux/redux-setup'; import { styleSelector } from '../themes/colors'; - import type { ChatNavigationProp } from './chat.react'; type Props = {| threadInfo: ThreadInfo, navigate: $PropertyType, 'navigate'>, // Redux state styles: typeof styles, |}; class ThreadSettingsButton extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, navigate: PropTypes.func.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, }; render() { return ( ); } onPress = () => { const threadInfo = this.props.threadInfo; this.props.navigate({ name: ThreadSettingsRouteName, params: { threadInfo }, key: `${ThreadSettingsRouteName}${threadInfo.id}`, }); }; } const styles = { button: { color: 'link', paddingHorizontal: 10, }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ styles: stylesSelector(state), }))(ThreadSettingsButton); diff --git a/native/chat/timestamp.react.js b/native/chat/timestamp.react.js index 29f14b699..7df35bdb3 100644 --- a/native/chat/timestamp.react.js +++ b/native/chat/timestamp.react.js @@ -1,45 +1,46 @@ // @flow -import { longAbsoluteDate } from 'lib/utils/date-utils'; import * as React from 'react'; +import { longAbsoluteDate } from 'lib/utils/date-utils'; + import { SingleLine } from '../components/single-line.react'; import { useStyles } from '../themes/colors'; export type DisplayType = 'lowContrast' | 'modal'; type Props = {| +time: number, +display: DisplayType, |}; function Timestamp(props: Props) { const styles = useStyles(unboundStyles); const style = [styles.timestamp]; if (props.display === 'modal') { style.push(styles.modal); } return ( {longAbsoluteDate(props.time).toUpperCase()} ); } const timestampHeight = 26; const unboundStyles = { modal: { // high contrast framed against OverlayNavigator-dimmed background color: 'white', }, timestamp: { alignSelf: 'center', bottom: 0, color: 'listBackgroundTernaryLabel', fontSize: 14, height: timestampHeight, paddingVertical: 3, }, }; export { Timestamp, timestampHeight }; diff --git a/native/components/clearable-text-input.react.ios.js b/native/components/clearable-text-input.react.ios.js index 5eba4fc7d..ad2b4b747 100644 --- a/native/components/clearable-text-input.react.ios.js +++ b/native/components/clearable-text-input.react.ios.js @@ -1,187 +1,186 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { TextInput, View, StyleSheet } from 'react-native'; import type { KeyPressEvent } from '../types/react-native'; - import type { ClearableTextInputProps } from './clearable-text-input'; type State = {| textInputKey: number, |}; class ClearableTextInput extends React.PureComponent< ClearableTextInputProps, State, > { state: State = { textInputKey: 0, }; pendingMessage: ?{| value: string, resolve: (value: string) => void |}; lastKeyPressed: ?string; lastTextInputSent = -1; currentTextInput: ?React.ElementRef; focused = false; sendMessage() { if (this.pendingMessageSent) { return; } const { pendingMessage } = this; invariant(pendingMessage, 'cannot send an empty message'); pendingMessage.resolve(pendingMessage.value); const textInputSent = this.state.textInputKey - 1; if (textInputSent > this.lastTextInputSent) { this.lastTextInputSent = textInputSent; } } get pendingMessageSent() { return this.lastTextInputSent >= this.state.textInputKey - 1; } onOldInputChangeText = (text: string) => { const { pendingMessage, lastKeyPressed } = this; invariant( pendingMessage, 'onOldInputChangeText should have a pendingMessage', ); if ( !this.pendingMessageSent && lastKeyPressed && lastKeyPressed.length > 1 ) { // This represents an autocorrect event on blur pendingMessage.value = text; } this.lastKeyPressed = null; this.sendMessage(); this.updateTextFromOldInput(text); }; updateTextFromOldInput(text: string) { const { pendingMessage } = this; invariant( pendingMessage, 'updateTextFromOldInput should have a pendingMessage', ); const pendingValue = pendingMessage.value; if (!pendingValue || !text.startsWith(pendingValue)) { return; } const newValue = text.substring(pendingValue.length); if (this.props.value === newValue) { return; } this.props.onChangeText(newValue); } onOldInputKeyPress = (event: KeyPressEvent) => { const { key } = event.nativeEvent; if (this.lastKeyPressed && this.lastKeyPressed.length > key.length) { return; } this.lastKeyPressed = key; this.props.onKeyPress && this.props.onKeyPress(event); }; onOldInputBlur = () => { this.sendMessage(); }; onOldInputFocus = () => { // It's possible for the user to press the old input after the new one // appears. We can prevent that with pointerEvents="none", but that causes a // blur event when we set it, which makes the keyboard briefly pop down // before popping back up again when textInputRef is called below. Instead // we try to catch the focus event here and refocus the currentTextInput if (this.currentTextInput) { this.currentTextInput.focus(); } }; textInputRef = (textInput: ?React.ElementRef) => { if (this.focused && textInput) { textInput.focus(); } this.currentTextInput = textInput; this.props.textInputRef(textInput); }; async getValueAndReset(): Promise { const { value } = this.props; this.props.onChangeText(''); if (!this.focused) { return value; } return await new Promise((resolve) => { this.pendingMessage = { value, resolve }; this.setState((prevState) => ({ textInputKey: prevState.textInputKey + 1, })); }); } onFocus = () => { this.focused = true; }; onBlur = () => { this.focused = false; if (this.pendingMessage) { // This is to catch a race condition where somebody hits the send button // and then blurs the TextInput before the textInputKey increment can // rerender this component. With this.focused set to false, the new // TextInput won't focus, and the old TextInput won't blur, which means // nothing will call sendMessage unless we do it right here. this.sendMessage(); } }; render() { const { textInputRef, ...props } = this.props; const textInputs = []; if (this.state.textInputKey > 0) { textInputs.push( , ); } textInputs.push( , ); return {textInputs}; } } const styles = StyleSheet.create({ invisibleTextInput: { opacity: 0, position: 'absolute', }, textInputContainer: { flex: 1, }, }); export default ClearableTextInput; diff --git a/native/components/clearable-text-input.react.js b/native/components/clearable-text-input.react.js index 79f21e18a..e06ab5fd4 100644 --- a/native/components/clearable-text-input.react.js +++ b/native/components/clearable-text-input.react.js @@ -1,76 +1,76 @@ // @flow -import sleep from 'lib/utils/sleep'; import * as React from 'react'; import { TextInput, View, StyleSheet } from 'react-native'; -import { waitForInteractions } from '../utils/timers'; +import sleep from 'lib/utils/sleep'; +import { waitForInteractions } from '../utils/timers'; import type { ClearableTextInputProps } from './clearable-text-input'; class ClearableTextInput extends React.PureComponent { textInput: ?React.ElementRef; lastMessageSent: ?string; queuedResolve: ?() => mixed; onChangeText = (inputText: string) => { let text; if (this.lastMessageSent && inputText.startsWith(this.lastMessageSent)) { text = inputText.substring(this.lastMessageSent.length); } else { text = inputText; this.lastMessageSent = null; } this.props.onChangeText(text); }; getValueAndReset(): Promise { const { value } = this.props; this.lastMessageSent = value; this.props.onChangeText(''); if (this.textInput) { this.textInput.clear(); } return new Promise((resolve) => { this.queuedResolve = async () => { await waitForInteractions(); await sleep(5); resolve(value); }; }); } componentDidUpdate(prevProps: ClearableTextInputProps) { if (!this.props.value && prevProps.value && this.queuedResolve) { const resolve = this.queuedResolve; this.queuedResolve = null; resolve(); } } render() { const { textInputRef, ...props } = this.props; return ( ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; this.props.textInputRef(textInput); }; } const styles = StyleSheet.create({ textInputContainer: { flex: 1, }, }); export default ClearableTextInput; diff --git a/native/components/color-picker.react.js b/native/components/color-picker.react.js index 9361e1dad..2d0e11b4e 100644 --- a/native/components/color-picker.react.js +++ b/native/components/color-picker.react.js @@ -1,661 +1,661 @@ // @flow import invariant from 'invariant'; -import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; import * as React from 'react'; import { View, Image, StyleSheet, I18nManager, PanResponder, ViewPropTypes, Text, Keyboard, } from 'react-native'; import tinycolor from 'tinycolor2'; +import { connect } from 'lib/utils/redux-utils'; + import type { AppState } from '../redux/redux-setup'; import { type Colors, colorsPropType, colorsSelector } from '../themes/colors'; import type { LayoutEvent } from '../types/react-native'; import type { ViewStyle } from '../types/styles'; - import Button from './button.react'; type PanEvent = $ReadOnly<{ nativeEvent: $ReadOnly<{ pageX: number, pageY: number, }>, }>; type HSVColor = {| h: number, s: number, v: number |}; type PickerContainer = React.ElementRef; type Props = {| color?: string | HSVColor, defaultColor?: string, oldColor?: ?string, onColorChange?: (color: HSVColor) => void, onColorSelected?: (color: string) => void, onOldColorSelected?: (color: string) => void, style?: ViewStyle, buttonText: string, oldButtonText: string, // Redux state colors: Colors, |}; type State = {| color: HSVColor, pickerSize: ?number, |}; class ColorPicker extends React.PureComponent { static propTypes = { color: PropTypes.oneOfType([ PropTypes.string, PropTypes.shape({ h: PropTypes.number, s: PropTypes.number, v: PropTypes.number, }), ]), defaultColor: PropTypes.string, oldColor: PropTypes.string, onColorChange: PropTypes.func, onColorSelected: PropTypes.func, onOldColorSelected: PropTypes.func, style: ViewPropTypes.style, buttonText: PropTypes.string, oldButtonText: PropTypes.string, colors: colorsPropType.isRequired, }; static defaultProps = { buttonText: 'Select', oldButtonText: 'Reset', }; _layout = { width: 0, height: 0 }; _pageX = 0; _pageY = 0; _pickerContainer: ?PickerContainer = null; _pickerResponder = null; _changingHColor = false; constructor(props: Props) { super(props); let color; if (props.defaultColor) { color = tinycolor(props.defaultColor).toHsv(); } else if (props.oldColor) { color = tinycolor(props.oldColor).toHsv(); } else { color = { h: 0, s: 1, v: 1 }; } this.state = { color, pickerSize: null }; const handleColorChange = ({ x, y }: { x: number, y: number }) => { if (this._changingHColor) { this._handleHColorChange({ x, y }); } else { this._handleSVColorChange({ x, y }); } }; this._pickerResponder = PanResponder.create({ onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponderCapture: () => true, onMoveShouldSetPanResponder: () => true, onMoveShouldSetPanResponderCapture: () => true, onPanResponderTerminationRequest: () => true, onPanResponderGrant: (evt: PanEvent) => { const x = evt.nativeEvent.pageX; const y = evt.nativeEvent.pageY; const { s, v } = this._computeColorFromTriangle({ x, y }); this._changingHColor = s > 1 || s < 0 || v > 1 || v < 0; handleColorChange({ x, y }); }, onPanResponderMove: (evt: PanEvent) => handleColorChange({ x: evt.nativeEvent.pageX, y: evt.nativeEvent.pageY, }), onPanResponderRelease: () => true, }); } componentDidMount() { Keyboard.dismiss(); } _getColor(): HSVColor { const passedColor = typeof this.props.color === 'string' ? tinycolor(this.props.color).toHsv() : this.props.color; return passedColor || this.state.color; } _onColorSelected = () => { const { onColorSelected } = this.props; const color = tinycolor(this._getColor()).toHexString(); onColorSelected && onColorSelected(color); }; _onOldColorSelected = () => { const { oldColor, onOldColorSelected } = this.props; const color = tinycolor(oldColor); this.setState({ color: color.toHsv() }); onOldColorSelected && onOldColorSelected(color.toHexString()); }; _onSValueChange = (s: number) => { const { h, v } = this._getColor(); this._onColorChange({ h, s, v }); }; _onVValueChange = (v: number) => { const { h, s } = this._getColor(); this._onColorChange({ h, s, v }); }; _onColorChange(color: HSVColor) { this.setState({ color }); if (this.props.onColorChange) { this.props.onColorChange(color); } } _onLayout = (l: LayoutEvent) => { this._layout = l.nativeEvent.layout; const { width, height } = this._layout; const pickerSize = Math.round(Math.min(width, height)); if ( !this.state.pickerSize || Math.abs(this.state.pickerSize - pickerSize) >= 3 ) { this.setState({ pickerSize }); } // We need to get pageX/pageY, ie. the absolute position of the picker on // the screen. This is because PanResponder's relative position information // is double broken (#12591, #15290). Unfortunately, the only way to get // absolute positioning for a View is via measure() after onLayout (#10556). // The setTimeout is necessary to make sure that the ColorPickerModal // completes its slide-in animation before we measure. setTimeout(() => { if (!this._pickerContainer) { return; } this._pickerContainer.measure((x, y, cWidth, cHeight, pageX, pageY) => { const { pickerPadding } = getPickerProperties(pickerSize); this._pageX = pageX; this._pageY = pageY - pickerPadding - 3; }); }, 500); }; _computeHValue(x: number, y: number) { const pickerSize = this.state.pickerSize; invariant( pickerSize !== null && pickerSize !== undefined, 'pickerSize should be set', ); const dx = x - pickerSize / 2; const dy = y - pickerSize / 2; const rad = Math.atan2(dx, dy) + Math.PI + Math.PI / 2; return ((rad * 180) / Math.PI) % 360; } _hValueToRad(deg: number) { const rad = (deg * Math.PI) / 180; return rad - Math.PI - Math.PI / 2; } getColor(): string { return tinycolor(this._getColor()).toHexString(); } _handleHColorChange({ x, y }: { x: number, y: number }) { const { s, v } = this._getColor(); const { pickerSize } = this.state; invariant( pickerSize !== null && pickerSize !== undefined, 'pickerSize should be set', ); const marginLeft = (this._layout.width - pickerSize) / 2; const marginTop = (this._layout.height - pickerSize) / 2; const relativeX = x - this._pageX - marginLeft; const relativeY = y - this._pageY - marginTop; const h = this._computeHValue(relativeX, relativeY); this._onColorChange({ h, s, v }); } _handleSVColorChange({ x, y }: { x: number, y: number }) { const { h, s: rawS, v: rawV } = this._computeColorFromTriangle({ x, y }); const s = Math.min(Math.max(0, rawS), 1); const v = Math.min(Math.max(0, rawV), 1); this._onColorChange({ h, s, v }); } _normalizeTriangleTouch( s: number, v: number, sRatio: number, ): { s: number, v: number } { // relative size to be considered as corner zone const CORNER_ZONE_SIZE = 0.12; // relative triangle margin to be considered as touch in triangle const NORMAL_MARGIN = 0.1; // relative triangle margin to be considered as touch in triangle // in corner zone const CORNER_MARGIN = 0.05; let margin = NORMAL_MARGIN; const posNS = v > 0 ? 1 - (1 - s) * sRatio : 1 - s * sRatio; const negNS = v > 0 ? s * sRatio : (1 - s) * sRatio; // normalized s value according to ratio and s value const ns = s > 1 ? posNS : negNS; const rightCorner = s > 1 - CORNER_ZONE_SIZE && v > 1 - CORNER_ZONE_SIZE; const leftCorner = ns < 0 + CORNER_ZONE_SIZE && v > 1 - CORNER_ZONE_SIZE; const topCorner = ns < 0 + CORNER_ZONE_SIZE && v < 0 + CORNER_ZONE_SIZE; if (rightCorner) { return { s, v }; } if (leftCorner || topCorner) { margin = CORNER_MARGIN; } // color normalization according to margin s = s < 0 && ns > 0 - margin ? 0 : s; s = s > 1 && ns < 1 + margin ? 1 : s; v = v < 0 && v > 0 - margin ? 0 : v; v = v > 1 && v < 1 + margin ? 1 : v; return { s, v }; } /** * Computes s, v from position (x, y). If position is outside of triangle, * it will return invalid values (greater than 1 or lower than 0) */ _computeColorFromTriangle({ x, y }: { x: number, y: number }): HSVColor { const { pickerSize } = this.state; invariant( pickerSize !== null && pickerSize !== undefined, 'pickerSize should be set', ); const { triangleHeight, triangleWidth } = getPickerProperties(pickerSize); const left = pickerSize / 2 - triangleWidth / 2; const top = pickerSize / 2 - (2 * triangleHeight) / 3; // triangle relative coordinates const marginLeft = (this._layout.width - pickerSize) / 2; const marginTop = (this._layout.height - pickerSize) / 2; const relativeX = x - this._pageX - marginLeft - left; const relativeY = y - this._pageY - marginTop - top; // rotation const { h } = this._getColor(); // starting angle is 330 due to comfortable calculation const deg = (h - 330 + 360) % 360; const rad = (deg * Math.PI) / 180; const center = { x: triangleWidth / 2, y: (2 * triangleHeight) / 3, }; const rotated = rotatePoint({ x: relativeX, y: relativeY }, rad, center); const line = (triangleWidth * rotated.y) / triangleHeight; const margin = triangleWidth / 2 - ((triangleWidth / 2) * rotated.y) / triangleHeight; const s = (rotated.x - margin) / line; const v = rotated.y / triangleHeight; // normalize const normalized = this._normalizeTriangleTouch( s, v, line / triangleHeight, ); return { h, s: normalized.s, v: normalized.v }; } render() { const { pickerSize } = this.state; const { style } = this.props; const color = this._getColor(); const tc = tinycolor(color); const selectedColor: string = tc.toHexString(); const isDark: boolean = tc.isDark(); const { modalIosHighlightUnderlay: underlayColor } = this.props.colors; let picker = null; if (pickerSize) { const pickerResponder = this._pickerResponder; invariant(pickerResponder, 'should be set'); const { h } = color; const indicatorColor = tinycolor({ h, s: 1, v: 1 }).toHexString(); const angle = this._hValueToRad(h); const computed = makeComputedStyles({ pickerSize, selectedColor, selectedColorHsv: color, indicatorColor, oldColor: this.props.oldColor, angle, isRTL: I18nManager.isRTL, }); picker = ( ); } let oldColorButton = null; if (this.props.oldColor) { const oldTinyColor = tinycolor(this.props.oldColor); const oldButtonTextStyle = { color: oldTinyColor.isDark() ? 'white' : 'black', }; oldColorButton = ( ); } const colorPreviewsStyle = { height: this.state.pickerSize ? this.state.pickerSize * 0.1 // responsive height : 20, }; const buttonContentsStyle = { backgroundColor: selectedColor, }; const buttonTextStyle = { color: isDark ? 'white' : 'black', }; return ( {picker} {oldColorButton} ); } pickerContainerRef = (pickerContainer: ?PickerContainer) => { this._pickerContainer = pickerContainer; }; } function getPickerProperties(pickerSize) { const indicatorPickerRatio = 42 / 510; // computed from picker image const originalIndicatorSize = indicatorPickerRatio * pickerSize; const indicatorSize = originalIndicatorSize; const pickerPadding = originalIndicatorSize / 3; const triangleSize = pickerSize - 6 * pickerPadding; const triangleRadius = triangleSize / 2; const triangleHeight = (triangleRadius * 3) / 2; // pythagorean theorem const triangleWidth = 2 * triangleRadius * Math.sqrt(3 / 4); return { triangleSize, triangleRadius, triangleHeight, triangleWidth, indicatorPickerRatio, indicatorSize, pickerPadding, }; } const makeComputedStyles = ({ indicatorColor, angle, pickerSize, selectedColorHsv, isRTL, }) => { const { triangleSize, triangleHeight, triangleWidth, indicatorSize, pickerPadding, } = getPickerProperties(pickerSize); /* ===== INDICATOR ===== */ const indicatorRadius = pickerSize / 2 - indicatorSize / 2 - pickerPadding; const mx = pickerSize / 2; const my = pickerSize / 2; const dx = Math.cos(angle) * indicatorRadius; const dy = Math.sin(angle) * indicatorRadius; /* ===== TRIANGLE ===== */ const triangleTop = pickerPadding * 3; const triangleLeft = pickerPadding * 3; const triangleAngle = -angle + Math.PI / 3; /* ===== SV INDICATOR ===== */ const markerColor = 'rgba(0,0,0,0.8)'; const { s, v, h } = selectedColorHsv; const svIndicatorSize = 18; const svY = v * triangleHeight; const margin = triangleWidth / 2 - v * (triangleWidth / 2); const svX = s * (triangleWidth - 2 * margin) + margin; const svIndicatorMarginLeft = (pickerSize - triangleWidth) / 2; const svIndicatorMarginTop = (pickerSize - (4 * triangleHeight) / 3) / 2; // starting angle is 330 due to comfortable calculation const deg = (h - 330 + 360) % 360; const rad = (deg * Math.PI) / 180; const center = { x: pickerSize / 2, y: pickerSize / 2 }; const notRotatedPoint = { x: svIndicatorMarginTop + svY, y: svIndicatorMarginLeft + svX, }; const svIndicatorPoint = rotatePoint(notRotatedPoint, rad, center); const offsetDirection: string = isRTL ? 'right' : 'left'; return { picker: { padding: pickerPadding, width: pickerSize, height: pickerSize, }, pickerIndicator: { top: mx + dx - indicatorSize / 2, [offsetDirection]: my + dy - indicatorSize / 2, width: indicatorSize, height: indicatorSize, transform: [ { rotate: -angle + 'rad', }, ], }, pickerIndicatorTick: { height: indicatorSize / 2, backgroundColor: markerColor, }, svIndicator: { top: svIndicatorPoint.x - svIndicatorSize / 2, [offsetDirection]: svIndicatorPoint.y - svIndicatorSize / 2, width: svIndicatorSize, height: svIndicatorSize, borderRadius: svIndicatorSize / 2, borderColor: markerColor, }, triangleContainer: { width: triangleSize, height: triangleSize, transform: [ { rotate: triangleAngle + 'rad', }, ], top: triangleTop, left: triangleLeft, }, triangleImage: { width: triangleWidth, height: triangleHeight, }, triangleUnderlayingColor: { left: (triangleSize - triangleWidth) / 2, borderLeftWidth: triangleWidth / 2, borderRightWidth: triangleWidth / 2, borderBottomWidth: triangleHeight, borderBottomColor: indicatorColor, }, }; }; type Point = { x: number, y: number }; function rotatePoint( point: Point, angle: number, center: Point = { x: 0, y: 0 }, ) { // translation to origin const transOriginX = point.x - center.x; const transOriginY = point.y - center.y; // rotation around origin const rotatedX = transOriginX * Math.cos(angle) - transOriginY * Math.sin(angle); const rotatedY = transOriginY * Math.cos(angle) + transOriginX * Math.sin(angle); // translate back from origin const normalizedX = rotatedX + center.x; const normalizedY = rotatedY + center.y; return { x: normalizedX, y: normalizedY, }; } const styles = StyleSheet.create({ buttonContents: { borderRadius: 3, flex: 1, padding: 3, }, buttonText: { flex: 1, fontSize: 20, textAlign: 'center', }, colorPreview: { flex: 1, marginHorizontal: 5, }, colorPreviews: { flexDirection: 'row', }, pickerContainer: { alignItems: 'center', flex: 1, justifyContent: 'center', }, pickerImage: { flex: 1, height: null, width: null, }, pickerIndicator: { alignItems: 'center', justifyContent: 'center', position: 'absolute', }, pickerIndicatorTick: { width: 5, }, svIndicator: { borderWidth: 4, position: 'absolute', }, triangleContainer: { alignItems: 'center', position: 'absolute', }, triangleUnderlayingColor: { backgroundColor: 'transparent', borderLeftColor: 'transparent', borderRightColor: 'transparent', borderStyle: 'solid', height: 0, position: 'absolute', top: 0, width: 0, }, }); export default connect((state: AppState) => ({ colors: colorsSelector(state), }))(ColorPicker); diff --git a/native/components/link-button.react.js b/native/components/link-button.react.js index 4a23c3c95..d9c1c2aac 100644 --- a/native/components/link-button.react.js +++ b/native/components/link-button.react.js @@ -1,65 +1,65 @@ // @flow -import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; import * as React from 'react'; import { Text, ViewPropTypes } from 'react-native'; +import { connect } from 'lib/utils/redux-utils'; + import type { AppState } from '../redux/redux-setup'; import { styleSelector } from '../themes/colors'; import type { ViewStyle } from '../types/styles'; - import Button from './button.react'; type Props = { text: string, onPress: () => void, disabled?: boolean, style?: ViewStyle, // Redux state styles: typeof styles, }; class LinkButton extends React.PureComponent { static propTypes = { text: PropTypes.string.isRequired, onPress: PropTypes.func.isRequired, disabled: PropTypes.bool, style: ViewPropTypes.style, styles: PropTypes.objectOf(PropTypes.object).isRequired, }; render() { const disabledStyle = this.props.disabled ? this.props.styles.disabled : null; return ( ); } } const styles = { disabled: { color: 'modalBackgroundSecondaryLabel', }, text: { color: 'link', fontSize: 17, paddingHorizontal: 10, }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ styles: stylesSelector(state), }))(LinkButton); diff --git a/native/components/modal.react.js b/native/components/modal.react.js index 2dba8924c..3dba6f36e 100644 --- a/native/components/modal.react.js +++ b/native/components/modal.react.js @@ -1,86 +1,86 @@ // @flow -import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; import * as React from 'react'; import { View, TouchableWithoutFeedback, ViewPropTypes, StyleSheet, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { connect } from 'lib/utils/redux-utils'; + import type { RootNavigationProp } from '../navigation/root-navigator.react'; import type { AppState } from '../redux/redux-setup'; import { styleSelector } from '../themes/colors'; import type { ViewStyle } from '../types/styles'; - import KeyboardAvoidingView from './keyboard-avoiding-view.react'; type Props = $ReadOnly<{| navigation: RootNavigationProp<>, children: React.Node, containerStyle?: ViewStyle, modalStyle?: ViewStyle, // Redux state styles: typeof styles, |}>; class Modal extends React.PureComponent { static propTypes = { children: PropTypes.node, navigation: PropTypes.shape({ isFocused: PropTypes.func.isRequired, goBackOnce: PropTypes.func.isRequired, }).isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, containerStyle: ViewPropTypes.style, modalStyle: ViewPropTypes.style, }; close = () => { if (this.props.navigation.isFocused()) { this.props.navigation.goBackOnce(); } }; render() { const { containerStyle, modalStyle, children } = this.props; return ( {children} ); } } const styles = { container: { flex: 1, justifyContent: 'center', overflow: 'visible', }, modal: { backgroundColor: 'modalBackground', borderRadius: 5, flex: 1, justifyContent: 'center', marginBottom: 30, marginHorizontal: 15, marginTop: 100, padding: 12, }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ styles: stylesSelector(state), }))(Modal); diff --git a/native/components/pencil-icon.react.js b/native/components/pencil-icon.react.js index e3f2997fa..70e6b0142 100644 --- a/native/components/pencil-icon.react.js +++ b/native/components/pencil-icon.react.js @@ -1,31 +1,32 @@ // @flow -import { connect } from 'lib/utils/redux-utils'; import * as React from 'react'; import { Platform } from 'react-native'; import Icon from 'react-native-vector-icons/FontAwesome'; +import { connect } from 'lib/utils/redux-utils'; + import type { AppState } from '../redux/redux-setup'; import { styleSelector } from '../themes/colors'; type Props = {| // Redux state styles: typeof styles, |}; function PencilIcon(props: Props) { return ; } const styles = { editIcon: { color: 'link', lineHeight: 20, paddingTop: Platform.select({ android: 1, default: 0 }), textAlign: 'right', }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ styles: stylesSelector(state), }))(PencilIcon); diff --git a/native/components/search.react.js b/native/components/search.react.js index ec74acbef..4d5004bf3 100644 --- a/native/components/search.react.js +++ b/native/components/search.react.js @@ -1,134 +1,135 @@ // @flow -import { isLoggedIn } from 'lib/selectors/user-selectors'; -import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; import * as React from 'react'; import { View, ViewPropTypes, TouchableOpacity, TextInput } from 'react-native'; import Icon from 'react-native-vector-icons/FontAwesome'; +import { isLoggedIn } from 'lib/selectors/user-selectors'; +import { connect } from 'lib/utils/redux-utils'; + import type { AppState } from '../redux/redux-setup'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../themes/colors'; import type { ViewStyle } from '../types/styles'; type Props = {| ...React.ElementConfig, searchText: string, onChangeText: (searchText: string) => void, containerStyle?: ViewStyle, textInputRef?: React.Ref, // Redux state colors: Colors, styles: typeof styles, loggedIn: boolean, |}; class Search extends React.PureComponent { static propTypes = { searchText: PropTypes.string.isRequired, onChangeText: PropTypes.func.isRequired, containerStyle: ViewPropTypes.style, textInputRef: PropTypes.func, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, loggedIn: PropTypes.bool.isRequired, }; componentDidUpdate(prevProps: Props) { if (!this.props.loggedIn && prevProps.loggedIn) { this.clearSearch(); } } render() { const { searchText, onChangeText, containerStyle, textInputRef, colors, styles, loggedIn, ...rest } = this.props; const { listSearchIcon: iconColor } = colors; let clearSearchInputIcon = null; if (searchText) { clearSearchInputIcon = ( ); } const textInputProps: React.ElementProps = { style: styles.searchInput, value: searchText, onChangeText: onChangeText, placeholderTextColor: iconColor, returnKeyType: 'go', }; return ( {clearSearchInputIcon} ); } clearSearch = () => { this.props.onChangeText(''); }; } const styles = { search: { alignItems: 'center', backgroundColor: 'listSearchBackground', borderRadius: 6, flexDirection: 'row', paddingLeft: 14, paddingRight: 12, paddingVertical: 6, }, searchInput: { color: 'listForegroundLabel', flex: 1, fontSize: 16, marginLeft: 8, marginVertical: 0, padding: 0, borderBottomColor: 'transparent', }, }; const stylesSelector = styleSelector(styles); const ConnectedSearch = connect((state: AppState) => ({ colors: colorsSelector(state), styles: stylesSelector(state), loggedIn: isLoggedIn(state), }))(Search); type ConnectedProps = $Diff< Props, {| colors: Colors, styles: typeof styles, loggedIn: boolean, |}, >; export default React.forwardRef( function ForwardedConnectedSearch( props: ConnectedProps, ref: React.Ref, ) { return ; }, ); diff --git a/native/components/single-line.react.js b/native/components/single-line.react.js index 86d410e4b..fbe37b19c 100644 --- a/native/components/single-line.react.js +++ b/native/components/single-line.react.js @@ -1,20 +1,21 @@ // @flow -import { firstLine } from 'lib/utils/string-utils'; import * as React from 'react'; import { Text } from 'react-native'; +import { firstLine } from 'lib/utils/string-utils'; + type Props = {| ...React.ElementConfig, children: ?string, |}; function SingleLine(props: Props) { const text = firstLine(props.children); return ( {text} ); } export { SingleLine }; diff --git a/native/components/swipeable.js b/native/components/swipeable.js index 1a4d343d9..8ac52a75d 100644 --- a/native/components/swipeable.js +++ b/native/components/swipeable.js @@ -1,132 +1,132 @@ // @flow -import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; import * as React from 'react'; import { Animated, View } from 'react-native'; import SwipeableComponent from 'react-native-gesture-handler/Swipeable'; +import { connect } from 'lib/utils/redux-utils'; + import type { AppState } from '../redux/redux-setup'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../themes/colors'; - import Button from './button.react'; type Props = { +buttonWidth: number, +rightActions: $ReadOnlyArray<{| +key: string, +onPress: () => mixed, +color: ?string, +content: React.Node, |}>, +onSwipeableRightWillOpen?: () => void, +innerRef: {| current: ?SwipeableComponent, |}, +children?: React.Node, // Redux state +windowWidth: number, +colors: Colors, +styles: typeof styles, ... }; class Swipeable extends React.PureComponent { static propTypes = { buttonWidth: PropTypes.number.isRequired, rightActions: PropTypes.arrayOf( PropTypes.exact({ key: PropTypes.string.isRequired, onPress: PropTypes.func.isRequired, color: PropTypes.string, content: PropTypes.node.isRequired, }), ), onSwipeableRightWillOpen: PropTypes.func, innerRef: PropTypes.exact({ current: PropTypes.instanceOf(SwipeableComponent), }), children: PropTypes.node, windowWidth: PropTypes.number.isRequired, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, }; static defaultProps = { rightActions: [], }; renderRightActions = (progress) => { const actions = this.props.rightActions.map( ({ key, content, color, onPress }, i) => { const translation = progress.interpolate({ inputRange: [0, 1], outputRange: [ (this.props.rightActions.length - i) * this.props.buttonWidth, 0, ], }); return ( ); }, ); return {actions}; }; render() { return ( {this.props.children} ); } } const styles = { action: { height: '100%', alignItems: 'center', justifyContent: 'center', }, actionsContainer: { flexDirection: 'row', }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ windowWidth: state.dimensions.width, colors: colorsSelector(state), styles: stylesSelector(state), }))(Swipeable); diff --git a/native/components/tag-input.react.js b/native/components/tag-input.react.js index 4795955ed..4e1d80b8c 100644 --- a/native/components/tag-input.react.js +++ b/native/components/tag-input.react.js @@ -1,491 +1,492 @@ // @flow import invariant from 'invariant'; -import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; import * as React from 'react'; import { View, Text, TextInput, StyleSheet, TouchableOpacity, TouchableWithoutFeedback, ScrollView, ViewPropTypes, Platform, } from 'react-native'; +import { connect } from 'lib/utils/redux-utils'; + import type { AppState } from '../redux/redux-setup'; import { type Colors, colorsPropType, colorsSelector } from '../themes/colors'; import type { LayoutEvent } from '../types/react-native'; import type { ViewStyle, TextStyle } from '../types/styles'; type Props = {| /** * An array of tags, which can be any type, as long as labelExtractor below * can extract a string from it. */ value: $ReadOnlyArray, /** * A handler to be called when array of tags change. */ onChange: (items: $ReadOnlyArray) => void, /** * Function to extract string value for label from item */ labelExtractor: (tagData: T) => string, /** * The text currently being displayed in the TextInput following the list of * tags. */ text: string, /** * This callback gets called when the user in the TextInput. The caller should * update the text prop when this is called if they want to access input. */ onChangeText: (text: string) => void, /** * If `true`, text and tags are not editable. The default value is `false`. */ disabled?: boolean, /** * Background color of tags */ tagColor?: string, /** * Text color of tags */ tagTextColor?: string, /** * Styling override for container surrounding tag text */ tagContainerStyle?: ViewStyle, /** * Styling override for tag's text component */ tagTextStyle?: TextStyle, /** * Color of text input */ inputColor?: string, /** * Any misc. TextInput props (autoFocus, placeholder, returnKeyType, etc.) */ inputProps?: React.ElementConfig, /** * Min height of the tag input on screen */ minHeight: number, /** * Max height of the tag input on screen (will scroll if max height reached) */ maxHeight: number, /** * Callback that gets passed the new component height when it changes */ onHeightChange?: (height: number) => void, /** * inputWidth if text === "". we want this number explicitly because if we're * forced to measure the component, there can be a short jump between the old * value and the new value, which looks sketchy. */ defaultInputWidth: number, innerRef?: (tagInput: ?TagInput) => void, // Redux state windowWidth: number, colors: Colors, |}; type State = {| wrapperHeight: number, contentHeight: number, wrapperWidth: number, spaceLeft: number, |}; class TagInput extends React.PureComponent, State> { static propTypes = { value: PropTypes.array.isRequired, onChange: PropTypes.func.isRequired, labelExtractor: PropTypes.func.isRequired, text: PropTypes.string.isRequired, onChangeText: PropTypes.func.isRequired, tagColor: PropTypes.string, tagTextColor: PropTypes.string, tagContainerStyle: ViewPropTypes.style, tagTextStyle: Text.propTypes.style, inputColor: PropTypes.string, inputProps: PropTypes.shape(TextInput.propTypes), minHeight: PropTypes.number, maxHeight: PropTypes.number, onHeightChange: PropTypes.func, defaultInputWidth: PropTypes.number, innerRef: PropTypes.func, windowWidth: PropTypes.number.isRequired, colors: colorsPropType.isRequired, }; // scroll to bottom scrollViewHeight = 0; scrollToBottomAfterNextScrollViewLayout = false; // refs tagInput: ?React.ElementRef = null; scrollView: ?React.ElementRef = null; lastChange: ?{| time: number, prevText: string |}; static defaultProps = { minHeight: 30, maxHeight: 75, defaultInputWidth: 90, }; constructor(props: Props) { super(props); this.state = { wrapperHeight: 30, // was wrapperHeight: 36, contentHeight: 0, wrapperWidth: props.windowWidth, spaceLeft: 0, }; } componentDidMount() { if (this.props.innerRef) { this.props.innerRef(this); } } componentWillUnmount() { if (this.props.innerRef) { this.props.innerRef(null); } } static getDerivedStateFromProps(props: Props, state: State) { const wrapperHeight = Math.max( Math.min(props.maxHeight, state.contentHeight), props.minHeight, ); return { wrapperHeight }; } componentDidUpdate(prevProps: Props, prevState: State) { if ( this.props.onHeightChange && this.state.wrapperHeight !== prevState.wrapperHeight ) { this.props.onHeightChange(this.state.wrapperHeight); } } measureWrapper = (event: LayoutEvent) => { const wrapperWidth = event.nativeEvent.layout.width; if (wrapperWidth !== this.state.wrapperWidth) { this.setState({ wrapperWidth }); } }; onChangeText = (text: string) => { this.lastChange = { time: Date.now(), prevText: this.props.text }; this.props.onChangeText(text); }; onBlur = ( event: $ReadOnly<{ nativeEvent: $ReadOnly<{ target: number }> }>, ) => { invariant(Platform.OS === 'ios', 'only iOS gets text on TextInput.onBlur'); const nativeEvent: $ReadOnly<{ target: number, text: string, }> = (event.nativeEvent: any); this.onChangeText(nativeEvent.text); }; onKeyPress = ( event: $ReadOnly<{ nativeEvent: $ReadOnly<{ key: string }> }>, ) => { const { lastChange } = this; let { text } = this.props; if ( Platform.OS === 'android' && lastChange !== null && lastChange !== undefined && Date.now() - lastChange.time < 150 ) { text = lastChange.prevText; } if (text !== '' || event.nativeEvent.key !== 'Backspace') { return; } const tags = [...this.props.value]; tags.pop(); this.props.onChange(tags); this.focus(); }; focus = () => { invariant(this.tagInput, 'should be set'); this.tagInput.focus(); }; removeIndex = (index: number) => { const tags = [...this.props.value]; tags.splice(index, 1); this.props.onChange(tags); }; scrollToBottom = () => { const scrollView = this.scrollView; invariant( scrollView, 'this.scrollView ref should exist before scrollToBottom called', ); scrollView.scrollToEnd(); }; render() { const tagColor = this.props.tagColor || this.props.colors.modalSubtext; const tagTextColor = this.props.tagTextColor || this.props.colors.modalForegroundLabel; const inputColor = this.props.inputColor || this.props.colors.modalForegroundLabel; const placeholderColor = this.props.colors.modalForegroundTertiaryLabel; const tags = this.props.value.map((tag, index) => ( )); let inputWidth; if (this.props.text === '') { inputWidth = this.props.defaultInputWidth; } else if (this.state.spaceLeft >= 100) { inputWidth = this.state.spaceLeft - 10; } else { inputWidth = this.state.wrapperWidth; } const defaultTextInputProps: React.ElementConfig = { blurOnSubmit: false, style: [ styles.textInput, { width: inputWidth, color: inputColor, }, ], autoCapitalize: 'none', autoCorrect: false, placeholder: 'Start typing', placeholderTextColor: placeholderColor, returnKeyType: 'done', keyboardType: 'default', }; const textInputProps: React.ElementConfig = { ...defaultTextInputProps, ...this.props.inputProps, // should not be overridden onKeyPress: this.onKeyPress, value: this.props.text, onBlur: Platform.OS === 'ios' ? this.onBlur : undefined, onChangeText: this.onChangeText, editable: !this.props.disabled, }; return ( {tags} ); } tagInputRef = (tagInput: ?React.ElementRef) => { this.tagInput = tagInput; }; scrollViewRef = (scrollView: ?React.ElementRef) => { this.scrollView = scrollView; }; onScrollViewContentSizeChange = (w: number, h: number) => { const oldContentHeight = this.state.contentHeight; if (h === oldContentHeight) { return; } let callback; if (h > oldContentHeight) { callback = () => { if (this.scrollViewHeight === this.props.maxHeight) { this.scrollToBottom(); } else { this.scrollToBottomAfterNextScrollViewLayout = true; } }; } this.setState({ contentHeight: h }, callback); }; onScrollViewLayout = (event: LayoutEvent) => { this.scrollViewHeight = event.nativeEvent.layout.height; if (this.scrollToBottomAfterNextScrollViewLayout) { this.scrollToBottom(); this.scrollToBottomAfterNextScrollViewLayout = false; } }; onLayoutLastTag = (endPosOfTag: number) => { const margin = 3; const spaceLeft = this.state.wrapperWidth - endPosOfTag - margin - 10; if (spaceLeft !== this.state.spaceLeft) { this.setState({ spaceLeft }); } }; } type TagProps = {| index: number, label: string, isLastTag: boolean, onLayoutLastTag: (endPosOfTag: number) => void, removeIndex: (index: number) => void, tagColor: string, tagTextColor: string, tagContainerStyle?: ViewStyle, tagTextStyle?: TextStyle, disabled?: boolean, |}; class Tag extends React.PureComponent { static propTypes = { index: PropTypes.number.isRequired, label: PropTypes.string.isRequired, isLastTag: PropTypes.bool.isRequired, onLayoutLastTag: PropTypes.func.isRequired, removeIndex: PropTypes.func.isRequired, tagColor: PropTypes.string.isRequired, tagTextColor: PropTypes.string.isRequired, tagContainerStyle: ViewPropTypes.style, tagTextStyle: Text.propTypes.style, }; curPos: ?number = null; componentDidUpdate(prevProps: TagProps) { if ( !prevProps.isLastTag && this.props.isLastTag && this.curPos !== null && this.curPos !== undefined ) { this.props.onLayoutLastTag(this.curPos); } } render() { return ( {this.props.label}  × ); } onPress = () => { this.props.removeIndex(this.props.index); }; onLayoutLastTag = (event: LayoutEvent) => { const layout = event.nativeEvent.layout; this.curPos = layout.width + layout.x; if (this.props.isLastTag) { this.props.onLayoutLastTag(this.curPos); } }; } const styles = StyleSheet.create({ tag: { borderRadius: 2, justifyContent: 'center', marginBottom: 3, marginRight: 3, paddingHorizontal: 6, paddingVertical: 2, }, tagInputContainer: { flex: 1, flexDirection: 'row', flexWrap: 'wrap', }, tagInputContainerScroll: { flex: 1, }, tagText: { fontSize: 16, margin: 0, padding: 0, }, textInput: { borderBottomColor: 'transparent', flex: 0.6, fontSize: 16, height: 24, marginBottom: 3, marginHorizontal: 0, marginTop: 3, padding: 0, }, textInputContainer: {}, wrapper: {}, }); export default connect((state: AppState) => ({ windowWidth: state.dimensions.width, colors: colorsSelector(state), }))(TagInput); diff --git a/native/components/thread-icon.react.js b/native/components/thread-icon.react.js index bbebc0f81..ca31a9e44 100644 --- a/native/components/thread-icon.react.js +++ b/native/components/thread-icon.react.js @@ -1,39 +1,40 @@ // @flow -import { threadTypes, type ThreadType } from 'lib/types/thread-types'; import * as React from 'react'; import { StyleSheet } from 'react-native'; import EntypoIcon from 'react-native-vector-icons/Entypo'; import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; +import { threadTypes, type ThreadType } from 'lib/types/thread-types'; + type Props = {| +threadType: ThreadType, +color: string, |}; function ThreadIcon(props: Props) { const { threadType, color } = props; if (threadType === threadTypes.CHAT_SECRET) { return ; } else if (threadType === threadTypes.SIDEBAR) { return ( ); } else if (threadType === threadTypes.PERSONAL) { return ; } else { return ; } } const styles = StyleSheet.create({ sidebarIcon: { paddingTop: 2, }, }); export default ThreadIcon; diff --git a/native/components/thread-list-thread.react.js b/native/components/thread-list-thread.react.js index a572299c7..dab604df6 100644 --- a/native/components/thread-list-thread.react.js +++ b/native/components/thread-list-thread.react.js @@ -1,83 +1,83 @@ // @flow -import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; -import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; import * as React from 'react'; import { Text, ViewPropTypes } from 'react-native'; +import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; +import { connect } from 'lib/utils/redux-utils'; + import type { AppState } from '../redux/redux-setup'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../themes/colors'; import type { ViewStyle, TextStyle } from '../types/styles'; - import Button from './button.react'; import ColorSplotch from './color-splotch.react'; import { SingleLine } from './single-line.react'; type Props = {| threadInfo: ThreadInfo, onSelect: (threadID: string) => void, style?: ViewStyle, textStyle?: TextStyle, // Redux state colors: Colors, styles: typeof styles, |}; class ThreadListThread extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, onSelect: PropTypes.func.isRequired, style: ViewPropTypes.style, textStyle: Text.propTypes.style, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, }; render() { const { modalIosHighlightUnderlay: underlayColor } = this.props.colors; return ( ); } onSelect = () => { this.props.onSelect(this.props.threadInfo.id); }; } const styles = { button: { alignItems: 'center', flexDirection: 'row', paddingLeft: 13, }, text: { color: 'modalForegroundLabel', fontSize: 16, paddingLeft: 9, paddingRight: 12, paddingVertical: 6, }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ colors: colorsSelector(state), styles: stylesSelector(state), }))(ThreadListThread); diff --git a/native/components/thread-list.react.js b/native/components/thread-list.react.js index 3f6de9aa2..0d4a8ba5f 100644 --- a/native/components/thread-list.react.js +++ b/native/components/thread-list.react.js @@ -1,149 +1,149 @@ // @flow import invariant from 'invariant'; -import SearchIndex from 'lib/shared/search-index'; -import type { ThreadInfo } from 'lib/types/thread-types'; import * as React from 'react'; import { FlatList, TextInput } from 'react-native'; import { createSelector } from 'reselect'; +import SearchIndex from 'lib/shared/search-index'; +import type { ThreadInfo } from 'lib/types/thread-types'; + import { type IndicatorStyle, useStyles, useIndicatorStyle, } from '../themes/colors'; import type { ViewStyle, TextStyle } from '../types/styles'; import { waitForModalInputFocus } from '../utils/timers'; - import Search from './search.react'; import ThreadListThread from './thread-list-thread.react'; type BaseProps = {| +threadInfos: $ReadOnlyArray, +onSelect: (threadID: string) => void, +itemStyle?: ViewStyle, +itemTextStyle?: TextStyle, +searchIndex?: SearchIndex, |}; type Props = {| ...BaseProps, // Redux state +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, |}; type State = {| +searchText: string, +searchResults: Set, |}; type PropsAndState = {| ...Props, ...State |}; class ThreadList extends React.PureComponent { state: State = { searchText: '', searchResults: new Set(), }; textInput: ?React.ElementRef; listDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfos, (propsAndState: PropsAndState) => propsAndState.searchText, (propsAndState: PropsAndState) => propsAndState.searchResults, (propsAndState: PropsAndState) => propsAndState.itemStyle, (propsAndState: PropsAndState) => propsAndState.itemTextStyle, ( threadInfos: $ReadOnlyArray, text: string, searchResults: Set, ) => text ? threadInfos.filter((threadInfo) => searchResults.has(threadInfo.id)) : // We spread to make sure the result of this selector updates when // any input param (namely itemStyle or itemTextStyle) changes [...threadInfos], ); get listData() { return this.listDataSelector({ ...this.props, ...this.state }); } render() { let searchBar = null; if (this.props.searchIndex) { searchBar = ( ); } return ( {searchBar} ); } static keyExtractor(threadInfo: ThreadInfo) { return threadInfo.id; } renderItem = (row: { item: ThreadInfo }) => { return ( ); }; static getItemLayout(data: ?$ReadOnlyArray, index: number) { return { length: 24, offset: 24 * index, index }; } onChangeSearchText = (searchText: string) => { invariant(this.props.searchIndex, 'should be set'); const results = this.props.searchIndex.getSearchResults(searchText); this.setState({ searchText, searchResults: new Set(results) }); }; searchRef = async (textInput: ?React.ElementRef) => { this.textInput = textInput; if (!textInput) { return; } await waitForModalInputFocus(); if (this.textInput) { this.textInput.focus(); } }; } const unboundStyles = { search: { marginBottom: 8, }, }; export default React.memo(function ConnectedThreadList( props: BaseProps, ) { const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); return ( ); }); diff --git a/native/components/thread-visibility.react.js b/native/components/thread-visibility.react.js index f55f4bcff..9dd71bebb 100644 --- a/native/components/thread-visibility.react.js +++ b/native/components/thread-visibility.react.js @@ -1,44 +1,45 @@ // @flow -import { threadTypes, type ThreadType } from 'lib/types/thread-types'; import * as React from 'react'; import { View, Text, StyleSheet } from 'react-native'; import Icon from 'react-native-vector-icons/MaterialIcons'; +import { threadTypes, type ThreadType } from 'lib/types/thread-types'; + type Props = {| +threadType: ThreadType, +color: string, |}; function ThreadVisibility(props: Props) { const { threadType, color } = props; const visLabelStyle = [styles.visibilityLabel, { color }]; if (threadType === threadTypes.CHAT_SECRET) { return ( Secret ); } else { return ( Open ); } } const styles = StyleSheet.create({ container: { alignItems: 'center', flexDirection: 'row', }, visibilityLabel: { fontSize: 16, fontWeight: 'bold', paddingLeft: 4, }, }); export default ThreadVisibility; diff --git a/native/components/user-list-user.react.js b/native/components/user-list-user.react.js index a3a49ec6d..815aefacf 100644 --- a/native/components/user-list-user.react.js +++ b/native/components/user-list-user.react.js @@ -1,105 +1,105 @@ // @flow -import { type UserListItem, userListItemPropType } from 'lib/types/user-types'; -import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; import * as React from 'react'; import { Text, Platform, Alert } from 'react-native'; +import { type UserListItem, userListItemPropType } from 'lib/types/user-types'; +import { connect } from 'lib/utils/redux-utils'; + import type { AppState } from '../redux/redux-setup'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../themes/colors'; import type { TextStyle } from '../types/styles'; - import Button from './button.react'; import { SingleLine } from './single-line.react'; // eslint-disable-next-line no-unused-vars const getUserListItemHeight = (item: UserListItem) => { // TODO consider parent thread notice return Platform.OS === 'ios' ? 31.5 : 33.5; }; type Props = {| userInfo: UserListItem, onSelect: (userID: string) => void, textStyle?: TextStyle, // Redux state colors: Colors, styles: typeof styles, |}; class UserListUser extends React.PureComponent { static propTypes = { userInfo: userListItemPropType.isRequired, onSelect: PropTypes.func.isRequired, textStyle: Text.propTypes.style, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, }; render() { const { userInfo } = this.props; let notice = null; if (userInfo.notice) { notice = {userInfo.notice}; } const { modalIosHighlightUnderlay: underlayColor } = this.props.colors; return ( ); } onSelect = () => { const { userInfo } = this.props; if (!userInfo.alertText) { this.props.onSelect(userInfo.id); return; } Alert.alert('Not a friend', userInfo.alertText, [{ text: 'OK' }], { cancelable: true, }); }; } const styles = { button: { alignItems: 'center', flexDirection: 'row', justifyContent: 'space-between', }, notice: { color: 'modalForegroundSecondaryLabel', fontStyle: 'italic', }, text: { color: 'modalForegroundLabel', flex: 1, fontSize: 16, paddingHorizontal: 12, paddingVertical: 6, }, }; const stylesSelector = styleSelector(styles); const WrappedUserListUser = connect((state: AppState) => ({ colors: colorsSelector(state), styles: stylesSelector(state), }))(UserListUser); export { WrappedUserListUser as UserListUser, getUserListItemHeight }; diff --git a/native/components/user-list.react.js b/native/components/user-list.react.js index ceb4b1af2..92d3de6bb 100644 --- a/native/components/user-list.react.js +++ b/native/components/user-list.react.js @@ -1,71 +1,71 @@ // @flow -import type { UserListItem } from 'lib/types/user-types'; import _sum from 'lodash/fp/sum'; import React from 'react'; import { FlatList } from 'react-native'; +import type { UserListItem } from 'lib/types/user-types'; + import { type IndicatorStyle, useIndicatorStyle } from '../themes/colors'; import type { TextStyle } from '../types/styles'; - import { UserListUser, getUserListItemHeight } from './user-list-user.react'; type BaseProps = {| +userInfos: $ReadOnlyArray, +onSelect: (userID: string) => void, +itemTextStyle?: TextStyle, |}; type Props = { ...BaseProps, // Redux state +indicatorStyle: IndicatorStyle, }; class UserList extends React.PureComponent { render() { return ( ); } static keyExtractor(userInfo: UserListItem) { return userInfo.id; } renderItem = (row: { item: UserListItem }) => { return ( ); }; static getItemLayout(data: ?$ReadOnlyArray, index: number) { if (!data) { return { length: 0, offset: 0, index }; } const offset = _sum( data.filter((_, i) => i < index).map(getUserListItemHeight), ); const item = data[index]; const length = item ? getUserListItemHeight(item) : 0; return { length, offset, index }; } } export default React.memo(function ConnectedUserList( props: BaseProps, ) { const indicatorStyle = useIndicatorStyle(); return ; }); diff --git a/native/config.js b/native/config.js index 8c8194683..e4074b476 100644 --- a/native/config.js +++ b/native/config.js @@ -1,19 +1,20 @@ // @flow -import { registerConfig } from 'lib/utils/config'; import { Platform } from 'react-native'; +import { registerConfig } from 'lib/utils/config'; + import { resolveInvalidatedCookie } from './account/resolve-invalidated-cookie'; import { persistConfig, codeVersion } from './redux/persist'; registerConfig({ resolveInvalidatedCookie, setCookieOnRequest: true, setSessionIDOnRequest: false, calendarRangeInactivityLimit: 15 * 60 * 1000, platformDetails: { platform: Platform.OS, codeVersion, stateVersion: persistConfig.version, }, }); diff --git a/native/connected-status-bar.react.js b/native/connected-status-bar.react.js index c0d4f16db..598aa319c 100644 --- a/native/connected-status-bar.react.js +++ b/native/connected-status-bar.react.js @@ -1,59 +1,60 @@ // @flow -import { globalLoadingStatusSelector } from 'lib/selectors/loading-selectors'; -import type { LoadingStatus } from 'lib/types/loading-types'; -import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; import React from 'react'; import { StatusBar, Platform } from 'react-native'; +import { globalLoadingStatusSelector } from 'lib/selectors/loading-selectors'; +import type { LoadingStatus } from 'lib/types/loading-types'; +import { connect } from 'lib/utils/redux-utils'; + import type { AppState } from './redux/redux-setup'; import { type GlobalTheme, globalThemePropType } from './types/themes'; type Props = {| barStyle?: 'default' | 'light-content' | 'dark-content', animated?: boolean, // Redux state globalLoadingStatus: LoadingStatus, activeTheme: ?GlobalTheme, |}; class ConnectedStatusBar extends React.PureComponent { static propTypes = { barStyle: PropTypes.oneOf(['default', 'light-content', 'dark-content']), animated: PropTypes.bool, globalLoadingStatus: PropTypes.string.isRequired, activeTheme: globalThemePropType, }; render() { const { barStyle: inBarStyle, activeTheme, globalLoadingStatus, ...statusBarProps } = this.props; let barStyle = inBarStyle; if (!barStyle) { if (Platform.OS !== 'android' && this.props.activeTheme === 'light') { barStyle = 'dark-content'; } else { barStyle = 'light-content'; } } const fetchingSomething = this.props.globalLoadingStatus === 'loading'; return ( ); } } export default connect((state: AppState) => ({ globalLoadingStatus: globalLoadingStatusSelector(state), activeTheme: state.globalThemeInfo.activeTheme, }))(ConnectedStatusBar); diff --git a/native/crash.react.js b/native/crash.react.js index e3ac6cf55..0da524bc2 100644 --- a/native/crash.react.js +++ b/native/crash.react.js @@ -1,267 +1,268 @@ // @flow import Clipboard from '@react-native-community/clipboard'; import invariant from 'invariant'; +import _shuffle from 'lodash/fp/shuffle'; +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { + View, + Text, + Platform, + StyleSheet, + ScrollView, + ActivityIndicator, +} from 'react-native'; +import ExitApp from 'react-native-exit-app'; +import Icon from 'react-native-vector-icons/FontAwesome'; + import { sendReportActionTypes, sendReport } from 'lib/actions/report-actions'; import { logOutActionTypes, logOut } from 'lib/actions/user-actions'; import { preRequestUserStateSelector } from 'lib/selectors/account-selectors'; import type { LogOutResult } from 'lib/types/account-types'; import type { ErrorData } from 'lib/types/report-types'; import { type ClientReportCreationRequest, type ReportCreationResponse, reportTypes, } from 'lib/types/report-types'; import { type PreRequestUserState, preRequestUserStatePropType, } from 'lib/types/session-types'; import { actionLogger } from 'lib/utils/action-logger'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import { connect } from 'lib/utils/redux-utils'; import { sanitizeAction, sanitizeState } from 'lib/utils/sanitization'; import sleep from 'lib/utils/sleep'; -import _shuffle from 'lodash/fp/shuffle'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import { - View, - Text, - Platform, - StyleSheet, - ScrollView, - ActivityIndicator, -} from 'react-native'; -import ExitApp from 'react-native-exit-app'; -import Icon from 'react-native-vector-icons/FontAwesome'; import Button from './components/button.react'; import ConnectedStatusBar from './connected-status-bar.react'; import { persistConfig, codeVersion } from './redux/persist'; import type { AppState } from './redux/redux-setup'; import { wipeAndExit } from './utils/crash-utils'; const errorTitles = ['Oh no!!', 'Womp womp womp...']; type Props = { errorData: $ReadOnlyArray, // Redux state preRequestUserState: PreRequestUserState, // Redux dispatch functions dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs sendReport: ( request: ClientReportCreationRequest, ) => Promise, logOut: (preRequestUserState: PreRequestUserState) => Promise, }; type State = {| errorReportID: ?string, doneWaiting: boolean, |}; class Crash extends React.PureComponent { static propTypes = { errorData: PropTypes.arrayOf( PropTypes.shape({ error: PropTypes.object.isRequired, info: PropTypes.shape({ componentStack: PropTypes.string.isRequired, }), }), ).isRequired, preRequestUserState: preRequestUserStatePropType.isRequired, dispatchActionPromise: PropTypes.func.isRequired, sendReport: PropTypes.func.isRequired, logOut: PropTypes.func.isRequired, }; errorTitle = _shuffle(errorTitles)[0]; state: State = { errorReportID: null, doneWaiting: false, }; componentDidMount() { this.props.dispatchActionPromise(sendReportActionTypes, this.sendReport()); this.timeOut(); } async timeOut() { // If it takes more than 10s, give up and let the user exit await sleep(10000); this.setState({ doneWaiting: true }); } render() { const errorText = [...this.props.errorData] .reverse() .map((errorData) => errorData.error.message) .join('\n'); let crashID; if (this.state.errorReportID) { crashID = ( {this.state.errorReportID} ); } else { crashID = ; } const buttonStyle = { opacity: Number(this.state.doneWaiting) }; return ( {this.errorTitle} I'm sorry, but the app crashed. Crash report ID: {crashID} Here's some text that's probably not helpful: {errorText} ); } async sendReport() { const result = await this.props.sendReport({ type: reportTypes.ERROR, platformDetails: { platform: Platform.OS, codeVersion, stateVersion: persistConfig.version, }, errors: this.props.errorData.map((data) => ({ errorMessage: data.error.message, stack: data.error.stack, componentStack: data.info && data.info.componentStack, })), preloadedState: sanitizeState(actionLogger.preloadedState), currentState: sanitizeState(actionLogger.currentState), actions: actionLogger.actions.map(sanitizeAction), }); this.setState({ errorReportID: result.id, doneWaiting: true, }); } onPressKill = () => { if (!this.state.doneWaiting) { return; } ExitApp.exitApp(); }; onPressWipe = async () => { if (!this.state.doneWaiting) { return; } this.props.dispatchActionPromise(logOutActionTypes, this.logOutAndExit()); }; async logOutAndExit() { try { await this.props.logOut(this.props.preRequestUserState); } catch (e) {} await wipeAndExit(); } onCopyCrashReportID = () => { invariant(this.state.errorReportID, 'should be set'); Clipboard.setString(this.state.errorReportID); }; } const styles = StyleSheet.create({ button: { backgroundColor: '#FF0000', borderRadius: 5, marginHorizontal: 10, paddingHorizontal: 10, paddingVertical: 5, }, buttonText: { color: 'white', fontSize: 16, }, buttons: { flexDirection: 'row', }, container: { alignItems: 'center', backgroundColor: 'white', flex: 1, justifyContent: 'center', }, copyCrashReportIDButtonText: { color: '#036AFF', }, crashID: { flexDirection: 'row', paddingBottom: 12, paddingTop: 2, }, crashIDText: { color: 'black', paddingRight: 8, }, errorReportID: { flexDirection: 'row', height: 20, }, errorReportIDText: { color: 'black', paddingRight: 8, }, errorText: { color: 'black', fontFamily: Platform.select({ ios: 'Menlo', default: 'monospace', }), }, header: { color: 'black', fontSize: 24, paddingBottom: 24, }, scrollView: { flex: 1, marginBottom: 24, marginTop: 12, maxHeight: 200, paddingHorizontal: 50, }, text: { color: 'black', paddingBottom: 12, }, }); export default connect( (state: AppState) => ({ preRequestUserState: preRequestUserStateSelector(state), }), { sendReport, logOut }, )(Crash); diff --git a/native/error-boundary.react.js b/native/error-boundary.react.js index ca6be40c6..3376b40e3 100644 --- a/native/error-boundary.react.js +++ b/native/error-boundary.react.js @@ -1,56 +1,57 @@ // @flow -import type { ErrorInfo, ErrorData } from 'lib/types/report-types'; import * as React from 'react'; +import type { ErrorInfo, ErrorData } from 'lib/types/report-types'; + import Crash from './crash.react'; let instance = null; const defaultHandler = global.ErrorUtils.getGlobalHandler(); global.ErrorUtils.setGlobalHandler((error) => { defaultHandler(error); if (instance) { instance.reportError(error); } }); type Props = {| children: React.Node, |}; type State = {| errorData: $ReadOnlyArray, |}; class ErrorBoundary extends React.PureComponent { state: State = { errorData: [], }; componentDidMount() { instance = this; } componentWillUnmount() { instance = null; } componentDidCatch(error: Error, info: ErrorInfo) { this.setState((prevState) => ({ errorData: [...prevState.errorData, { error, info }], })); } reportError(error: Error) { this.setState((prevState) => ({ errorData: [...prevState.errorData, { error }], })); } render() { if (this.state.errorData.length > 0) { return ; } return this.props.children; } } export default ErrorBoundary; diff --git a/native/input/input-state-container.react.js b/native/input/input-state-container.react.js index 4c163a3f0..5acf18259 100644 --- a/native/input/input-state-container.react.js +++ b/native/input/input-state-container.react.js @@ -1,1075 +1,1075 @@ // @flow import invariant from 'invariant'; +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { Platform } from 'react-native'; +import * as Upload from 'react-native-background-upload'; +import { createSelector } from 'reselect'; + import { createLocalMessageActionType, sendMultimediaMessageActionTypes, sendMultimediaMessage, sendTextMessageActionTypes, sendTextMessage, } from 'lib/actions/message-actions'; import { queueReportsActionType } from 'lib/actions/report-actions'; import { uploadMultimedia, updateMultimediaMessageMediaActionType, type MultimediaUploadCallbacks, type MultimediaUploadExtras, } from 'lib/actions/upload-actions'; import { pathFromURI } from 'lib/media/file-utils'; import { videoDurationLimit } from 'lib/media/video-utils'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors'; import { createMediaMessageInfo } from 'lib/shared/message-utils'; import { isStaff } from 'lib/shared/user-utils'; import type { UploadMultimediaResult, Media, NativeMediaSelection, MediaMissionResult, MediaMission, } from 'lib/types/media-types'; import { messageTypes, type RawMessageInfo, type RawMultimediaMessageInfo, type SendMessageResult, type SendMessagePayload, type RawImagesMessageInfo, type RawMediaMessageInfo, type RawTextMessageInfo, } from 'lib/types/message-types'; import { type MediaMissionReportCreationRequest, reportTypes, } from 'lib/types/report-types'; import type { DispatchActionPayload, DispatchActionPromise, } from 'lib/utils/action-utils'; import { getConfig } from 'lib/utils/config'; import { getMessageForException, cloneError } from 'lib/utils/errors'; import type { FetchJSONOptions, FetchJSONServerResponse, } from 'lib/utils/fetch-json'; import { connect } from 'lib/utils/redux-utils'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import { Platform } from 'react-native'; -import * as Upload from 'react-native-background-upload'; -import { createSelector } from 'reselect'; import { disposeTempFile } from '../media/file-utils'; import { processMedia } from '../media/media-utils'; import { displayActionResultModal } from '../navigation/action-result-modal'; import type { AppState } from '../redux/redux-setup'; - import { InputStateContext, type PendingMultimediaUploads, } from './input-state'; let nextLocalUploadID = 0; function getNewLocalID() { return `localUpload${nextLocalUploadID++}`; } type SelectionWithID = {| selection: NativeMediaSelection, localID: string, |}; type CompletedUploads = { [localMessageID: string]: ?Set }; type Props = {| children: React.Node, // Redux state viewerID: ?string, nextLocalID: number, messageStoreMessages: { [id: string]: RawMessageInfo }, ongoingMessageCreation: boolean, hasWiFi: boolean, // Redux dispatch functions dispatchActionPayload: DispatchActionPayload, dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs uploadMultimedia: ( multimedia: Object, extras: MultimediaUploadExtras, callbacks: MultimediaUploadCallbacks, ) => Promise, sendMultimediaMessage: ( threadID: string, localID: string, mediaIDs: $ReadOnlyArray, ) => Promise, sendTextMessage: ( threadID: string, localID: string, text: string, ) => Promise, |}; type State = {| pendingUploads: PendingMultimediaUploads, |}; class InputStateContainer extends React.PureComponent { static propTypes = { children: PropTypes.node.isRequired, viewerID: PropTypes.string, nextLocalID: PropTypes.number.isRequired, messageStoreMessages: PropTypes.object.isRequired, ongoingMessageCreation: PropTypes.bool.isRequired, hasWiFi: PropTypes.bool.isRequired, dispatchActionPayload: PropTypes.func.isRequired, dispatchActionPromise: PropTypes.func.isRequired, uploadMultimedia: PropTypes.func.isRequired, sendMultimediaMessage: PropTypes.func.isRequired, sendTextMessage: PropTypes.func.isRequired, }; state: State = { pendingUploads: {}, }; sendCallbacks: Array<() => void> = []; activeURIs = new Map(); replyCallbacks: Array<(message: string) => void> = []; static getCompletedUploads(props: Props, state: State): CompletedUploads { const completedUploads = {}; for (let localMessageID in state.pendingUploads) { const messagePendingUploads = state.pendingUploads[localMessageID]; const rawMessageInfo = props.messageStoreMessages[localMessageID]; if (!rawMessageInfo) { continue; } invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); const completed = []; let allUploadsComplete = true; for (let localUploadID in messagePendingUploads) { let media; for (let singleMedia of rawMessageInfo.media) { if (singleMedia.id === localUploadID) { media = singleMedia; break; } } if (media) { allUploadsComplete = false; } else { completed.push(localUploadID); } } if (allUploadsComplete) { completedUploads[localMessageID] = null; } else if (completed.length > 0) { completedUploads[localMessageID] = new Set(completed); } } return completedUploads; } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.viewerID !== prevProps.viewerID) { this.setState({ pendingUploads: {} }); return; } const currentlyComplete = InputStateContainer.getCompletedUploads( this.props, this.state, ); const previouslyComplete = InputStateContainer.getCompletedUploads( prevProps, prevState, ); const newPendingUploads = {}; let pendingUploadsChanged = false; const readyMessageIDs = []; for (let localMessageID in this.state.pendingUploads) { const messagePendingUploads = this.state.pendingUploads[localMessageID]; const prevRawMessageInfo = prevProps.messageStoreMessages[localMessageID]; const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; const completedUploadIDs = currentlyComplete[localMessageID]; const previouslyCompletedUploadIDs = previouslyComplete[localMessageID]; if (!rawMessageInfo && prevRawMessageInfo) { pendingUploadsChanged = true; continue; } else if (completedUploadIDs === null) { // All of this message's uploads have been completed newPendingUploads[localMessageID] = {}; if (previouslyCompletedUploadIDs !== null) { readyMessageIDs.push(localMessageID); pendingUploadsChanged = true; } continue; } else if (!completedUploadIDs) { // Nothing has been completed newPendingUploads[localMessageID] = messagePendingUploads; continue; } const newUploads = {}; let uploadsChanged = false; for (let localUploadID in messagePendingUploads) { if (!completedUploadIDs.has(localUploadID)) { newUploads[localUploadID] = messagePendingUploads[localUploadID]; } else if ( !previouslyCompletedUploadIDs || !previouslyCompletedUploadIDs.has(localUploadID) ) { uploadsChanged = true; } } if (uploadsChanged) { pendingUploadsChanged = true; newPendingUploads[localMessageID] = newUploads; } else { newPendingUploads[localMessageID] = messagePendingUploads; } } if (pendingUploadsChanged) { this.setState({ pendingUploads: newPendingUploads }); } for (let localMessageID of readyMessageIDs) { const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; if (!rawMessageInfo) { continue; } invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); this.dispatchMultimediaMessageAction(rawMessageInfo); } } dispatchMultimediaMessageAction(messageInfo: RawMultimediaMessageInfo) { this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(messageInfo), undefined, messageInfo, ); } async sendMultimediaMessageAction( messageInfo: RawMultimediaMessageInfo, ): Promise { const { localID, threadID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const mediaIDs = []; for (let { id } of messageInfo.media) { mediaIDs.push(id); } try { const result = await this.props.sendMultimediaMessage( threadID, localID, mediaIDs, ); return { localID, serverID: result.id, threadID, time: result.time, }; } catch (e) { const copy = cloneError(e); copy.localID = localID; copy.threadID = threadID; throw copy; } } inputStateSelector = createSelector( (state: State) => state.pendingUploads, (pendingUploads: PendingMultimediaUploads) => ({ pendingUploads, sendTextMessage: this.sendTextMessage, sendMultimediaMessage: this.sendMultimediaMessage, addReply: this.addReply, addReplyListener: this.addReplyListener, removeReplyListener: this.removeReplyListener, messageHasUploadFailure: this.messageHasUploadFailure, retryMultimediaMessage: this.retryMultimediaMessage, registerSendCallback: this.registerSendCallback, unregisterSendCallback: this.unregisterSendCallback, uploadInProgress: this.uploadInProgress, reportURIDisplayed: this.reportURIDisplayed, }), ); uploadInProgress = () => { if (this.props.ongoingMessageCreation) { return true; } for (let localMessageID in this.state.pendingUploads) { const messagePendingUploads = this.state.pendingUploads[localMessageID]; for (let localUploadID in messagePendingUploads) { const { failed } = messagePendingUploads[localUploadID]; if (!failed) { return true; } } } return false; }; sendTextMessage = (messageInfo: RawTextMessageInfo) => { this.sendCallbacks.forEach((callback) => callback()); this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction(messageInfo), undefined, messageInfo, ); }; async sendTextMessageAction( messageInfo: RawTextMessageInfo, ): Promise { try { const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const result = await this.props.sendTextMessage( messageInfo.threadID, localID, messageInfo.text, ); return { localID, serverID: result.id, threadID: messageInfo.threadID, time: result.time, }; } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; throw copy; } } sendMultimediaMessage = async ( threadID: string, selections: $ReadOnlyArray, ) => { this.sendCallbacks.forEach((callback) => callback()); const localMessageID = `local${this.props.nextLocalID}`; const selectionsWithIDs = selections.map((selection) => ({ selection, localID: getNewLocalID(), })); const pendingUploads = {}; for (let { localID } of selectionsWithIDs) { pendingUploads[localID] = { failed: null, progressPercent: 0, }; } this.setState( (prevState) => { return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: pendingUploads, }, }; }, () => { const creatorID = this.props.viewerID; invariant(creatorID, 'need viewer ID in order to send a message'); const media = selectionsWithIDs.map(({ localID, selection }) => { if (selection.step === 'photo_library') { return { id: localID, uri: selection.uri, type: 'photo', dimensions: selection.dimensions, localMediaSelection: selection, }; } else if (selection.step === 'photo_capture') { return { id: localID, uri: selection.uri, type: 'photo', dimensions: selection.dimensions, localMediaSelection: selection, }; } else if (selection.step === 'video_library') { return { id: localID, uri: selection.uri, type: 'video', dimensions: selection.dimensions, localMediaSelection: selection, loop: false, }; } invariant(false, `invalid selection ${JSON.stringify(selection)}`); }); const messageInfo = createMediaMessageInfo({ localID: localMessageID, threadID, creatorID, media, }); this.props.dispatchActionPayload( createLocalMessageActionType, messageInfo, ); }, ); await this.uploadFiles(localMessageID, selectionsWithIDs); }; async uploadFiles( localMessageID: string, selectionsWithIDs: $ReadOnlyArray, ) { const results = await Promise.all( selectionsWithIDs.map((selectionWithID) => this.uploadFile(localMessageID, selectionWithID), ), ); const errors = [...new Set(results.filter(Boolean))]; if (errors.length > 0) { displayActionResultModal(errors.join(', ') + ' :('); } } async uploadFile( localMessageID: string, selectionWithID: SelectionWithID, ): Promise { const { localID, selection } = selectionWithID; const start = selection.sendTime; let steps = [selection], serverID, userTime, errorMessage; let reportPromise; const finish = async (result: MediaMissionResult) => { if (reportPromise) { const finalSteps = await reportPromise; steps.push(...finalSteps); } const totalTime = Date.now() - start; userTime = userTime ? userTime : totalTime; this.queueMediaMissionReport( { localID, localMessageID, serverID }, { steps, result, totalTime, userTime }, ); return errorMessage; }; const fail = (message: string) => { errorMessage = message; this.handleUploadFailure(localMessageID, localID, message); userTime = Date.now() - start; }; let processedMedia; const processingStart = Date.now(); try { const processMediaReturn = processMedia( selection, this.mediaProcessConfig(), ); reportPromise = processMediaReturn.reportPromise; const processResult = await processMediaReturn.resultPromise; if (!processResult.success) { const message = processResult.reason === 'video_too_long' ? `can't do vids longer than ${videoDurationLimit}min` : 'processing failed'; fail(message); return await finish(processResult); } processedMedia = processResult; } catch (e) { fail('processing failed'); return await finish({ success: false, reason: 'processing_exception', time: Date.now() - processingStart, exceptionMessage: getMessageForException(e), }); } const { uploadURI, shouldDisposePath, filename, mime } = processedMedia; const { hasWiFi } = this.props; const uploadStart = Date.now(); let uploadExceptionMessage, uploadResult, mediaMissionResult; try { uploadResult = await this.props.uploadMultimedia( { uri: uploadURI, name: filename, type: mime }, { ...processedMedia.dimensions, loop: processedMedia.loop }, { onProgress: (percent: number) => this.setProgress(localMessageID, localID, percent), uploadBlob: this.uploadBlob, }, ); mediaMissionResult = { success: true }; } catch (e) { uploadExceptionMessage = getMessageForException(e); fail('upload failed'); mediaMissionResult = { success: false, reason: 'http_upload_failed', exceptionMessage: uploadExceptionMessage, }; } if (uploadResult) { const { id, mediaType, uri, dimensions, loop } = uploadResult; serverID = id; this.props.dispatchActionPayload(updateMultimediaMessageMediaActionType, { messageID: localMessageID, currentMediaID: localID, mediaUpdate: { id, type: mediaType, uri, dimensions, localMediaSelection: undefined, loop, }, }); userTime = Date.now() - start; } const processSteps = await reportPromise; reportPromise = null; steps.push(...processSteps); steps.push({ step: 'upload', success: !!uploadResult, exceptionMessage: uploadExceptionMessage, time: Date.now() - uploadStart, inputFilename: filename, outputMediaType: uploadResult && uploadResult.mediaType, outputURI: uploadResult && uploadResult.uri, outputDimensions: uploadResult && uploadResult.dimensions, outputLoop: uploadResult && uploadResult.loop, hasWiFi, }); const promises = []; if (shouldDisposePath) { // If processMedia needed to do any transcoding before upload, we dispose // of the resultant temporary file here. Since the transcoded temporary // file is only used for upload, we can dispose of it after processMedia // (reportPromise) and the upload are complete promises.push( (async () => { const disposeStep = await disposeTempFile(shouldDisposePath); steps.push(disposeStep); })(), ); } if (selection.captureTime) { // If we are uploading a newly captured photo, we dispose of the original // file here. Note that we try to save photo captures to the camera roll // if we have permission. Even if we fail, this temporary file isn't // visible to the user, so there's no point in keeping it around. Since // the initial URI is used in rendering paths, we have to wait for it to // be replaced with the remote URI before we can dispose const captureURI = selection.uri; promises.push( (async () => { const { steps: clearSteps, result: capturePath, } = await this.waitForCaptureURIUnload(captureURI); steps.push(...clearSteps); if (!capturePath) { return; } const disposeStep = await disposeTempFile(capturePath); steps.push(disposeStep); })(), ); } await Promise.all(promises); return await finish(mediaMissionResult); } mediaProcessConfig() { const { hasWiFi, viewerID } = this.props; if (__DEV__ || (viewerID && isStaff(viewerID))) { return { hasWiFi, finalFileHeaderCheck: true, }; } return { hasWiFi }; } setProgress( localMessageID: string, localUploadID: string, progressPercent: number, ) { this.setState((prevState) => { const pendingUploads = prevState.pendingUploads[localMessageID]; if (!pendingUploads) { return {}; } const pendingUpload = pendingUploads[localUploadID]; if (!pendingUpload) { return {}; } const newOutOfHundred = Math.floor(progressPercent * 100); const oldOutOfHundred = Math.floor(pendingUpload.progressPercent * 100); if (newOutOfHundred === oldOutOfHundred) { return {}; } const newPendingUploads = { ...pendingUploads, [localUploadID]: { ...pendingUpload, progressPercent, }, }; return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: newPendingUploads, }, }; }); } uploadBlob = async ( url: string, cookie: ?string, sessionID: ?string, input: { [key: string]: mixed }, options?: ?FetchJSONOptions, ): Promise => { invariant( cookie && input.multimedia && Array.isArray(input.multimedia) && input.multimedia.length === 1 && input.multimedia[0] && typeof input.multimedia[0] === 'object', 'InputStateContainer.uploadBlob sent incorrect input', ); const { uri, name, type } = input.multimedia[0]; invariant( typeof uri === 'string' && typeof name === 'string' && typeof type === 'string', 'InputStateContainer.uploadBlob sent incorrect input', ); const parameters = {}; parameters.cookie = cookie; parameters.filename = name; for (let key in input) { if ( key === 'multimedia' || key === 'cookie' || key === 'sessionID' || key === 'filename' ) { continue; } const value = input[key]; invariant( typeof value === 'string', 'blobUpload calls can only handle string values for non-multimedia keys', ); parameters[key] = value; } let path = uri; if (Platform.OS === 'android') { const resolvedPath = pathFromURI(uri); if (resolvedPath) { path = resolvedPath; } } const uploadID = await Upload.startUpload({ url, path, type: 'multipart', headers: { Accept: 'application/json', }, field: 'multimedia', parameters, }); if (options && options.abortHandler) { options.abortHandler(() => { Upload.cancelUpload(uploadID); }); } return await new Promise((resolve, reject) => { Upload.addListener('error', uploadID, (data) => { reject(data.error); }); Upload.addListener('cancelled', uploadID, () => { reject(new Error('request aborted')); }); Upload.addListener('completed', uploadID, (data) => { resolve(JSON.parse(data.responseBody)); }); if (options && options.onProgress) { const { onProgress } = options; Upload.addListener('progress', uploadID, (data) => onProgress(data.progress / 100), ); } }); }; handleUploadFailure( localMessageID: string, localUploadID: string, message: string, ) { this.setState((prevState) => { const uploads = prevState.pendingUploads[localMessageID]; const upload = uploads[localUploadID]; if (!upload) { // The upload has been completed before it failed return {}; } return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: { ...uploads, [localUploadID]: { ...upload, failed: message, progressPercent: 0, }, }, }, }; }); } queueMediaMissionReport( ids: {| localID: string, localMessageID: string, serverID: ?string |}, mediaMission: MediaMission, ) { const report: MediaMissionReportCreationRequest = { type: reportTypes.MEDIA_MISSION, time: Date.now(), platformDetails: getConfig().platformDetails, mediaMission, uploadServerID: ids.serverID, uploadLocalID: ids.localID, messageLocalID: ids.localMessageID, }; this.props.dispatchActionPayload(queueReportsActionType, { reports: [report], }); } messageHasUploadFailure = (localMessageID: string) => { const pendingUploads = this.state.pendingUploads[localMessageID]; if (!pendingUploads) { return false; } for (let localUploadID in pendingUploads) { const { failed } = pendingUploads[localUploadID]; if (failed) { return true; } } return false; }; addReply = (message: string) => { this.replyCallbacks.forEach((addReplyCallback) => addReplyCallback(message), ); }; addReplyListener = (callbackReply: (message: string) => void) => { this.replyCallbacks.push(callbackReply); }; removeReplyListener = (callbackReply: (message: string) => void) => { this.replyCallbacks = this.replyCallbacks.filter( (candidate) => candidate !== callbackReply, ); }; retryMultimediaMessage = async (localMessageID: string) => { const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; invariant(rawMessageInfo, `rawMessageInfo ${localMessageID} should exist`); let pendingUploads = this.state.pendingUploads[localMessageID]; if (!pendingUploads) { pendingUploads = {}; } const now = Date.now(); const updateMedia = (media: $ReadOnlyArray): T[] => media.map((singleMedia) => { const oldID = singleMedia.id; if (!oldID.startsWith('localUpload')) { // already uploaded return singleMedia; } if (pendingUploads[oldID] && !pendingUploads[oldID].failed) { // still being uploaded return singleMedia; } // If we have an incomplete upload that isn't in pendingUploads, that // indicates the app has restarted. We'll reassign a new localID to // avoid collisions. Note that this isn't necessary for the message ID // since the localID reducer prevents collisions there const id = pendingUploads[oldID] ? oldID : getNewLocalID(); const oldSelection = singleMedia.localMediaSelection; invariant( oldSelection, 'localMediaSelection should be set on locally created Media', ); const retries = oldSelection.retries ? oldSelection.retries + 1 : 1; // We switch for Flow let selection; if (oldSelection.step === 'photo_capture') { selection = { ...oldSelection, sendTime: now, retries }; } else if (oldSelection.step === 'photo_library') { selection = { ...oldSelection, sendTime: now, retries }; } else { selection = { ...oldSelection, sendTime: now, retries }; } if (singleMedia.type === 'photo') { return { type: 'photo', ...singleMedia, id, localMediaSelection: selection, }; } else { return { type: 'video', ...singleMedia, id, localMediaSelection: selection, }; } }); let newRawMessageInfo; // This conditional is for Flow if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { newRawMessageInfo = ({ ...rawMessageInfo, time: now, media: updateMedia(rawMessageInfo.media), }: RawMediaMessageInfo); } else if (rawMessageInfo.type === messageTypes.IMAGES) { newRawMessageInfo = ({ ...rawMessageInfo, time: now, media: updateMedia(rawMessageInfo.media), }: RawImagesMessageInfo); } else { invariant(false, `rawMessageInfo ${localMessageID} should be multimedia`); } const incompleteMedia: Media[] = []; for (let singleMedia of newRawMessageInfo.media) { if (singleMedia.id.startsWith('localUpload')) { incompleteMedia.push(singleMedia); } } if (incompleteMedia.length === 0) { this.dispatchMultimediaMessageAction(newRawMessageInfo); this.setState((prevState) => ({ pendingUploads: { ...prevState.pendingUploads, [localMessageID]: {}, }, })); return; } const retryMedia = incompleteMedia.filter( ({ id }) => !pendingUploads[id] || pendingUploads[id].failed, ); if (retryMedia.length === 0) { // All media are already in the process of being uploaded return; } // We're not actually starting the send here, // we just use this action to update the message in Redux this.props.dispatchActionPayload( sendMultimediaMessageActionTypes.started, newRawMessageInfo, ); // We clear out the failed status on individual media here, // which makes the UI show pending status instead of error messages for (let { id } of retryMedia) { pendingUploads[id] = { failed: null, progressPercent: 0, }; } this.setState((prevState) => ({ pendingUploads: { ...prevState.pendingUploads, [localMessageID]: pendingUploads, }, })); const selectionsWithIDs = retryMedia.map((singleMedia) => { const { id, localMediaSelection } = singleMedia; invariant( localMediaSelection, 'localMediaSelection should be set on locally created Media', ); return { selection: localMediaSelection, localID: id }; }); await this.uploadFiles(localMessageID, selectionsWithIDs); }; registerSendCallback = (callback: () => void) => { this.sendCallbacks.push(callback); }; unregisterSendCallback = (callback: () => void) => { this.sendCallbacks = this.sendCallbacks.filter( (candidate) => candidate !== callback, ); }; reportURIDisplayed = (uri: string, loaded: boolean) => { const prevActiveURI = this.activeURIs.get(uri); const curCount = prevActiveURI && prevActiveURI.count; const prevCount = curCount ? curCount : 0; const count = loaded ? prevCount + 1 : prevCount - 1; const prevOnClear = prevActiveURI && prevActiveURI.onClear; const onClear = prevOnClear ? prevOnClear : []; const activeURI = { count, onClear }; if (count) { this.activeURIs.set(uri, activeURI); return; } this.activeURIs.delete(uri); for (let callback of onClear) { callback(); } }; waitForCaptureURIUnload(uri: string) { const start = Date.now(); const path = pathFromURI(uri); if (!path) { return Promise.resolve({ result: null, steps: [ { step: 'wait_for_capture_uri_unload', success: false, time: Date.now() - start, uri, }, ], }); } const getResult = () => ({ result: path, steps: [ { step: 'wait_for_capture_uri_unload', success: true, time: Date.now() - start, uri, }, ], }); const activeURI = this.activeURIs.get(uri); if (!activeURI) { return Promise.resolve(getResult()); } return new Promise((resolve) => { const finish = () => resolve(getResult()); const newActiveURI = { ...activeURI, onClear: [...activeURI.onClear, finish], }; this.activeURIs.set(uri, newActiveURI); }); } render() { const inputState = this.inputStateSelector(this.state); return ( {this.props.children} ); } } const mediaCreationLoadingStatusSelector = createLoadingStatusSelector( sendMultimediaMessageActionTypes, ); const textCreationLoadingStatusSelector = createLoadingStatusSelector( sendTextMessageActionTypes, ); export default connect( (state: AppState) => ({ viewerID: state.currentUserInfo && state.currentUserInfo.id, nextLocalID: state.nextLocalID, messageStoreMessages: state.messageStore.messages, ongoingMessageCreation: combineLoadingStatuses( mediaCreationLoadingStatusSelector(state), textCreationLoadingStatusSelector(state), ) === 'loading', hasWiFi: state.connectivity.hasWiFi, }), { uploadMultimedia, sendMultimediaMessage, sendTextMessage }, )(InputStateContainer); diff --git a/native/input/input-state.js b/native/input/input-state.js index d4a8fcae4..155012e92 100644 --- a/native/input/input-state.js +++ b/native/input/input-state.js @@ -1,72 +1,73 @@ // @flow -import type { NativeMediaSelection } from 'lib/types/media-types'; -import type { RawTextMessageInfo } from 'lib/types/message-types'; import PropTypes from 'prop-types'; import * as React from 'react'; +import type { NativeMediaSelection } from 'lib/types/media-types'; +import type { RawTextMessageInfo } from 'lib/types/message-types'; + export type PendingMultimediaUpload = {| failed: ?string, progressPercent: number, |}; const pendingMultimediaUploadPropType = PropTypes.shape({ failed: PropTypes.string, progressPercent: PropTypes.number.isRequired, }); export type MessagePendingUploads = { [localUploadID: string]: PendingMultimediaUpload, }; const messagePendingUploadsPropType = PropTypes.objectOf( pendingMultimediaUploadPropType, ); export type PendingMultimediaUploads = { [localMessageID: string]: MessagePendingUploads, }; const pendingMultimediaUploadsPropType = PropTypes.objectOf( messagePendingUploadsPropType, ); export type InputState = {| pendingUploads: PendingMultimediaUploads, sendTextMessage: (messageInfo: RawTextMessageInfo) => void, sendMultimediaMessage: ( threadID: string, selections: $ReadOnlyArray, ) => Promise, addReply: (text: string) => void, addReplyListener: ((message: string) => void) => void, removeReplyListener: ((message: string) => void) => void, messageHasUploadFailure: (localMessageID: string) => boolean, retryMultimediaMessage: (localMessageID: string) => Promise, registerSendCallback: (() => void) => void, unregisterSendCallback: (() => void) => void, uploadInProgress: () => boolean, reportURIDisplayed: (uri: string, loaded: boolean) => void, |}; const inputStatePropType = PropTypes.shape({ pendingUploads: pendingMultimediaUploadsPropType.isRequired, sendTextMessage: PropTypes.func.isRequired, sendMultimediaMessage: PropTypes.func.isRequired, addReply: PropTypes.func.isRequired, addReplyListener: PropTypes.func.isRequired, removeReplyListener: PropTypes.func.isRequired, messageHasUploadFailure: PropTypes.func.isRequired, retryMultimediaMessage: PropTypes.func.isRequired, uploadInProgress: PropTypes.func.isRequired, reportURIDisplayed: PropTypes.func.isRequired, }); const InputStateContext = React.createContext(null); export { messagePendingUploadsPropType, pendingMultimediaUploadPropType, inputStatePropType, InputStateContext, }; diff --git a/native/keyboard/keyboard-input-host.react.js b/native/keyboard/keyboard-input-host.react.js index a20e5e03e..70e5bf33b 100644 --- a/native/keyboard/keyboard-input-host.react.js +++ b/native/keyboard/keyboard-input-host.react.js @@ -1,131 +1,131 @@ // @flow import invariant from 'invariant'; -import type { MediaLibrarySelection } from 'lib/types/media-types'; import PropTypes from 'prop-types'; import * as React from 'react'; import { TextInput } from 'react-native'; import { KeyboardAccessoryView } from 'react-native-keyboard-input'; +import type { MediaLibrarySelection } from 'lib/types/media-types'; + import { type InputState, inputStatePropType, InputStateContext, } from '../input/input-state'; import { mediaGalleryKeyboardName } from '../media/media-gallery-keyboard.react'; import { activeMessageListSelector } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import { useStyles } from '../themes/colors'; - import { type KeyboardState, keyboardStatePropType, KeyboardContext, } from './keyboard-state'; type BaseProps = {| +textInputRef?: React.ElementRef, |}; type Props = {| ...BaseProps, // Redux state +styles: typeof unboundStyles, +activeMessageList: ?string, // withKeyboardState +keyboardState: ?KeyboardState, // withInputState +inputState: ?InputState, |}; class KeyboardInputHost extends React.PureComponent { static propTypes = { textInputRef: PropTypes.object, styles: PropTypes.objectOf(PropTypes.object).isRequired, activeMessageList: PropTypes.string, keyboardState: keyboardStatePropType, inputState: inputStatePropType, }; componentDidUpdate(prevProps: Props) { if ( prevProps.activeMessageList && this.props.activeMessageList !== prevProps.activeMessageList ) { this.hideMediaGallery(); } } static mediaGalleryOpen(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.mediaGalleryOpen); } render() { const kbComponent = KeyboardInputHost.mediaGalleryOpen(this.props) ? mediaGalleryKeyboardName : null; return ( ); } onMediaGalleryItemSelected = ( keyboardName: string, selections: $ReadOnlyArray, ) => { const { keyboardState } = this.props; invariant( keyboardState, 'keyboardState should be set in onMediaGalleryItemSelected', ); keyboardState.dismissKeyboard(); const mediaGalleryThreadID = keyboardState.getMediaGalleryThreadID(); if (mediaGalleryThreadID === null || mediaGalleryThreadID === undefined) { return; } const { inputState } = this.props; invariant( inputState, 'inputState should be set in onMediaGalleryItemSelected', ); inputState.sendMultimediaMessage(mediaGalleryThreadID, selections); }; hideMediaGallery = () => { const { keyboardState } = this.props; invariant(keyboardState, 'keyboardState should be initialized'); keyboardState.hideMediaGallery(); }; } const unboundStyles = { kbInitialProps: { backgroundColor: 'listBackground', }, }; export default React.memo(function ConnectedKeyboardInputHost( props: BaseProps, ) { const inputState = React.useContext(InputStateContext); const keyboardState = React.useContext(KeyboardContext); const navContext = React.useContext(NavContext); const styles = useStyles(unboundStyles); const activeMessageList = activeMessageListSelector(navContext); return ( ); }); diff --git a/native/keyboard/keyboard-state-container.react.js b/native/keyboard/keyboard-state-container.react.js index 1431d6256..61240e0ea 100644 --- a/native/keyboard/keyboard-state-container.react.js +++ b/native/keyboard/keyboard-state-container.react.js @@ -1,157 +1,157 @@ // @flow -import sleep from 'lib/utils/sleep'; import PropTypes from 'prop-types'; import * as React from 'react'; import { Platform } from 'react-native'; import { KeyboardUtils } from 'react-native-keyboard-input'; +import sleep from 'lib/utils/sleep'; + import { tabBarAnimationDuration } from '../navigation/tab-bar.react'; import { waitForInteractions } from '../utils/timers'; - import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, androidKeyboardResizesFrame, } from './keyboard'; import KeyboardInputHost from './keyboard-input-host.react'; import { KeyboardContext } from './keyboard-state'; type Props = {| children: React.Node, |}; type State = {| systemKeyboardShowing: boolean, mediaGalleryOpen: boolean, mediaGalleryThreadID: ?string, renderKeyboardInputHost: boolean, |}; class KeyboardStateContainer extends React.PureComponent { static propTypes = { children: PropTypes.node.isRequired, }; state: State = { systemKeyboardShowing: false, mediaGalleryOpen: false, mediaGalleryThreadID: null, renderKeyboardInputHost: false, }; keyboardShowListener: ?Object; keyboardDismissListener: ?Object; keyboardShow = () => { this.setState({ systemKeyboardShowing: true }); }; keyboardDismiss = () => { this.setState({ systemKeyboardShowing: false }); }; componentDidMount() { this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); this.keyboardDismissListener = addKeyboardDismissListener( this.keyboardDismiss, ); } componentWillUnmount() { if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardDismissListener) { removeKeyboardListener(this.keyboardDismissListener); this.keyboardDismissListener = null; } } componentDidUpdate(prevProps: Props, prevState: State) { if (Platform.OS !== 'android' || androidKeyboardResizesFrame) { return; } if (this.state.mediaGalleryOpen && !prevState.mediaGalleryOpen) { (async () => { await sleep(tabBarAnimationDuration); await waitForInteractions(); this.setState({ renderKeyboardInputHost: true }); })(); } } dismissKeyboard = () => { KeyboardUtils.dismiss(); this.hideMediaGallery(); }; dismissKeyboardIfShowing = () => { if (!this.keyboardShowing) { return false; } this.dismissKeyboard(); return true; }; get keyboardShowing() { const { systemKeyboardShowing, mediaGalleryOpen } = this.state; return systemKeyboardShowing || mediaGalleryOpen; } showMediaGallery = (threadID: string) => { const updates: $Shape = { mediaGalleryOpen: true, mediaGalleryThreadID: threadID, }; if (androidKeyboardResizesFrame) { updates.renderKeyboardInputHost = true; } this.setState(updates); }; hideMediaGallery = () => { this.setState({ mediaGalleryOpen: false, mediaGalleryThreadID: null, renderKeyboardInputHost: false, }); }; getMediaGalleryThreadID = () => this.state.mediaGalleryThreadID; render() { const { systemKeyboardShowing, mediaGalleryOpen, renderKeyboardInputHost, } = this.state; const { keyboardShowing, dismissKeyboard, dismissKeyboardIfShowing, showMediaGallery, hideMediaGallery, getMediaGalleryThreadID, } = this; const keyboardState = { keyboardShowing, dismissKeyboard, dismissKeyboardIfShowing, systemKeyboardShowing, mediaGalleryOpen, showMediaGallery, hideMediaGallery, getMediaGalleryThreadID, }; const keyboardInputHost = renderKeyboardInputHost ? ( ) : null; return ( {this.props.children} {keyboardInputHost} ); } } export default KeyboardStateContainer; diff --git a/native/lifecycle/lifecycle-handler.react.js b/native/lifecycle/lifecycle-handler.react.js index 41bc1d701..819a0f731 100644 --- a/native/lifecycle/lifecycle-handler.react.js +++ b/native/lifecycle/lifecycle-handler.react.js @@ -1,44 +1,44 @@ // @flow +import * as React from 'react'; +import { useDispatch } from 'react-redux'; + import { backgroundActionType, foregroundActionType, } from 'lib/reducers/foreground-reducer'; -import * as React from 'react'; -import { useDispatch } from 'react-redux'; import { appBecameInactive } from '../redux/redux-setup'; - import { addLifecycleListener } from './lifecycle'; const LifecycleHandler = React.memo<{||}>(() => { const dispatch = useDispatch(); const lastStateRef = React.useRef(); const onLifecycleChange = React.useCallback( (nextState: ?string) => { if (!nextState || nextState === 'unknown') { return; } const lastState = lastStateRef.current; lastStateRef.current = nextState; if (lastState === 'background' && nextState === 'active') { dispatch({ type: foregroundActionType, payload: null }); } else if (lastState !== 'background' && nextState === 'background') { dispatch({ type: backgroundActionType, payload: null }); appBecameInactive(); } }, [lastStateRef, dispatch], ); React.useEffect(() => { const subscription = addLifecycleListener(onLifecycleChange); return () => subscription.remove(); }, [onLifecycleChange]); return null; }); LifecycleHandler.displayName = 'LifecycleHandler'; export default LifecycleHandler; diff --git a/native/markdown/markdown-link.react.js b/native/markdown/markdown-link.react.js index 536caf484..08eae4340 100644 --- a/native/markdown/markdown-link.react.js +++ b/native/markdown/markdown-link.react.js @@ -1,52 +1,53 @@ // @flow -import { normalizeURL } from 'lib/utils/url-utils'; import * as React from 'react'; import { Text, Linking, Alert } from 'react-native'; +import { normalizeURL } from 'lib/utils/url-utils'; + import { MarkdownLinkContext } from './markdown-link-context'; function useDisplayLinkPrompt(inputURL: string) { const markdownLinkContext = React.useContext(MarkdownLinkContext); const setLinkPressActive = markdownLinkContext?.setLinkPressActive; const onDismiss = React.useCallback(() => { setLinkPressActive?.(false); }, [setLinkPressActive]); const url = normalizeURL(inputURL); const onConfirm = React.useCallback(() => { onDismiss(); Linking.openURL(url); }, [url, onDismiss]); let displayURL = url.substring(0, 64); if (url.length > displayURL.length) { displayURL += '…'; } return React.useCallback(() => { setLinkPressActive && setLinkPressActive(true); Alert.alert( 'External link', `You sure you want to open this link?\n\n${displayURL}`, [ { text: 'Cancel', style: 'cancel', onPress: onDismiss }, { text: 'Open', onPress: onConfirm }, ], { cancelable: true, onDismiss }, ); }, [setLinkPressActive, displayURL, onConfirm, onDismiss]); } type TextProps = React.ElementConfig; type Props = {| +target: string, +children: React.Node, ...TextProps, |}; function MarkdownLink(props: Props) { const { target, ...rest } = props; const onPressLink = useDisplayLinkPrompt(target); return ; } export default MarkdownLink; diff --git a/native/markdown/markdown.react.js b/native/markdown/markdown.react.js index d00ab9ebb..408b0d268 100644 --- a/native/markdown/markdown.react.js +++ b/native/markdown/markdown.react.js @@ -1,76 +1,76 @@ // @flow import invariant from 'invariant'; -import { onlyEmojiRegex } from 'lib/shared/emojis'; import * as React from 'react'; import { View, Text, StyleSheet } from 'react-native'; import type { TextStyle as FlattenedTextStyle } from 'react-native/Libraries/StyleSheet/StyleSheet'; import * as SimpleMarkdown from 'simple-markdown'; -import type { TextStyle } from '../types/styles'; +import { onlyEmojiRegex } from 'lib/shared/emojis'; +import type { TextStyle } from '../types/styles'; import type { MarkdownRules } from './rules.react'; type Props = {| +style: TextStyle, +children: string, +rules: MarkdownRules, |}; function Markdown(props: Props) { const { style, children, rules } = props; const { simpleMarkdownRules, emojiOnlyFactor, container } = rules; const parser = React.useMemo( () => SimpleMarkdown.parserFor(simpleMarkdownRules), [simpleMarkdownRules], ); const ast = React.useMemo( () => parser(children, { disableAutoBlockNewlines: true, container }), [parser, children, container], ); const output = React.useMemo( () => SimpleMarkdown.outputFor(simpleMarkdownRules, 'react'), [simpleMarkdownRules], ); const emojiOnly = React.useMemo(() => { if (emojiOnlyFactor === null || emojiOnlyFactor === undefined) { return false; } return onlyEmojiRegex.test(children); }, [emojiOnlyFactor, children]); const textStyle = React.useMemo(() => { if ( !emojiOnly || emojiOnlyFactor === null || emojiOnlyFactor === undefined ) { return style; } const flattened: FlattenedTextStyle = (StyleSheet.flatten(style): any); invariant( flattened && typeof flattened === 'object', `Markdown component should have style`, ); const { fontSize } = flattened; invariant( fontSize, `style prop should have fontSize if using emojiOnlyFactor`, ); return { ...flattened, fontSize: fontSize * emojiOnlyFactor }; }, [emojiOnly, style, emojiOnlyFactor]); const renderedOutput = React.useMemo( () => output(ast, { textStyle, container }), [ast, output, textStyle, container], ); if (container === 'Text') { return {renderedOutput}; } else { return {renderedOutput}; } } export default Markdown; diff --git a/native/markdown/rules.react.js b/native/markdown/rules.react.js index 643cd56f3..87f3e6c11 100644 --- a/native/markdown/rules.react.js +++ b/native/markdown/rules.react.js @@ -1,374 +1,374 @@ // @flow -import { relativeMemberInfoSelectorForMembersOfThread } from 'lib/selectors/user-selectors'; -import * as SharedMarkdown from 'lib/shared/markdown'; -import type { RelativeMemberInfo } from 'lib/types/thread-types'; import _memoize from 'lodash/memoize'; import * as React from 'react'; import { Text, View } from 'react-native'; import * as SimpleMarkdown from 'simple-markdown'; -import { useSelector } from '../redux/redux-utils'; +import { relativeMemberInfoSelectorForMembersOfThread } from 'lib/selectors/user-selectors'; +import * as SharedMarkdown from 'lib/shared/markdown'; +import type { RelativeMemberInfo } from 'lib/types/thread-types'; +import { useSelector } from '../redux/redux-utils'; import MarkdownLink from './markdown-link.react'; import { getMarkdownStyles } from './styles'; export type MarkdownRules = {| +simpleMarkdownRules: SimpleMarkdown.ParserRules, +emojiOnlyFactor: ?number, // We need to use a Text container for Entry because it needs to match up // exactly with TextInput. However, if we use a Text container, we can't // support styles for things like blockQuote, which rely on rendering as a // View, and Views can't be nested inside Texts without explicit height and // width +container: 'View' | 'Text', |}; // Entry requires a seamless transition between Markdown and TextInput // components, so we can't do anything that would change the position of text const inlineMarkdownRules: (boolean) => MarkdownRules = _memoize( (useDarkStyle) => { const styles = getMarkdownStyles(useDarkStyle ? 'dark' : 'light'); const simpleMarkdownRules = { // Matches 'https://google.com' during parse phase and returns a 'link' node url: { ...SimpleMarkdown.defaultRules.url, // simple-markdown is case-sensitive, but we don't want to be match: SimpleMarkdown.inlineRegex(SharedMarkdown.urlRegex), }, // Matches '[Google](https://google.com)' during parse phase and handles // rendering all 'link' nodes, including for 'autolink' and 'url' link: { ...SimpleMarkdown.defaultRules.link, match: () => null, react( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) { return ( {output(node.content, state)} ); }, }, // Each line gets parsed into a 'paragraph' node. The AST returned by the // parser will be an array of one or more 'paragraph' nodes paragraph: { ...SimpleMarkdown.defaultRules.paragraph, // simple-markdown's default RegEx collapses multiple newlines into one. // We want to keep the newlines, but when rendering within a View, we // strip just one trailing newline off, since the View adds vertical // spacing between its children match: (source: string, state: SimpleMarkdown.State) => { if (state.inline) { return null; } else if (state.container === 'View') { return SharedMarkdown.paragraphStripTrailingNewlineRegex.exec( source, ); } else { return SharedMarkdown.paragraphRegex.exec(source); } }, parse( capture: SimpleMarkdown.Capture, parse: SimpleMarkdown.Parser, state: SimpleMarkdown.State, ) { let content = capture[1]; if (state.container === 'View') { // React Native renders empty lines with less height. We want to // preserve the newline characters, so we replace empty lines with a // single space content = content.replace(/^$/m, ' '); } return { content: SimpleMarkdown.parseInline(parse, content, state), }; }, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, // This is the leaf node in the AST returned by the parse phase text: SimpleMarkdown.defaultRules.text, }; return { simpleMarkdownRules, emojiOnlyFactor: null, container: 'Text', }; }, ); // We allow the most markdown features for TextMessage, which doesn't have the // same requirements as Entry const fullMarkdownRules: (boolean) => MarkdownRules = _memoize( (useDarkStyle) => { const styles = getMarkdownStyles(useDarkStyle ? 'dark' : 'light'); const inlineRules = inlineMarkdownRules(useDarkStyle); const simpleMarkdownRules = { ...inlineRules.simpleMarkdownRules, // Matches '' during parse phase and returns a 'link' // node autolink: SimpleMarkdown.defaultRules.autolink, // Matches '[Google](https://google.com)' during parse phase and handles // rendering all 'link' nodes, including for 'autolink' and 'url' link: { ...inlineRules.simpleMarkdownRules.link, match: SimpleMarkdown.defaultRules.link.match, }, mailto: SimpleMarkdown.defaultRules.mailto, em: { ...SimpleMarkdown.defaultRules.em, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, strong: { ...SimpleMarkdown.defaultRules.strong, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, u: { ...SimpleMarkdown.defaultRules.u, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, del: { ...SimpleMarkdown.defaultRules.del, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, inlineCode: { ...SimpleMarkdown.defaultRules.inlineCode, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {node.content} ), }, heading: { ...SimpleMarkdown.defaultRules.heading, match: SimpleMarkdown.blockRegex( SharedMarkdown.headingStripFollowingNewlineRegex, ), // eslint-disable-next-line react/display-name react( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) { const headingStyle = styles['h' + node.level]; return ( {output(node.content, state)} ); }, }, blockQuote: { ...SimpleMarkdown.defaultRules.blockQuote, // match end of blockQuote by either \n\n or end of string match: SimpleMarkdown.blockRegex( SharedMarkdown.blockQuoteStripFollowingNewlineRegex, ), parse( capture: SimpleMarkdown.Capture, parse: SimpleMarkdown.Parser, state: SimpleMarkdown.State, ) { const content = capture[1].replace(/^ *> ?/gm, ''); return { content: parse(content, state), }; }, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, codeBlock: { ...SimpleMarkdown.defaultRules.codeBlock, match: SimpleMarkdown.blockRegex( SharedMarkdown.codeBlockStripTrailingNewlineRegex, ), parse(capture: SimpleMarkdown.Capture) { return { content: capture[1].replace(/^ {4}/gm, ''), }; }, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {node.content} ), }, fence: { ...SimpleMarkdown.defaultRules.fence, match: SimpleMarkdown.blockRegex( SharedMarkdown.fenceStripTrailingNewlineRegex, ), parse: (capture: SimpleMarkdown.Capture) => ({ type: 'codeBlock', content: capture[2], }), }, json: { order: SimpleMarkdown.defaultRules.paragraph.order - 1, match: (source: string, state: SimpleMarkdown.State) => { if (state.inline) { return null; } return SharedMarkdown.jsonMatch(source); }, parse: (capture: SimpleMarkdown.Capture) => ({ type: 'codeBlock', content: SharedMarkdown.jsonPrint(capture), }), }, list: { ...SimpleMarkdown.defaultRules.list, match: SharedMarkdown.matchList, parse: SharedMarkdown.parseList, react( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) { const children = node.items.map((item, i) => { const content = output(item, state); const bulletValue = node.ordered ? node.start + i + '. ' : '\u2022 '; return ( {bulletValue} {content} ); }); return {children}; }, }, escape: SimpleMarkdown.defaultRules.escape, }; return { ...inlineRules, simpleMarkdownRules, emojiOnlyFactor: 2, container: 'View', }; }, ); function useTextMessageRulesFunc(threadID: string) { const threadMembers = useSelector( relativeMemberInfoSelectorForMembersOfThread(threadID), ); return React.useMemo( () => _memoize<[boolean], MarkdownRules>((useDarkStyle: boolean) => textMessageRules(threadMembers, useDarkStyle), ), [threadMembers], ); } function textMessageRules( members: $ReadOnlyArray, useDarkStyle: boolean, ) { const styles = getMarkdownStyles(useDarkStyle ? 'dark' : 'light'); const baseRules = fullMarkdownRules(useDarkStyle); return { ...baseRules, simpleMarkdownRules: { ...baseRules.simpleMarkdownRules, mention: { ...SimpleMarkdown.defaultRules.strong, match: SharedMarkdown.matchMentions(members), parse: (capture: SimpleMarkdown.Capture) => ({ content: capture[0], }), // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {node.content} ), }, }, }; } export { inlineMarkdownRules, useTextMessageRulesFunc }; diff --git a/native/media/blob-utils.js b/native/media/blob-utils.js index 3c1eb621d..b5e02b50a 100644 --- a/native/media/blob-utils.js +++ b/native/media/blob-utils.js @@ -1,150 +1,151 @@ // @flow import base64 from 'base-64'; import invariant from 'invariant'; + import { fileInfoFromData, bytesNeededForFileTypeCheck, } from 'lib/media/file-utils'; import type { MediaMissionStep, MediaMissionFailure, } from 'lib/types/media-types'; import { getMessageForException } from 'lib/utils/errors'; import { getFetchableURI } from './identifier-utils'; function blobToDataURI(blob: Blob): Promise { const fileReader = new FileReader(); return new Promise((resolve, reject) => { fileReader.onerror = (error) => { fileReader.abort(); reject(error); }; fileReader.onload = () => { invariant( typeof fileReader.result === 'string', 'FileReader.readAsDataURL should result in string', ); resolve(fileReader.result); }; fileReader.readAsDataURL(blob); }); } const base64CharsNeeded = 4 * Math.ceil(bytesNeededForFileTypeCheck / 3); function dataURIToIntArray(dataURI: string): Uint8Array { const uri = dataURI.replace(/\r?\n/g, ''); const firstComma = uri.indexOf(','); if (firstComma <= 4) { throw new TypeError('malformed data-URI'); } const meta = uri.substring(5, firstComma).split(';'); const base64Encoded = meta.some((metum) => metum === 'base64'); let data = unescape(uri.substr(firstComma + 1, base64CharsNeeded)); if (base64Encoded) { data = base64.decode(data); } return stringToIntArray(data); } function stringToIntArray(str: string): Uint8Array { const array = new Uint8Array(str.length); for (let i = 0; i < str.length; i++) { array[i] = str.charCodeAt(i); } return array; } type FetchBlobResult = {| success: true, base64: string, mime: string, |}; async function fetchBlob( inputURI: string, ): Promise<{| steps: $ReadOnlyArray, result: MediaMissionFailure | FetchBlobResult, |}> { const uri = getFetchableURI(inputURI); const steps = []; let blob, fetchExceptionMessage; const fetchStart = Date.now(); try { const response = await fetch(uri); blob = await response.blob(); } catch (e) { fetchExceptionMessage = getMessageForException(e); } steps.push({ step: 'fetch_blob', success: !!blob, exceptionMessage: fetchExceptionMessage, time: Date.now() - fetchStart, inputURI, uri, size: blob && blob.size, mime: blob && blob.type, }); if (!blob) { return { result: { success: false, reason: 'fetch_failed' }, steps }; } let dataURI, dataURIExceptionMessage; const dataURIStart = Date.now(); try { dataURI = await blobToDataURI(blob); } catch (e) { dataURIExceptionMessage = getMessageForException(e); } steps.push({ step: 'data_uri_from_blob', success: !!dataURI, exceptionMessage: dataURIExceptionMessage, time: Date.now() - dataURIStart, first255Chars: dataURI && dataURI.substring(0, 255), }); if (!dataURI) { return { result: { success: false, reason: 'data_uri_failed' }, steps }; } const firstComma = dataURI.indexOf(','); invariant(firstComma > 4, 'malformed data-URI'); const base64String = dataURI.substring(firstComma + 1); let mime = blob.type; if (!mime) { let mimeCheckExceptionMessage; const mimeCheckStart = Date.now(); try { const intArray = dataURIToIntArray(dataURI); ({ mime } = fileInfoFromData(intArray)); } catch (e) { mimeCheckExceptionMessage = getMessageForException(e); } steps.push({ step: 'mime_check', success: !!mime, exceptionMessage: mimeCheckExceptionMessage, time: Date.now() - mimeCheckStart, mime, }); } if (!mime) { return { result: { success: false, reason: 'mime_check_failed', mime }, steps, }; } return { result: { success: true, base64: base64String, mime }, steps }; } export { stringToIntArray, fetchBlob }; diff --git a/native/media/camera-modal.react.js b/native/media/camera-modal.react.js index cf3605d7a..ea2872095 100644 --- a/native/media/camera-modal.react.js +++ b/native/media/camera-modal.react.js @@ -1,1232 +1,1232 @@ // @flow import invariant from 'invariant'; -import { pathFromURI, filenameFromPathOrURI } from 'lib/media/file-utils'; -import type { PhotoCapture } from 'lib/types/media-types'; -import type { Dispatch } from 'lib/types/redux-types'; import PropTypes from 'prop-types'; import * as React from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Platform, Image, Animated, Easing, } from 'react-native'; import { RNCamera } from 'react-native-camera'; import filesystem from 'react-native-fs'; import { PinchGestureHandler, TapGestureHandler, State as GestureState, } from 'react-native-gesture-handler'; import Orientation from 'react-native-orientation-locker'; import type { Orientations } from 'react-native-orientation-locker'; import Reanimated, { Easing as ReanimatedEasing, } from 'react-native-reanimated'; import Icon from 'react-native-vector-icons/Ionicons'; import { useDispatch } from 'react-redux'; +import { pathFromURI, filenameFromPathOrURI } from 'lib/media/file-utils'; +import type { PhotoCapture } from 'lib/types/media-types'; +import type { Dispatch } from 'lib/types/redux-types'; + import ContentLoading from '../components/content-loading.react'; import ConnectedStatusBar from '../connected-status-bar.react'; import { type InputState, inputStatePropType, InputStateContext, } from '../input/input-state'; 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 { updateDeviceCameraInfoActionType } from '../redux/action-types'; import { type DimensionsInfo, dimensionsInfoPropType, } from '../redux/dimensions-updater.react'; import { useSelector } from '../redux/redux-utils'; import { colors } from '../themes/colors'; import { type DeviceCameraInfo, deviceCameraInfoPropType, } from '../types/camera'; import type { NativeMethods } from '../types/react-native'; import type { ViewStyle } from '../types/styles'; import { clamp, gestureJustEnded } from '../utils/animation-utils'; - import SendMediaButton from './send-media-button.react'; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, event, Extrapolate, block, set, call, cond, not, and, or, eq, greaterThan, lessThan, add, sub, multiply, divide, abs, interpolate, startClock, stopClock, clockRunning, timing, spring, SpringUtils, } = Reanimated; /* eslint-enable import/no-named-as-default-member */ const maxZoom = 16; const zoomUpdateFactor = (() => { if (Platform.OS === 'ios') { return 0.002; } if (Platform.OS === 'android' && Platform.Version > 26) { return 0.005; } if (Platform.OS === 'android' && Platform.Version > 23) { return 0.01; } return 0.03; })(); const stagingModeAnimationConfig = { duration: 150, easing: ReanimatedEasing.inOut(ReanimatedEasing.ease), }; const sendButtonAnimationConfig = { duration: 150, easing: Easing.inOut(Easing.ease), useNativeDriver: true, }; const indicatorSpringConfig = { ...SpringUtils.makeDefaultConfig(), damping: 0, mass: 0.6, toValue: 1, }; const indicatorTimingConfig = { duration: 500, easing: ReanimatedEasing.out(ReanimatedEasing.ease), toValue: 0, }; function runIndicatorAnimation( // Inputs springClock: Clock, delayClock: Clock, timingClock: Clock, animationRunning: Value, // Outputs scale: Value, opacity: Value, ): Value { const delayStart = new Value(0); const springScale = new Value(0.75); const delayScale = new Value(0); const timingScale = new Value(0.75); const animatedScale = cond( clockRunning(springClock), springScale, cond(clockRunning(delayClock), delayScale, timingScale), ); const lastAnimatedScale = new Value(0.75); const numScaleLoops = new Value(0); const springState = { finished: new Value(1), velocity: new Value(0), time: new Value(0), position: springScale, }; const timingState = { finished: new Value(1), frameTime: new Value(0), time: new Value(0), position: timingScale, }; return block([ cond(not(animationRunning), [ set(springState.finished, 0), set(springState.velocity, 0), set(springState.time, 0), set(springScale, 0.75), set(lastAnimatedScale, 0.75), set(numScaleLoops, 0), set(opacity, 1), startClock(springClock), ]), [ cond( clockRunning(springClock), spring(springClock, springState, indicatorSpringConfig), ), timing(timingClock, timingState, indicatorTimingConfig), ], [ cond( and( greaterThan(animatedScale, 1.2), not(greaterThan(lastAnimatedScale, 1.2)), ), [ set(numScaleLoops, add(numScaleLoops, 1)), cond(greaterThan(numScaleLoops, 1), [ set(springState.finished, 1), stopClock(springClock), set(delayScale, springScale), set(delayStart, delayClock), startClock(delayClock), ]), ], ), set(lastAnimatedScale, animatedScale), ], cond( and( clockRunning(delayClock), greaterThan(delayClock, add(delayStart, 400)), ), [ stopClock(delayClock), set(timingState.finished, 0), set(timingState.frameTime, 0), set(timingState.time, 0), set(timingScale, delayScale), startClock(timingClock), ], ), cond( and(springState.finished, timingState.finished), stopClock(timingClock), ), set(scale, animatedScale), cond(clockRunning(timingClock), set(opacity, clamp(animatedScale, 0, 1))), ]); } export type CameraModalParams = {| presentedFrom: string, threadID: string, |}; type TouchableOpacityInstance = React.AbstractComponent< React.ElementConfig, NativeMethods, >; type BaseProps = {| +navigation: AppNavigationProp<'CameraModal'>, +route: NavigationRoute<'CameraModal'>, |}; type Props = {| ...BaseProps, // Redux state +dimensions: DimensionsInfo, +deviceCameraInfo: DeviceCameraInfo, +deviceOrientation: Orientations, +foreground: boolean, // Redux dispatch functions +dispatch: Dispatch, // withInputState +inputState: ?InputState, // withOverlayContext +overlayContext: ?OverlayContextType, |}; type State = {| +zoom: number, +useFrontCamera: boolean, +hasCamerasOnBothSides: boolean, +flashMode: number, +autoFocusPointOfInterest: ?{| x: number, y: number, autoExposure?: boolean, |}, +stagingMode: boolean, +pendingPhotoCapture: ?PhotoCapture, |}; class CameraModal extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ goBackOnce: PropTypes.func.isRequired, }).isRequired, route: PropTypes.shape({ params: PropTypes.shape({ threadID: PropTypes.string.isRequired, }).isRequired, }).isRequired, dimensions: dimensionsInfoPropType.isRequired, deviceCameraInfo: deviceCameraInfoPropType.isRequired, deviceOrientation: PropTypes.string.isRequired, foreground: PropTypes.bool.isRequired, dispatch: PropTypes.func.isRequired, inputState: inputStatePropType, overlayContext: overlayContextPropType, }; camera: ?RNCamera; pinchEvent; pinchHandler = React.createRef(); tapEvent; tapHandler = React.createRef(); animationCode: Value; closeButton: ?React.ElementRef; closeButtonX = new Value(-1); closeButtonY = new Value(-1); closeButtonWidth = new Value(0); closeButtonHeight = new Value(0); photoButton: ?React.ElementRef; photoButtonX = new Value(-1); photoButtonY = new Value(-1); photoButtonWidth = new Value(0); photoButtonHeight = new Value(0); switchCameraButton: ?React.ElementRef; switchCameraButtonX = new Value(-1); switchCameraButtonY = new Value(-1); switchCameraButtonWidth = new Value(0); switchCameraButtonHeight = new Value(0); flashButton: ?React.ElementRef; flashButtonX = new Value(-1); flashButtonY = new Value(-1); flashButtonWidth = new Value(0); flashButtonHeight = new Value(0); focusIndicatorX = new Value(-1); focusIndicatorY = new Value(-1); focusIndicatorScale = new Value(0); focusIndicatorOpacity = new Value(0); cancelIndicatorAnimation = new Value(0); cameraIDsFetched = false; stagingModeProgress = new Value(0); sendButtonProgress = new Animated.Value(0); sendButtonStyle: ViewStyle; overlayStyle: ViewStyle; constructor(props: Props) { super(props); this.state = { zoom: 0, useFrontCamera: props.deviceCameraInfo.defaultUseFrontCamera, hasCamerasOnBothSides: props.deviceCameraInfo.hasCamerasOnBothSides, flashMode: RNCamera.Constants.FlashMode.off, autoFocusPointOfInterest: undefined, stagingMode: false, pendingPhotoCapture: undefined, }; const sendButtonScale = this.sendButtonProgress.interpolate({ inputRange: [0, 1], outputRange: ([1.1, 1]: number[]), // Flow... }); this.sendButtonStyle = { opacity: this.sendButtonProgress, transform: [{ scale: sendButtonScale }], }; const overlayOpacity = interpolate(this.stagingModeProgress, { inputRange: [0, 0.01, 1], outputRange: [0, 0.5, 0], extrapolate: Extrapolate.CLAMP, }); this.overlayStyle = { ...styles.overlay, opacity: overlayOpacity, }; const pinchState = new Value(-1); const pinchScale = new Value(1); this.pinchEvent = event([ { nativeEvent: { state: pinchState, scale: pinchScale, }, }, ]); const tapState = new Value(-1); const tapX = new Value(0); const tapY = new Value(0); this.tapEvent = event([ { nativeEvent: { state: tapState, x: tapX, y: tapY, }, }, ]); this.animationCode = block([ this.zoomAnimationCode(pinchState, pinchScale), this.focusAnimationCode(tapState, tapX, tapY), ]); } zoomAnimationCode(pinchState: Value, pinchScale: Value): Value { const pinchJustEnded = gestureJustEnded(pinchState); const zoomBase = new Value(1); const zoomReported = new Value(1); const currentZoom = interpolate(multiply(zoomBase, pinchScale), { inputRange: [1, 8], outputRange: [1, 8], extrapolate: Extrapolate.CLAMP, }); const cameraZoomFactor = interpolate(zoomReported, { inputRange: [1, 8], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); const resolvedZoom = cond( eq(pinchState, GestureState.ACTIVE), currentZoom, zoomBase, ); return [ cond(pinchJustEnded, set(zoomBase, currentZoom)), cond( or( pinchJustEnded, greaterThan( abs(sub(divide(resolvedZoom, zoomReported), 1)), zoomUpdateFactor, ), ), [ set(zoomReported, resolvedZoom), call([cameraZoomFactor], this.updateZoom), ], ), ]; } focusAnimationCode(tapState: Value, tapX: Value, tapY: Value): Value { const lastTapX = new Value(0); const lastTapY = new Value(0); const fingerJustReleased = and( gestureJustEnded(tapState), this.outsideButtons(lastTapX, lastTapY), ); const indicatorSpringClock = new Clock(); const indicatorDelayClock = new Clock(); const indicatorTimingClock = new Clock(); const indicatorAnimationRunning = or( clockRunning(indicatorSpringClock), clockRunning(indicatorDelayClock), clockRunning(indicatorTimingClock), ); return [ cond(fingerJustReleased, [ call([tapX, tapY], this.focusOnPoint), set(this.focusIndicatorX, tapX), set(this.focusIndicatorY, tapY), stopClock(indicatorSpringClock), stopClock(indicatorDelayClock), stopClock(indicatorTimingClock), ]), cond(this.cancelIndicatorAnimation, [ set(this.cancelIndicatorAnimation, 0), stopClock(indicatorSpringClock), stopClock(indicatorDelayClock), stopClock(indicatorTimingClock), set(this.focusIndicatorOpacity, 0), ]), cond( or(fingerJustReleased, indicatorAnimationRunning), runIndicatorAnimation( indicatorSpringClock, indicatorDelayClock, indicatorTimingClock, indicatorAnimationRunning, this.focusIndicatorScale, this.focusIndicatorOpacity, ), ), set(lastTapX, tapX), set(lastTapY, tapY), ]; } outsideButtons(x: Value, y: Value) { const { closeButtonX, closeButtonY, closeButtonWidth, closeButtonHeight, photoButtonX, photoButtonY, photoButtonWidth, photoButtonHeight, switchCameraButtonX, switchCameraButtonY, switchCameraButtonWidth, switchCameraButtonHeight, flashButtonX, flashButtonY, flashButtonWidth, flashButtonHeight, } = this; return and( or( lessThan(x, closeButtonX), greaterThan(x, add(closeButtonX, closeButtonWidth)), lessThan(y, closeButtonY), greaterThan(y, add(closeButtonY, closeButtonHeight)), ), or( lessThan(x, photoButtonX), greaterThan(x, add(photoButtonX, photoButtonWidth)), lessThan(y, photoButtonY), greaterThan(y, add(photoButtonY, photoButtonHeight)), ), or( lessThan(x, switchCameraButtonX), greaterThan(x, add(switchCameraButtonX, switchCameraButtonWidth)), lessThan(y, switchCameraButtonY), greaterThan(y, add(switchCameraButtonY, switchCameraButtonHeight)), ), or( lessThan(x, flashButtonX), greaterThan(x, add(flashButtonX, flashButtonWidth)), lessThan(y, flashButtonY), greaterThan(y, add(flashButtonY, flashButtonHeight)), ), ); } static isActive(props) { const { overlayContext } = props; invariant(overlayContext, 'CameraModal should have OverlayContext'); return !overlayContext.isDismissing; } componentDidMount() { if (CameraModal.isActive(this.props)) { Orientation.unlockAllOrientations(); } } componentWillUnmount() { if (CameraModal.isActive(this.props)) { Orientation.lockToPortrait(); } } componentDidUpdate(prevProps: Props, prevState: State) { const isActive = CameraModal.isActive(this.props); const wasActive = CameraModal.isActive(prevProps); if (isActive && !wasActive) { Orientation.unlockAllOrientations(); } else if (!isActive && wasActive) { Orientation.lockToPortrait(); } if (!this.state.hasCamerasOnBothSides && prevState.hasCamerasOnBothSides) { this.switchCameraButtonX.setValue(-1); this.switchCameraButtonY.setValue(-1); this.switchCameraButtonWidth.setValue(0); this.switchCameraButtonHeight.setValue(0); } if (this.props.deviceOrientation !== prevProps.deviceOrientation) { this.setState({ autoFocusPointOfInterest: null }); this.cancelIndicatorAnimation.setValue(1); } if (this.props.foreground && !prevProps.foreground && this.camera) { this.camera.refreshAuthorizationStatus(); } if (this.state.stagingMode && !prevState.stagingMode) { this.cancelIndicatorAnimation.setValue(1); this.focusIndicatorOpacity.setValue(0); timing(this.stagingModeProgress, { ...stagingModeAnimationConfig, toValue: 1, }).start(); } else if (!this.state.stagingMode && prevState.stagingMode) { this.stagingModeProgress.setValue(0); } if (this.state.pendingPhotoCapture && !prevState.pendingPhotoCapture) { Animated.timing(this.sendButtonProgress, { ...sendButtonAnimationConfig, toValue: 1, }).start(); } else if ( !this.state.pendingPhotoCapture && prevState.pendingPhotoCapture ) { CameraModal.cleanUpPendingPhotoCapture(prevState.pendingPhotoCapture); this.sendButtonProgress.setValue(0); } } static async cleanUpPendingPhotoCapture(pendingPhotoCapture: PhotoCapture) { const path = pathFromURI(pendingPhotoCapture.uri); if (!path) { return; } try { await filesystem.unlink(path); } catch (e) {} } get containerStyle() { const { overlayContext } = this.props; invariant(overlayContext, 'CameraModal should have OverlayContext'); return { ...styles.container, opacity: overlayContext.position, }; } get focusIndicatorStyle() { return { ...styles.focusIndicator, opacity: this.focusIndicatorOpacity, transform: [ { translateX: this.focusIndicatorX }, { translateY: this.focusIndicatorY }, { scale: this.focusIndicatorScale }, ], }; } renderCamera = ({ camera, status }) => { if (camera && camera._cameraHandle) { this.fetchCameraIDs(camera); } if (this.state.stagingMode) { return this.renderStagingView(); } const topButtonStyle = { top: Math.max(this.props.dimensions.topInset, 6), }; return ( <> {this.renderCameraContent(status)} × ); }; renderStagingView() { let image = null; const { pendingPhotoCapture } = this.state; if (pendingPhotoCapture) { const imageSource = { uri: pendingPhotoCapture.uri }; image = ; } else { image = ; } const topButtonStyle = { top: Math.max(this.props.dimensions.topInset - 3, 3), }; const sendButtonContainerStyle = { bottom: this.props.dimensions.bottomInset + 22, }; return ( <> {image} ); } renderCameraContent(status) { if (status === 'PENDING_AUTHORIZATION') { return ; } else if (status === 'NOT_AUTHORIZED') { return ( {"don't have permission :("} ); } let switchCameraButton = null; if (this.state.hasCamerasOnBothSides) { switchCameraButton = ( ); } let flashIcon; if (this.state.flashMode === RNCamera.Constants.FlashMode.on) { flashIcon = ; } else if (this.state.flashMode === RNCamera.Constants.FlashMode.off) { flashIcon = ; } else { flashIcon = ( <> A ); } const topButtonStyle = { top: Math.max(this.props.dimensions.topInset - 3, 3), }; const bottomButtonsContainerStyle = { bottom: this.props.dimensions.bottomInset + 20, }; return ( {flashIcon} {switchCameraButton} ); } render() { const statusBar = CameraModal.isActive(this.props) ? (