diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index d55d7a188..5f8901f95 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,896 +1,899 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find'; import * as React from 'react'; import { type ParserRules } from 'simple-markdown'; import tinycolor from 'tinycolor2'; import { fetchMostRecentMessagesActionTypes, fetchMostRecentMessages, } from '../actions/message-actions'; import { newThread, newThreadActionTypes } from '../actions/thread-actions'; import { permissionLookup, getAllThreadPermissions, makePermissionsBlob, } from '../permissions/thread-permissions'; import type { ChatThreadItem } from '../selectors/chat-selectors'; import type { MultimediaMessageInfo, RobotextMessageInfo, } from '../types/message-types'; import type { TextMessageInfo } from '../types/messages/text'; import { userRelationshipStatus } from '../types/relationship-types'; import { type RawThreadInfo, type ThreadInfo, type ThreadPermission, type MemberInfo, type ServerThreadInfo, type RelativeMemberInfo, type ThreadCurrentUserInfo, type RoleInfo, type ServerMemberInfo, type ThreadPermissionsInfo, type ThreadType, threadTypes, threadPermissions, } from '../types/thread-types'; -import type { NewThreadRequest, NewThreadResult } from '../types/thread-types'; +import type { + NewThreadRequest, + NewThreadResult, + OptimisticThreadInfo, +} from '../types/thread-types'; import { type UpdateInfo, updateTypes } from '../types/update-types'; import type { GlobalAccountUserInfo, UserInfos, UserInfo, } from '../types/user-types'; import { useDispatchActionPromise, useServerCall } from '../utils/action-utils'; import type { DispatchActionPromise } from '../utils/action-utils'; import { pluralize } from '../utils/text-utils'; import { getMessageTitle } from './message-utils'; import { relationshipBlockedInEitherDirection } from './relationship-utils'; import threadWatcher from './thread-watcher'; function colorIsDark(color: string) { return tinycolor(`#${color}`).isDark(); } // Randomly distributed in RGB-space const hexNumerals = '0123456789abcdef'; function generateRandomColor() { let color = ''; for (let i = 0; i < 6; i++) { color += hexNumerals[Math.floor(Math.random() * 16)]; } return color; } function generatePendingThreadColor( userIDs: $ReadOnlyArray, viewerID: string, ) { const ids = [...userIDs, viewerID].sort().join('#'); let hash = 0; for (let i = 0; i < ids.length; i++) { hash = 1009 * hash + ids.charCodeAt(i) * 83; hash %= 1000000007; } const hashString = hash.toString(16); return hashString.substring(hashString.length - 6).padStart(6, '8'); } function threadHasPermission( threadInfo: ?(ThreadInfo | RawThreadInfo), permission: ThreadPermission, ): boolean { invariant( !permissionsDisabledByBlock.has(permission) || threadInfo?.uiName, `${permission} can be disabled by a block, but threadHasPermission can't ` + 'check for a block on RawThreadInfo. Please pass in ThreadInfo instead!', ); if (!threadInfo || !threadInfo.currentUser.permissions[permission]) { return false; } return threadInfo.currentUser.permissions[permission].value; } function viewerIsMember(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!( threadInfo && threadInfo.currentUser.role !== null && threadInfo.currentUser.role !== undefined ); } function threadIsInHome(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!(threadInfo && threadInfo.currentUser.subscription.home); } // Can have messages function threadInChatList(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return ( viewerIsMember(threadInfo) && threadHasPermission(threadInfo, threadPermissions.VISIBLE) ); } function threadIsTopLevel(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!( threadInChatList(threadInfo) && threadInfo && threadInfo.type !== threadTypes.SIDEBAR ); } function threadInBackgroundChatList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return threadInChatList(threadInfo) && !threadIsInHome(threadInfo); } function threadInHomeChatList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return threadInChatList(threadInfo) && threadIsInHome(threadInfo); } // Can have Calendar entries, // does appear as a top-level entity in the thread list function threadInFilterList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return ( threadInChatList(threadInfo) && !!threadInfo && threadInfo.type !== threadTypes.SIDEBAR ); } function userIsMember( threadInfo: ?(ThreadInfo | RawThreadInfo), userID: string, ): boolean { if (!threadInfo) { return false; } return threadInfo.members.some( (member) => member.id === userID && member.role !== null && member.role !== undefined, ); } function threadActualMembers( memberInfos: $ReadOnlyArray, ): $ReadOnlyArray { return memberInfos .filter( (memberInfo) => memberInfo.role !== null && memberInfo.role !== undefined, ) .map((memberInfo) => memberInfo.id); } function threadIsGroupChat(threadInfo: ThreadInfo | RawThreadInfo) { return ( threadInfo.members.filter( (member) => member.role || member.permissions[threadPermissions.VOICED]?.value, ).length > 2 ); } function threadOrParentThreadIsGroupChat( threadInfo: RawThreadInfo | ThreadInfo, ) { return threadInfo.members.length > 2; } function threadIsPending(threadID: ?string) { return threadID?.startsWith('pending'); } function threadIsPersonalAndPending(threadInfo: ?(ThreadInfo | RawThreadInfo)) { return ( threadInfo?.type === threadTypes.PERSONAL && threadIsPending(threadInfo?.id) ); } function getPendingThreadOtherUsers(threadInfo: ThreadInfo | RawThreadInfo) { invariant(threadIsPending(threadInfo.id), 'Thread should be pending'); const otherUserIDs = threadInfo.id.split('/')[1]; invariant( otherUserIDs || threadInfo.type === threadTypes.SIDEBAR, 'Pending threads should contain other members id in its id', ); if (!otherUserIDs) { return []; } return otherUserIDs.split('+'); } function getSingleOtherUser( threadInfo: ThreadInfo | RawThreadInfo, viewerID: ?string, ) { if (!viewerID) { return undefined; } const otherMemberIDs = threadInfo.members .map((member) => member.id) .filter((id) => id !== viewerID); if (otherMemberIDs.length !== 1) { return undefined; } return otherMemberIDs[0]; } function getPendingThreadKey(memberIDs: $ReadOnlyArray) { return [...memberIDs].sort().join('+'); } type CreatePendingThreadArgs = {| +viewerID: string, +threadType: ThreadType, +members?: $ReadOnlyArray, +parentThreadID?: ?string, +threadColor?: ?string, +name?: ?string, |}; function createPendingThread({ viewerID, threadType, members, parentThreadID, threadColor, name, }: CreatePendingThreadArgs) { const now = Date.now(); members = members ?? []; const memberIDs = members.map((member) => member.id); const threadID = `pending/${getPendingThreadKey(memberIDs)}`; const permissions = { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.VOICED]: true, }; const membershipPermissions = getAllThreadPermissions( makePermissionsBlob(permissions, null, threadID, threadType), threadID, ); const role = { id: `${threadID}/role`, name: 'Members', permissions, isDefault: true, }; const rawThreadInfo = { id: threadID, type: threadType, name: name ?? null, description: null, color: threadColor ?? generatePendingThreadColor(memberIDs, viewerID), creationTime: now, parentThreadID: parentThreadID ?? null, members: [ { id: viewerID, role: role.id, permissions: membershipPermissions, isSender: false, }, ...members.map((member) => ({ id: member.id, role: role.id, permissions: membershipPermissions, isSender: false, })), ], roles: { [role.id]: role, }, currentUser: { role: role.id, permissions: membershipPermissions, subscription: { pushNotifs: false, home: false, }, unread: false, }, repliesCount: 0, }; const userInfos = {}; members.forEach((member) => (userInfos[member.id] = member)); return threadInfoFromRawThreadInfo(rawThreadInfo, viewerID, userInfos); } function createPendingThreadItem( viewerID: string, user: GlobalAccountUserInfo, ): ChatThreadItem { const threadInfo = createPendingThread({ viewerID, threadType: threadTypes.PERSONAL, members: [user], }); return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo: null, mostRecentNonLocalMessage: null, lastUpdatedTime: threadInfo.creationTime, lastUpdatedTimeIncludingSidebars: threadInfo.creationTime, sidebars: [], pendingPersonalThreadUserInfo: { id: user.id, username: user.username, }, }; } function createPendingSidebar( messageInfo: TextMessageInfo | MultimediaMessageInfo | RobotextMessageInfo, threadInfo: ThreadInfo, viewerID: string, markdownRules: ParserRules, ) { const { id, username } = messageInfo.creator; const { id: parentThreadID, color } = threadInfo; invariant(username, 'username should be set in createPendingSidebar'); const initialMemberUserInfo: GlobalAccountUserInfo = { id, username }; const creatorIsMember = userIsMember(threadInfo, id); return createPendingThread({ viewerID, threadType: threadTypes.SIDEBAR, members: creatorIsMember ? [initialMemberUserInfo] : [], parentThreadID, threadColor: color, name: getMessageTitle( messageInfo, threadInfo, markdownRules, 'global_viewer', ), }); } function pendingThreadType(numberOfOtherMembers: number) { return numberOfOtherMembers === 1 ? threadTypes.PERSONAL : threadTypes.CHAT_SECRET; } async function createRealThreadFromPendingThread( threadInfo: ThreadInfo, dispatchActionPromise: DispatchActionPromise, createNewThread: (request: NewThreadRequest) => Promise, sourceMessageID: ?string, handleError?: () => mixed, ): Promise { const otherMemberIDs = getPendingThreadOtherUsers(threadInfo); try { let resultPromise; if (threadInfo.type !== threadTypes.SIDEBAR) { invariant( otherMemberIDs.length > 0, 'otherMemberIDs should not be empty for threads', ); resultPromise = createNewThread({ type: pendingThreadType(otherMemberIDs.length), initialMemberIDs: otherMemberIDs, color: threadInfo.color, }); } else { invariant( sourceMessageID, 'sourceMessageID should be set when creating a sidebar', ); resultPromise = createNewThread({ type: threadTypes.SIDEBAR, initialMemberIDs: otherMemberIDs, color: threadInfo.color, sourceMessageID, parentThreadID: threadInfo.parentThreadID, name: threadInfo.name, }); } dispatchActionPromise(newThreadActionTypes, resultPromise); const { newThreadID } = await resultPromise; return newThreadID; } catch (e) { if (handleError) { handleError(); return undefined; } else { throw e; } } } function useRealThreadCreator( - threadInfo: ThreadInfo, - sourceMessageID?: ?string, + { threadInfo, sourceMessageID }: OptimisticThreadInfo, handleError?: () => mixed, ) { const serverThreadID = React.useRef( threadIsPending(threadInfo.id) ? null : threadInfo.id, ); const dispatchActionPromise = useDispatchActionPromise(); const callNewThread = useServerCall(newThread); return React.useCallback(async () => { if (serverThreadID.current) { return serverThreadID.current; } const newThreadID = await createRealThreadFromPendingThread( threadInfo, dispatchActionPromise, callNewThread, sourceMessageID, handleError, ); serverThreadID.current = newThreadID; return newThreadID; }, [ callNewThread, dispatchActionPromise, handleError, sourceMessageID, threadInfo, ]); } type RawThreadInfoOptions = {| +includeVisibilityRules?: ?boolean, +filterMemberList?: ?boolean, |}; function rawThreadInfoFromServerThreadInfo( serverThreadInfo: ServerThreadInfo, viewerID: string, options?: RawThreadInfoOptions, ): ?RawThreadInfo { const includeVisibilityRules = options?.includeVisibilityRules; const filterMemberList = options?.filterMemberList; const members = []; let currentUser; for (const serverMember of serverThreadInfo.members) { if ( filterMemberList && serverMember.id !== viewerID && !serverMember.role && !memberHasAdminPowers(serverMember) ) { continue; } members.push({ id: serverMember.id, role: serverMember.role, permissions: serverMember.permissions, isSender: serverMember.isSender, }); if (serverMember.id === viewerID) { currentUser = { role: serverMember.role, permissions: serverMember.permissions, subscription: serverMember.subscription, unread: serverMember.unread, }; } } let currentUserPermissions; if (currentUser) { currentUserPermissions = currentUser.permissions; } else { currentUserPermissions = getAllThreadPermissions(null, serverThreadInfo.id); currentUser = { role: null, permissions: currentUserPermissions, subscription: { home: false, pushNotifs: false, }, unread: null, }; } if (!permissionLookup(currentUserPermissions, threadPermissions.KNOW_OF)) { return null; } const rawThreadInfo: RawThreadInfo = { id: serverThreadInfo.id, type: serverThreadInfo.type, name: serverThreadInfo.name, description: serverThreadInfo.description, color: serverThreadInfo.color, creationTime: serverThreadInfo.creationTime, parentThreadID: serverThreadInfo.parentThreadID, members, roles: serverThreadInfo.roles, currentUser, repliesCount: serverThreadInfo.repliesCount, }; const sourceMessageID = serverThreadInfo.sourceMessageID; if (sourceMessageID) { rawThreadInfo.sourceMessageID = sourceMessageID; } if (!includeVisibilityRules) { return rawThreadInfo; } return ({ ...rawThreadInfo, visibilityRules: rawThreadInfo.type, }: any); } function robotextName( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): string { const threadUsernames: string[] = threadInfo.members .filter( (threadMember) => threadMember.id !== viewerID && (threadMember.role || memberHasAdminPowers(threadMember)), ) .map( (threadMember) => userInfos[threadMember.id] && userInfos[threadMember.id].username, ) .filter(Boolean); if (threadUsernames.length === 0) { return 'just you'; } return pluralize(threadUsernames); } function threadUIName( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): string { if (threadInfo.name) { return threadInfo.name; } return robotextName(threadInfo, viewerID, userInfos); } function threadInfoFromRawThreadInfo( rawThreadInfo: RawThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadInfo { const threadInfo: ThreadInfo = { id: rawThreadInfo.id, type: rawThreadInfo.type, name: rawThreadInfo.name, uiName: threadUIName(rawThreadInfo, viewerID, userInfos), description: rawThreadInfo.description, color: rawThreadInfo.color, creationTime: rawThreadInfo.creationTime, parentThreadID: rawThreadInfo.parentThreadID, members: rawThreadInfo.members, roles: rawThreadInfo.roles, currentUser: getCurrentUser(rawThreadInfo, viewerID, userInfos), repliesCount: rawThreadInfo.repliesCount, }; const { sourceMessageID } = rawThreadInfo; if (sourceMessageID) { threadInfo.sourceMessageID = sourceMessageID; } return threadInfo; } function getCurrentUser( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadCurrentUserInfo { if (!threadFrozenDueToBlock(threadInfo, viewerID, userInfos)) { return threadInfo.currentUser; } return { ...threadInfo.currentUser, permissions: { ...threadInfo.currentUser.permissions, ...disabledPermissions, }, }; } function threadIsWithBlockedUserOnly( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock?: boolean, ): boolean { if ( threadOrParentThreadIsGroupChat(threadInfo) || threadOrParentThreadHasAdminRole(threadInfo) ) { return false; } const otherUserID = getSingleOtherUser(threadInfo, viewerID); if (!otherUserID) { return false; } const otherUserRelationshipStatus = userInfos[otherUserID]?.relationshipStatus; if (checkOnlyViewerBlock) { return ( otherUserRelationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ); } return ( !!otherUserRelationshipStatus && relationshipBlockedInEitherDirection(otherUserRelationshipStatus) ); } function threadFrozenDueToBlock( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos); } function threadFrozenDueToViewerBlock( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos, true); } function rawThreadInfoFromThreadInfo(threadInfo: ThreadInfo): RawThreadInfo { const rawThreadInfo: RawThreadInfo = { id: threadInfo.id, type: threadInfo.type, name: threadInfo.name, description: threadInfo.description, color: threadInfo.color, creationTime: threadInfo.creationTime, parentThreadID: threadInfo.parentThreadID, members: threadInfo.members, roles: threadInfo.roles, currentUser: threadInfo.currentUser, repliesCount: threadInfo.repliesCount, }; const { sourceMessageID } = threadInfo; if (sourceMessageID) { rawThreadInfo.sourceMessageID = sourceMessageID; } return rawThreadInfo; } const threadTypeDescriptions = { [threadTypes.CHAT_NESTED_OPEN]: 'Anybody in the parent thread can see an open child thread.', [threadTypes.CHAT_SECRET]: 'Only visible to its members and admins of ancestor threads.', }; function usersInThreadInfo(threadInfo: RawThreadInfo | ThreadInfo): string[] { const userIDs = new Set(); for (let member of threadInfo.members) { userIDs.add(member.id); } return [...userIDs]; } function memberIsAdmin( memberInfo: RelativeMemberInfo | MemberInfo, threadInfo: ThreadInfo | RawThreadInfo, ) { return memberInfo.role && roleIsAdminRole(threadInfo.roles[memberInfo.role]); } // Since we don't have access to all of the ancestor ThreadInfos, we approximate // "parent admin" as anybody with CHANGE_ROLE permissions. function memberHasAdminPowers( memberInfo: RelativeMemberInfo | MemberInfo | ServerMemberInfo, ): boolean { return !!memberInfo.permissions[threadPermissions.CHANGE_ROLE]?.value; } function roleIsAdminRole(roleInfo: ?RoleInfo) { return roleInfo && !roleInfo.isDefault && roleInfo.name === 'Admins'; } function threadHasAdminRole( threadInfo: ?(RawThreadInfo | ThreadInfo | ServerThreadInfo), ) { if (!threadInfo) { return false; } return _find({ name: 'Admins' })(threadInfo.roles); } function threadOrParentThreadHasAdminRole( threadInfo: RawThreadInfo | ThreadInfo, ) { return ( threadInfo.members.filter((member) => memberHasAdminPowers(member)).length > 0 ); } function identifyInvalidatedThreads( updateInfos: $ReadOnlyArray, ): Set { const invalidated = new Set(); for (const updateInfo of updateInfos) { if (updateInfo.type === updateTypes.DELETE_THREAD) { invalidated.add(updateInfo.threadID); } } return invalidated; } const permissionsDisabledByBlockArray = [ threadPermissions.VOICED, threadPermissions.EDIT_ENTRIES, threadPermissions.EDIT_THREAD, threadPermissions.CREATE_SUBTHREADS, threadPermissions.CREATE_SIDEBARS, threadPermissions.JOIN_THREAD, threadPermissions.EDIT_PERMISSIONS, threadPermissions.ADD_MEMBERS, threadPermissions.REMOVE_MEMBERS, ]; const permissionsDisabledByBlock: Set = new Set( permissionsDisabledByBlockArray, ); const disabledPermissions: ThreadPermissionsInfo = permissionsDisabledByBlockArray.reduce( (permissions: ThreadPermissionsInfo, permission: string) => ({ ...permissions, [permission]: { value: false, source: null }, }), {}, ); // Consider updating itemHeight in native/chat/chat-thread-list.react.js // if you change this const emptyItemText = `Background threads are just like normal threads, except they don't ` + `contribute to your unread count.\n\n` + `To move a thread over here, switch the “Background” option in its settings.`; const threadSearchText = ( threadInfo: RawThreadInfo | ThreadInfo, userInfos: UserInfos, ): string => { const searchTextArray = []; if (threadInfo.name) { searchTextArray.push(threadInfo.name); } if (threadInfo.description) { searchTextArray.push(threadInfo.description); } for (let member of threadInfo.members) { const isParentAdmin = memberHasAdminPowers(member); if (!member.role && !isParentAdmin) { continue; } const userInfo = userInfos[member.id]; if (userInfo && userInfo.username) { searchTextArray.push(userInfo.username); } } return searchTextArray.join(' '); }; function threadNoun(threadType: ThreadType) { return threadType === threadTypes.SIDEBAR ? 'sidebar' : 'thread'; } function threadLabel(threadType: ThreadType) { if (threadType === threadTypes.CHAT_SECRET) { return 'Secret'; } else if (threadType === threadTypes.PERSONAL) { return 'Personal'; } else if (threadType === threadTypes.SIDEBAR) { return 'Sidebar'; } else if (threadType === threadTypes.PRIVATE) { return 'Private'; } else if (threadType === threadTypes.CHAT_NESTED_OPEN) { return 'Open'; } invariant(false, `unexpected threadType ${threadType}`); } function useWatchThread(threadInfo: ?ThreadInfo) { const dispatchActionPromise = useDispatchActionPromise(); const callFetchMostRecentMessages = useServerCall(fetchMostRecentMessages); const threadID = threadInfo?.id; const threadNotInChatList = !threadInChatList(threadInfo); React.useEffect(() => { if (threadID && threadNotInChatList) { threadWatcher.watchID(threadID); dispatchActionPromise( fetchMostRecentMessagesActionTypes, callFetchMostRecentMessages(threadID), ); } return () => { if (threadID && threadNotInChatList) { threadWatcher.removeID(threadID); } }; }, [ callFetchMostRecentMessages, dispatchActionPromise, threadNotInChatList, threadID, ]); } export { colorIsDark, generateRandomColor, generatePendingThreadColor, threadHasPermission, viewerIsMember, threadInChatList, threadIsTopLevel, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadIsGroupChat, threadIsPending, threadIsPersonalAndPending, getPendingThreadOtherUsers, getSingleOtherUser, getPendingThreadKey, createPendingThread, createPendingThreadItem, createPendingSidebar, pendingThreadType, useRealThreadCreator, getCurrentUser, threadFrozenDueToBlock, threadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, robotextName, threadInfoFromRawThreadInfo, rawThreadInfoFromThreadInfo, threadTypeDescriptions, usersInThreadInfo, memberIsAdmin, memberHasAdminPowers, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadSearchText, threadNoun, threadLabel, useWatchThread, }; diff --git a/lib/types/thread-types.js b/lib/types/thread-types.js index e1df84239..d6b01961a 100644 --- a/lib/types/thread-types.js +++ b/lib/types/thread-types.js @@ -1,412 +1,422 @@ // @flow import invariant from 'invariant'; import PropTypes from 'prop-types'; import type { Shape } from './core'; import type { CalendarQuery, CalendarResult, RawEntryInfo, } from './entry-types'; import type { RawMessageInfo, MessageTruncationStatuses, } from './message-types'; import type { ClientThreadInconsistencyReportCreationRequest } from './report-types'; import type { ThreadSubscription } from './subscription-types'; import type { UpdateInfo } from './update-types'; import type { UserInfo, AccountUserInfo } from './user-types'; export const threadTypes = Object.freeze({ //OPEN: 0, (DEPRECATED) //CLOSED: 1, (DEPRECATED) //SECRET: 2, (DEPRECATED) CHAT_NESTED_OPEN: 3, CHAT_SECRET: 4, SIDEBAR: 5, PERSONAL: 6, PRIVATE: 7, }); export type ThreadType = $Values; export function assertThreadType(threadType: number): ThreadType { invariant( threadType === 3 || threadType === 4 || threadType === 5 || threadType === 6 || threadType === 7, 'number is not ThreadType enum', ); return threadType; } export const threadPermissions = Object.freeze({ KNOW_OF: 'know_of', VISIBLE: 'visible', VOICED: 'voiced', EDIT_ENTRIES: 'edit_entries', EDIT_THREAD: 'edit_thread', DELETE_THREAD: 'delete_thread', CREATE_SUBTHREADS: 'create_subthreads', CREATE_SIDEBARS: 'create_sidebars', JOIN_THREAD: 'join_thread', EDIT_PERMISSIONS: 'edit_permissions', ADD_MEMBERS: 'add_members', REMOVE_MEMBERS: 'remove_members', CHANGE_ROLE: 'change_role', LEAVE_THREAD: 'leave_thread', }); export type ThreadPermission = $Values; export function assertThreadPermissions( ourThreadPermissions: string, ): ThreadPermission { invariant( ourThreadPermissions === 'know_of' || ourThreadPermissions === 'visible' || ourThreadPermissions === 'voiced' || ourThreadPermissions === 'edit_entries' || ourThreadPermissions === 'edit_thread' || ourThreadPermissions === 'delete_thread' || ourThreadPermissions === 'create_subthreads' || ourThreadPermissions === 'create_sidebars' || ourThreadPermissions === 'join_thread' || ourThreadPermissions === 'edit_permissions' || ourThreadPermissions === 'add_members' || ourThreadPermissions === 'remove_members' || ourThreadPermissions === 'change_role' || ourThreadPermissions === 'leave_thread', 'string is not threadPermissions enum', ); return ourThreadPermissions; } export const threadPermissionPrefixes = Object.freeze({ DESCENDANT: 'descendant_', CHILD: 'child_', OPEN: 'open_', OPEN_DESCENDANT: 'descendant_open_', }); export type ThreadPermissionInfo = | {| value: true, source: string |} | {| value: false, source: null |}; export type ThreadPermissionsBlob = { [permission: string]: ThreadPermissionInfo, }; export type ThreadRolePermissionsBlob = { [permission: string]: boolean }; export type ThreadPermissionsInfo = { [permission: ThreadPermission]: ThreadPermissionInfo, }; export const threadPermissionsInfoPropType = PropTypes.objectOf( PropTypes.oneOfType([ PropTypes.shape({ value: PropTypes.oneOf([true]), source: PropTypes.string.isRequired, }), PropTypes.shape({ value: PropTypes.oneOf([false]), source: PropTypes.oneOf([null]), }), ]), ); export type MemberInfo = {| +id: string, +role: ?string, +permissions: ThreadPermissionsInfo, +isSender: boolean, |}; export const memberInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, role: PropTypes.string, permissions: threadPermissionsInfoPropType.isRequired, isSender: PropTypes.bool.isRequired, }); export type RelativeMemberInfo = {| ...MemberInfo, username: ?string, isViewer: boolean, |}; export const relativeMemberInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, role: PropTypes.string, permissions: threadPermissionsInfoPropType.isRequired, username: PropTypes.string, isViewer: PropTypes.bool.isRequired, isSender: PropTypes.bool.isRequired, }); export type RoleInfo = {| id: string, name: string, permissions: ThreadRolePermissionsBlob, isDefault: boolean, |}; export type ThreadCurrentUserInfo = {| role: ?string, permissions: ThreadPermissionsInfo, subscription: ThreadSubscription, unread: ?boolean, |}; export type RawThreadInfo = {| id: string, type: ThreadType, name: ?string, description: ?string, color: string, // hex, without "#" or "0x" creationTime: number, // millisecond timestamp parentThreadID: ?string, members: $ReadOnlyArray, roles: { [id: string]: RoleInfo }, currentUser: ThreadCurrentUserInfo, sourceMessageID?: string, repliesCount: number, |}; export type ThreadInfo = {| id: string, type: ThreadType, name: ?string, uiName: string, description: ?string, color: string, // hex, without "#" or "0x" creationTime: number, // millisecond timestamp parentThreadID: ?string, members: $ReadOnlyArray, roles: { [id: string]: RoleInfo }, currentUser: ThreadCurrentUserInfo, sourceMessageID?: string, repliesCount: number, |}; +export type OptimisticThreadInfo = {| + +threadInfo: ThreadInfo, + +sourceMessageID?: string, +|}; + export const threadTypePropType = PropTypes.oneOf([ threadTypes.CHAT_NESTED_OPEN, threadTypes.CHAT_SECRET, threadTypes.SIDEBAR, threadTypes.PERSONAL, threadTypes.PRIVATE, ]); const rolePropType = PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, permissions: PropTypes.objectOf(PropTypes.bool).isRequired, isDefault: PropTypes.bool.isRequired, }); const currentUserPropType = PropTypes.shape({ role: PropTypes.string, permissions: threadPermissionsInfoPropType.isRequired, subscription: PropTypes.shape({ pushNotifs: PropTypes.bool.isRequired, home: PropTypes.bool.isRequired, }).isRequired, unread: PropTypes.bool, }); export const rawThreadInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, type: threadTypePropType.isRequired, name: PropTypes.string, description: PropTypes.string, color: PropTypes.string.isRequired, creationTime: PropTypes.number.isRequired, parentThreadID: PropTypes.string, members: PropTypes.arrayOf(memberInfoPropType).isRequired, roles: PropTypes.objectOf(rolePropType).isRequired, currentUser: currentUserPropType.isRequired, sourceMessageID: PropTypes.string, repliesCount: PropTypes.number.isRequired, }); export const threadInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, type: threadTypePropType.isRequired, name: PropTypes.string, uiName: PropTypes.string.isRequired, description: PropTypes.string, color: PropTypes.string.isRequired, creationTime: PropTypes.number.isRequired, parentThreadID: PropTypes.string, members: PropTypes.arrayOf(memberInfoPropType).isRequired, roles: PropTypes.objectOf(rolePropType).isRequired, currentUser: currentUserPropType.isRequired, sourceMessageID: PropTypes.string, repliesCount: PropTypes.number.isRequired, }); +export const optimisticThreadInfoPropType = PropTypes.shape({ + threadInfo: threadInfoPropType.isRequired, + sourceMessageID: PropTypes.string, +}); + export type ServerMemberInfo = {| +id: string, +role: ?string, +permissions: ThreadPermissionsInfo, +subscription: ThreadSubscription, +unread: ?boolean, +isSender: boolean, |}; export type ServerThreadInfo = {| id: string, type: ThreadType, name: ?string, description: ?string, color: string, // hex, without "#" or "0x" creationTime: number, // millisecond timestamp parentThreadID: ?string, members: $ReadOnlyArray, roles: { [id: string]: RoleInfo }, sourceMessageID?: string, repliesCount: number, |}; export type ThreadStore = {| threadInfos: { [id: string]: RawThreadInfo }, inconsistencyReports: $ReadOnlyArray, |}; export type ThreadDeletionRequest = {| threadID: string, accountPassword: string, |}; export type RemoveMembersRequest = {| threadID: string, memberIDs: $ReadOnlyArray, |}; export type RoleChangeRequest = {| threadID: string, memberIDs: $ReadOnlyArray, role: string, |}; export type ChangeThreadSettingsResult = {| threadInfo?: RawThreadInfo, threadInfos?: { [id: string]: RawThreadInfo }, updatesResult: { newUpdates: $ReadOnlyArray, }, newMessageInfos: $ReadOnlyArray, |}; export type ChangeThreadSettingsPayload = {| threadID: string, updatesResult: { newUpdates: $ReadOnlyArray, }, newMessageInfos: $ReadOnlyArray, |}; export type LeaveThreadRequest = {| threadID: string, |}; export type LeaveThreadResult = {| threadInfos?: { [id: string]: RawThreadInfo }, updatesResult: { newUpdates: $ReadOnlyArray, }, |}; export type LeaveThreadPayload = {| updatesResult: { newUpdates: $ReadOnlyArray, }, |}; export type ThreadChanges = Shape<{| type: ThreadType, name: string, description: string, color: string, parentThreadID: string, newMemberIDs: $ReadOnlyArray, |}>; export type UpdateThreadRequest = {| threadID: string, changes: ThreadChanges, |}; export type BaseNewThreadRequest = {| +name?: ?string, +description?: ?string, +color?: ?string, +parentThreadID?: ?string, +initialMemberIDs?: ?$ReadOnlyArray, +ghostMemberIDs?: ?$ReadOnlyArray, |}; export type NewThreadRequest = | {| +type: 3 | 4 | 6 | 7, ...BaseNewThreadRequest, |} | {| +type: 5, +sourceMessageID: string, ...BaseNewThreadRequest, |}; export type NewThreadResponse = {| +updatesResult: {| +newUpdates: $ReadOnlyArray, |}, +newMessageInfos: $ReadOnlyArray, +newThreadInfo?: RawThreadInfo, +userInfos: { [string]: AccountUserInfo }, +newThreadID?: string, |}; export type NewThreadResult = {| +updatesResult: {| +newUpdates: $ReadOnlyArray, |}, +newMessageInfos: $ReadOnlyArray, +userInfos: { [string]: AccountUserInfo }, +newThreadID: string, |}; export type ServerThreadJoinRequest = {| threadID: string, calendarQuery?: ?CalendarQuery, |}; export type ClientThreadJoinRequest = {| threadID: string, calendarQuery: CalendarQuery, |}; export type ThreadJoinResult = {| threadInfos?: { [id: string]: RawThreadInfo }, updatesResult: { newUpdates: $ReadOnlyArray, }, rawMessageInfos: $ReadOnlyArray, truncationStatuses: MessageTruncationStatuses, userInfos: { [string]: AccountUserInfo }, rawEntryInfos?: ?$ReadOnlyArray, |}; export type ThreadJoinPayload = {| updatesResult: { newUpdates: $ReadOnlyArray, }, rawMessageInfos: RawMessageInfo[], truncationStatuses: MessageTruncationStatuses, userInfos: $ReadOnlyArray, calendarResult: CalendarResult, |}; export type SidebarInfo = {| +threadInfo: ThreadInfo, +lastUpdatedTime: number, +mostRecentNonLocalMessage: ?string, |}; // We can show a max of 3 sidebars inline underneath their parent in the chat // tab. If there are more, we show a button that opens a modal to see the rest export const maxReadSidebars = 3; // We can show a max of 5 sidebars inline underneath their parent // in the chat tab if every one of the displayed sidebars is unread export const maxUnreadSidebars = 5; diff --git a/native/calendar/entry.react.js b/native/calendar/entry.react.js index 3b1e0b51e..aebe60541 100644 --- a/native/calendar/entry.react.js +++ b/native/calendar/entry.react.js @@ -1,792 +1,792 @@ // @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 { Shape } from 'lib/types/core'; 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 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 }, + params: { thread: { 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/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js index 782e2927b..17a7b5a47 100644 --- a/native/chat/chat-input-bar.react.js +++ b/native/chat/chat-input-bar.react.js @@ -1,904 +1,906 @@ // @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, NativeAppEventEmitter, } 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 } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { trimMessage } from 'lib/shared/message-utils'; import { threadHasPermission, viewerIsMember, threadFrozenDueToViewerBlock, threadActualMembers, useRealThreadCreator, } 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 type { PhotoPaste } from 'lib/types/media-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, } 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 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, CameraModalRouteName, ImagePasteModalRouteName, } 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, +getServerThreadID: () => 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, getServerThreadID: 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; 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 = ( ); const threadColor = `#${this.props.threadInfo.color}`; 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(); }; 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.props.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, ); const showErrorAlert = () => Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], { cancelable: false, }); 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 imagePastedCallback = React.useCallback( (imagePastedEvent) => { if (props.threadInfo.id !== imagePastedEvent['threadID']) { return; } const pastedImage: PhotoPaste = { step: 'photo_paste', dimensions: { height: imagePastedEvent.height, width: imagePastedEvent.width, }, filename: imagePastedEvent.fileName, uri: 'file://' + imagePastedEvent.filePath, selectTime: 0, sendTime: 0, retries: 0, }; props.navigation.navigate({ name: ImagePasteModalRouteName, params: { imagePasteStagingInfo: pastedImage, threadID: props.threadInfo.id, }, }); }, [props.navigation, props.threadInfo.id], ); React.useEffect(() => { const imagePasteListener = NativeAppEventEmitter.addListener( 'imagePasted', imagePastedCallback, ); return () => imagePasteListener.remove(); }, [imagePastedCallback]); const getServerThreadID = useRealThreadCreator( - props.threadInfo, - props.route.params.sidebarSourceMessageID, + { + threadInfo: props.threadInfo, + sourceMessageID: props.route.params.thread.sourceMessageID, + }, showErrorAlert, ); return ( ); }); diff --git a/native/chat/chat-thread-list.react.js b/native/chat/chat-thread-list.react.js index 9e62ce3fc..bf86950ca 100644 --- a/native/chat/chat-thread-list.react.js +++ b/native/chat/chat-thread-list.react.js @@ -1,425 +1,427 @@ // @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 { searchUsers } from 'lib/actions/user-actions'; import { type ChatThreadItem, useFlattenedChatListData, } from 'lib/selectors/chat-selectors'; import { threadSearchIndex as threadSearchIndexSelector } from 'lib/selectors/nav-selectors'; import { usersWithPersonalThreadSelector } from 'lib/selectors/user-selectors'; import SearchIndex from 'lib/shared/search-index'; import { createPendingThread, createPendingThreadItem, threadIsTopLevel, } from 'lib/shared/thread-utils'; import type { UserSearchResult } from 'lib/types/search-types'; import type { ThreadInfo } from 'lib/types/thread-types'; import { threadTypes } from 'lib/types/thread-types'; import type { GlobalAccountUserInfo, UserInfo } from 'lib/types/user-types'; import { useServerCall } from 'lib/utils/action-utils'; import Search from '../components/search.react'; import type { TabNavigationProp } from '../navigation/app-navigator.react'; import { 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, +usersWithPersonalThread: $ReadOnlySet, // async functions that hit server APIs +searchUsers: (usernamePrefix: string) => Promise, |}; type State = {| +searchText: string, +threadsSearchResults: Set, +usersSearchResults: $ReadOnlyArray, +openedSwipeableId: string, |}; type PropsAndState = {| ...Props, ...State |}; class ChatThreadList extends React.PureComponent { state: State = { searchText: '', threadsSearchResults: new Set(), usersSearchResults: [], 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.type === 'chatThreadItem') { return item.threadInfo.id; } else if (item.type === 'empty') { 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.threadsSearchResults, (propsAndState: PropsAndState) => propsAndState.emptyItem, (propsAndState: PropsAndState) => propsAndState.usersSearchResults, ( reduxChatListData: $ReadOnlyArray, searchText: string, threadsSearchResults: Set, emptyItem?: React.ComponentType<{||}>, usersSearchResults: $ReadOnlyArray, ): Item[] => { const chatItems = []; if (!searchText) { chatItems.push( ...reduxChatListData.filter( (item) => threadIsTopLevel(item.threadInfo) && this.props.filterThreads(item.threadInfo), ), ); } else { const personalThreads = []; const nonPersonalThreads = []; for (const item of reduxChatListData) { if (!threadsSearchResults.has(item.threadInfo.id)) { continue; } if (item.threadInfo.type === threadTypes.PERSONAL) { personalThreads.push({ ...item, sidebars: [] }); } else { nonPersonalThreads.push({ ...item, sidebars: [] }); } } chatItems.push(...personalThreads, ...nonPersonalThreads); const { viewerID } = this.props; if (viewerID) { chatItems.push( ...usersSearchResults.map((user) => createPendingThreadItem(viewerID, user), ), ); } } 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; }; async searchUsers(usernamePrefix: string) { if (usernamePrefix.length === 0) { return []; } const { userInfos } = await this.props.searchUsers(usernamePrefix); return userInfos.filter( (info) => !this.props.usersWithPersonalThread.has(info.id) && info.id !== this.props.viewerID, ); } onChangeSearchText = async (searchText: string) => { const results = this.props.threadSearchIndex.getSearchResults(searchText); this.setState({ searchText, threadsSearchResults: new Set(results) }); const usersSearchResults = await this.searchUsers(searchText); this.setState({ usersSearchResults }); }; onPressItem = ( threadInfo: ThreadInfo, pendingPersonalThreadUserInfo?: UserInfo, ) => { this.onChangeSearchText(''); if (this.searchInput) { this.searchInput.blur(); } this.props.navigation.navigate({ name: MessageListRouteName, - params: { threadInfo, pendingPersonalThreadUserInfo }, + params: { thread: { threadInfo }, pendingPersonalThreadUserInfo }, 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 = () => { if (this.props.viewerID) { this.props.navigation.navigate({ name: MessageListRouteName, params: { - threadInfo: createPendingThread({ - viewerID: this.props.viewerID, - threadType: threadTypes.CHAT_SECRET, - }), + thread: { + threadInfo: createPendingThread({ + viewerID: this.props.viewerID, + threadType: threadTypes.CHAT_SECRET, + }), + }, searching: true, }, }); } }; } 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 = useFlattenedChatListData(); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const threadSearchIndex = useSelector(threadSearchIndexSelector); const styles = useStyles(unboundStyles); const indicatorStyle = useSelector(indicatorStyleSelector); const callSearchUsers = useServerCall(searchUsers); const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); return ( ); }); diff --git a/native/chat/chat.react.js b/native/chat/chat.react.js index 1e78897e0..20999bc1f 100644 --- a/native/chat/chat.react.js +++ b/native/chat/chat.react.js @@ -1,263 +1,264 @@ // @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 { threadIsPending } from 'lib/shared/thread-utils'; 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' && !threadIsPending(route.params.threadInfo.id) + Platform.OS === 'android' && + !threadIsPending(route.params.thread.threadInfo.id) ? // 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 74f99f790..ef05bdc9a 100644 --- a/native/chat/compose-thread-button.react.js +++ b/native/chat/compose-thread-button.react.js @@ -1,70 +1,72 @@ // @flow import * as React from 'react'; import { StyleSheet } from 'react-native'; import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; import { createPendingThread } from 'lib/shared/thread-utils'; import { threadTypes } from 'lib/types/thread-types'; import Button from '../components/button.react'; import { MessageListRouteName } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { type Colors, useColors } from '../themes/colors'; import type { ChatNavigationProp } from './chat.react'; type BaseProps = {| +navigate: $PropertyType, 'navigate'>, |}; type Props = {| ...BaseProps, +colors: Colors, +viewerID: ?string, |}; class ComposeThreadButton extends React.PureComponent { render() { const { link: linkColor } = this.props.colors; return ( ); } onPress = () => { if (this.props.viewerID) { this.props.navigate({ name: MessageListRouteName, params: { - threadInfo: createPendingThread({ - viewerID: this.props.viewerID, - threadType: threadTypes.CHAT_SECRET, - }), + thread: { + threadInfo: createPendingThread({ + viewerID: this.props.viewerID, + threadType: threadTypes.CHAT_SECRET, + }), + }, searching: true, }, }); } }; } const styles = StyleSheet.create({ composeButton: { paddingHorizontal: 10, }, }); export default React.memo(function ConnectedComposeThreadButton( props, ) { const colors = useColors(); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); return ; }); diff --git a/native/chat/compose-thread.react.js b/native/chat/compose-thread.react.js index 7ed05f0e6..f3202f8ad 100644 --- a/native/chat/compose-thread.react.js +++ b/native/chat/compose-thread.react.js @@ -1,526 +1,526 @@ // @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 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), (propsAndState: PropsAndState) => propsAndState.route.params.threadType, ( text: string, userInfos: { [id: string]: AccountUserInfo }, searchIndex: SearchIndex, userInfoInputArray: $ReadOnlyArray, parentThreadInfo: ?ThreadInfo, threadType: ?ThreadType, ) => getPotentialMemberItems( text, userInfos, searchIndex, userInfoInputArray.map((userInfo) => userInfo.id), parentThreadInfo, threadType, ), ); 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 ?? threadTypes.CHAT_SECRET; const initialMemberIDs = this.state.userInfoInputArray.map( (userInfo: AccountUserInfo) => userInfo.id, ); const parentThreadInfo = ComposeThread.getParentThreadInfo(this.props); invariant( threadType !== 5, 'Creating sidebars from thread composer is not yet supported', ); 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 }, + params: { thread: { 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/inline-sidebar.react.js b/native/chat/inline-sidebar.react.js index 65b6fb1bc..4b277df6b 100644 --- a/native/chat/inline-sidebar.react.js +++ b/native/chat/inline-sidebar.react.js @@ -1,128 +1,128 @@ // @flow import { useNavigation } from '@react-navigation/native'; import * as React from 'react'; import { Text, View } from 'react-native'; import Icon from 'react-native-vector-icons/Feather'; import { relativeMemberInfoSelectorForMembersOfThread } from 'lib/selectors/user-selectors'; import { stringForUser } from 'lib/shared/user-utils'; import type { ThreadInfo } from 'lib/types/thread-types'; import { pluralizeAndTrim } from 'lib/utils/text-utils'; import Button from '../components/button.react'; import { MessageListRouteName } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { useStyles } from '../themes/colors'; import type { ChatNavigationProp } from './chat.react'; type Props = {| +threadInfo: ThreadInfo, +positioning: 'left' | 'center' | 'right', |}; function InlineSidebar(props: Props) { const { threadInfo } = props; const navigation: ChatNavigationProp<'MessageList'> = (useNavigation(): any); const onPress = React.useCallback(() => { navigation.navigate({ name: MessageListRouteName, - params: { threadInfo }, + params: { thread: { threadInfo } }, key: `${MessageListRouteName}${threadInfo.id}`, }); }, [navigation, threadInfo]); const styles = useStyles(unboundStyles); let viewerIcon, nonViewerIcon, alignStyle; if (props.positioning === 'right') { viewerIcon = ; alignStyle = styles.rightAlign; } else if (props.positioning === 'left') { nonViewerIcon = ( ); alignStyle = styles.leftAlign; } else { nonViewerIcon = ( ); alignStyle = styles.centerAlign; } const unreadStyle = threadInfo.currentUser.unread ? styles.unread : null; const repliesCount = threadInfo.repliesCount || 1; const repliesText = `${repliesCount} ${ repliesCount > 1 ? 'replies' : 'reply' }`; const threadMembers = useSelector( relativeMemberInfoSelectorForMembersOfThread(threadInfo.id), ); const sendersText = React.useMemo(() => { const senders = threadMembers .filter((member) => member.isSender) .map(stringForUser); return senders.length > 0 ? `${pluralizeAndTrim(senders, 25)} sent ` : ''; }, [threadMembers]); return ( ); } const inlineSidebarHeight = 20; const inlineSidebarMarginTop = 5; const inlineSidebarMarginBottom = 3; const unboundStyles = { content: { flexDirection: 'row', marginRight: 30, marginLeft: 10, flex: 1, height: inlineSidebarHeight, }, unread: { fontWeight: 'bold', }, sidebar: { flexDirection: 'row', display: 'flex', alignItems: 'center', }, icon: { color: 'listForegroundTertiaryLabel', }, name: { paddingTop: 1, color: 'listForegroundTertiaryLabel', fontSize: 16, paddingLeft: 4, paddingRight: 2, }, leftAlign: { justifyContent: 'flex-start', }, rightAlign: { justifyContent: 'flex-end', }, centerAlign: { justifyContent: 'center', }, }; export { InlineSidebar, inlineSidebarHeight, inlineSidebarMarginTop, inlineSidebarMarginBottom, }; diff --git a/native/chat/inner-robotext-message.react.js b/native/chat/inner-robotext-message.react.js index 344571061..822df52f8 100644 --- a/native/chat/inner-robotext-message.react.js +++ b/native/chat/inner-robotext-message.react.js @@ -1,165 +1,165 @@ // @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 { splitRobotext, parseRobotextEntity, robotextToRawString, } from 'lib/shared/message-utils'; import Markdown from '../markdown/markdown.react'; import { inlineMarkdownRules } from '../markdown/rules.react'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { MessageListRouteName } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { useOverlayStyles } from '../themes/colors'; import type { ChatNavigationProp } from './chat.react'; import type { ChatRobotextMessageInfoItemWithHeight } from './robotext-message.react'; function dummyNodeForRobotextMessageHeightMeasurement(robotext: string) { return ( {robotextToRawString(robotext)} ); } type InnerRobotextMessageProps = {| +item: ChatRobotextMessageInfoItemWithHeight, +navigation: | ChatNavigationProp<'MessageList'> | AppNavigationProp<'RobotextMessageTooltipModal'>, +onPress: () => void, +onLongPress?: () => void, |}; function InnerRobotextMessage(props: InnerRobotextMessageProps) { const { item, navigation, onLongPress, onPress } = props; const activeTheme = useSelector((state) => state.globalThemeInfo.activeTheme); const styles = useOverlayStyles(unboundStyles); const { robotext } = item; const robotextParts = splitRobotext(robotext); const textParts = []; let keyIndex = 0; for (const 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 }); } return ( {textParts} ); } type ThreadEntityProps = {| +id: string, +name: string, +navigation: | ChatNavigationProp<'MessageList'> | AppNavigationProp<'RobotextMessageTooltipModal'>, |}; function ThreadEntity(props: ThreadEntityProps) { const threadID = props.id; const threadInfo = useSelector( (state) => threadInfoSelector(state)[threadID], ); const styles = useOverlayStyles(unboundStyles); const { navigate } = props.navigation; const onPressThread = React.useCallback(() => { invariant(threadInfo, 'onPressThread should have threadInfo'); navigate({ name: MessageListRouteName, - params: { threadInfo }, + params: { thread: { 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 { dummyNodeForRobotextMessageHeightMeasurement, InnerRobotextMessage }; diff --git a/native/chat/message-list-container.react.js b/native/chat/message-list-container.react.js index ca5e4ef85..55794273a 100644 --- a/native/chat/message-list-container.react.js +++ b/native/chat/message-list-container.react.js @@ -1,515 +1,520 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { type ChatMessageItem, messageListData as messageListDataSelector, messageInfoSelector, getSourceMessageChatItemForPendingSidebar, } from 'lib/selectors/chat-selectors'; import { threadInfoSelector, threadInfoFromSourceMessageIDSelector, } from 'lib/selectors/thread-selectors'; import { userInfoSelectorForPotentialMembers, userSearchIndexForPotentialMembers, } from 'lib/selectors/user-selectors'; import { messageID } from 'lib/shared/message-utils'; import { getPotentialMemberItems } from 'lib/shared/search-utils'; import { createPendingThread, getCurrentUser, getPendingThreadKey, pendingThreadType, threadHasAdminRole, threadIsPending, } from 'lib/shared/thread-utils'; import { messageTypes } from 'lib/types/message-types'; import { type ThreadInfo, threadTypes } from 'lib/types/thread-types'; import type { AccountUserInfo, UserListItem } from 'lib/types/user-types'; 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 { dummyNodeForRobotextMessageHeightMeasurement } from './inner-robotext-message.react'; import { dummyNodeForTextMessageHeightMeasurement } from './inner-text-message.react'; import MessageListThreadSearch from './message-list-thread-search.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'; export type ChatMessageItemWithHeight = | {| itemType: 'loader' |} | ChatMessageInfoItemWithHeight; type BaseProps = {| +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, |}; type Props = {| ...BaseProps, // Redux state +usernameInputText: string, +updateUsernameInput: (text: string) => void, +userInfoInputArray: $ReadOnlyArray, +updateTagInput: (items: $ReadOnlyArray) => void, +otherUserInfos: { [id: string]: AccountUserInfo }, +userSearchResults: $ReadOnlyArray, +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; get frozen() { const { overlayContext } = this.props; invariant( overlayContext, 'MessageListContainer should have OverlayContext', ); return overlayContext.scrollBlockingModalStatus !== 'closed'; } componentDidUpdate(prevProps: Props) { 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, styles } = this.props; const { listDataWithHeights } = this.state; const { searching } = this.props.route.params; let searchComponent = null; if (searching) { searchComponent = ( ); } const showMessageList = !searching || this.props.userInfoInputArray.length > 0; let threadContent = null; if (showMessageList) { let messageList; if (listDataWithHeights) { messageList = ( ); } else { messageList = ( ); } threadContent = ( {messageList} ); } return ( {searchComponent} {threadContent} ); } 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; invariant( messageInfo.type !== messageTypes.SIDEBAR_SOURCE, 'Sidebar source messages should be replaced by sourceMessage before being measured', ); const { threadInfo } = 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, threadCreatedFromMessage: item.threadCreatedFromMessage, 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, threadCreatedFromMessage: item.threadCreatedFromMessage, 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, threadCreatedFromMessage: item.threadCreatedFromMessage, 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, }, threadContent: { flex: 1, }, }; export default React.memo(function ConnectedMessageListContainer( props: BaseProps, ) { const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const [usernameInputText, setUsernameInputText] = React.useState(''); const [userInfoInputArray, setUserInfoInputArray] = React.useState< $ReadOnlyArray, >([]); const updateTagInput = React.useCallback( (input: $ReadOnlyArray) => setUserInfoInputArray(input), [], ); const updateUsernameInput = React.useCallback( (text: string) => setUsernameInputText(text), [], ); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const userSearchResults = React.useMemo( () => getPotentialMemberItems( usernameInputText, otherUserInfos, userSearchIndex, userInfoInputArray.map((userInfo) => userInfo.id), ), [usernameInputText, otherUserInfos, userSearchIndex, userInfoInputArray], ); const threadInfos = useSelector(threadInfoSelector); const userInfos = useSelector((state) => state.userStore.userInfos); - const threadInfoRef = React.useRef(props.route.params.threadInfo); + const threadInfoRef = React.useRef(props.route.params.thread.threadInfo); const [originalThreadInfo, setOriginalThreadInfo] = React.useState( - props.route.params.threadInfo, + props.route.params.thread.threadInfo, ); const { searching } = props.route.params; const inputState = React.useContext(InputStateContext); const hideSearch = React.useCallback(() => { setOriginalThreadInfo(threadInfoRef.current); props.navigation.setParams({ searching: false, }); }, [props.navigation]); React.useEffect(() => { if (!searching) { return; } inputState?.registerSendCallback(hideSearch); return () => inputState?.unregisterSendCallback(hideSearch); }, [hideSearch, inputState, searching]); const threadCandidates = React.useMemo(() => { const infos = new Map(); for (const threadID in threadInfos) { const info = threadInfos[threadID]; if (info.parentThreadID || threadHasAdminRole(info)) { continue; } const key = getPendingThreadKey(info.members.map((member) => member.id)); const indexedThread = infos.get(key); if (!indexedThread || info.creationTime < indexedThread.creationTime) { infos.set(key, info); } } return infos; }, [threadInfos]); - const { sidebarSourceMessageID } = props.route.params; + const { sourceMessageID } = props.route.params.thread; const sidebarCandidate = useSelector((state) => { - if (!sidebarSourceMessageID) { + if (!sourceMessageID) { return null; } - return threadInfoFromSourceMessageIDSelector(state)[sidebarSourceMessageID]; + return threadInfoFromSourceMessageIDSelector(state)[sourceMessageID]; }); const latestThreadInfo = React.useMemo((): ?ThreadInfo => { const threadInfoFromParams = originalThreadInfo; const threadInfoFromStore = threadInfos[threadInfoFromParams.id]; if (threadInfoFromStore) { return threadInfoFromStore; } else if (!viewerID || !threadIsPending(threadInfoFromParams.id)) { return undefined; } const pendingThreadMemberIDs = searching ? [...userInfoInputArray.map((user) => user.id), viewerID] : threadInfoFromParams.members.map((member) => member.id); const threadKey = getPendingThreadKey(pendingThreadMemberIDs); if ( threadInfoFromParams.type !== threadTypes.SIDEBAR && threadCandidates.get(threadKey) ) { return threadCandidates.get(threadKey); } if (sidebarCandidate) { return sidebarCandidate; } const updatedThread = searching ? createPendingThread({ viewerID, threadType: pendingThreadType(userInfoInputArray.length), members: userInfoInputArray, }) : threadInfoFromParams; return { ...updatedThread, currentUser: getCurrentUser(updatedThread, viewerID, userInfos), }; }, [ originalThreadInfo, threadInfos, viewerID, searching, userInfoInputArray, threadCandidates, sidebarCandidate, userInfos, ]); if (latestThreadInfo) { threadInfoRef.current = latestThreadInfo; } const threadInfo = threadInfoRef.current; const { setParams } = props.navigation; React.useEffect(() => { - setParams({ threadInfo }); - }, [setParams, threadInfo]); + setParams({ + thread: { + threadInfo, + sourceMessageID, + }, + }); + }, [setParams, sourceMessageID, threadInfo]); const threadID = threadInfoRef.current.id; const boundMessageListData = useSelector(messageListDataSelector(threadID)); const sidebarSourceMessageInfo = useSelector((state) => - sidebarSourceMessageID && !sidebarCandidate - ? messageInfoSelector(state)[sidebarSourceMessageID] + sourceMessageID && !sidebarCandidate + ? messageInfoSelector(state)[sourceMessageID] : null, ); invariant( !sidebarSourceMessageInfo || sidebarSourceMessageInfo.type !== messageTypes.SIDEBAR_SOURCE, 'sidebars can not be created from sidebar_source message', ); const messageListData = React.useMemo(() => { if (searching && userInfoInputArray.length === 0) { return []; } else if (sidebarSourceMessageInfo) { return [ getSourceMessageChatItemForPendingSidebar( sidebarSourceMessageInfo, threadInfos, ), ]; } return boundMessageListData; }, [ searching, userInfoInputArray.length, sidebarSourceMessageInfo, boundMessageListData, threadInfos, ]); const composedMessageMaxWidth = useSelector(composedMessageMaxWidthSelector); const colors = useColors(); const styles = useStyles(unboundStyles); const overlayContext = React.useContext(OverlayContext); const messageListContext = useMessageListContext(threadID); return ( ); }); diff --git a/native/chat/message-list-types.js b/native/chat/message-list-types.js index c3b20da5a..d8ecde9b0 100644 --- a/native/chat/message-list-types.js +++ b/native/chat/message-list-types.js @@ -1,59 +1,58 @@ // @flow import PropTypes from 'prop-types'; import * as React from 'react'; -import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; +import { optimisticThreadInfoPropType } from 'lib/types/thread-types'; +import type { OptimisticThreadInfo } 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, - searching?: boolean, - sidebarSourceMessageID?: string, + +thread: OptimisticThreadInfo, + +pendingPersonalThreadUserInfo?: UserInfo, + +searching?: boolean, |}; const messageListRoutePropType = PropTypes.shape({ key: PropTypes.string.isRequired, params: PropTypes.shape({ - threadInfo: threadInfoPropType.isRequired, + thread: optimisticThreadInfoPropType.isRequired, pendingPersonalThreadUserInfo: userInfoPropType, searching: PropTypes.bool, - sidebarSourceMessageID: PropTypes.string, }).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/settings/thread-settings-child-thread.react.js b/native/chat/settings/thread-settings-child-thread.react.js index 143c32c00..f59a9074c 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 * 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 }, + params: { thread: { 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-parent.react.js b/native/chat/settings/thread-settings-parent.react.js index d1a3737ad..a7749bd4c 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 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 }, + params: { thread: { 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/sidebar-list-modal.react.js b/native/chat/sidebar-list-modal.react.js index 45acf6f52..56640bc52 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 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 }, + params: { thread: { 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/sidebar-navigation.js b/native/chat/sidebar-navigation.js index 049b55cb9..450688de4 100644 --- a/native/chat/sidebar-navigation.js +++ b/native/chat/sidebar-navigation.js @@ -1,98 +1,102 @@ // @flow import invariant from 'invariant'; import { createPendingSidebar } from 'lib/shared/thread-utils'; import type { DispatchFunctions, ActionFunc, BoundServerCall, } from 'lib/utils/action-utils'; import type { InputState } from '../input/input-state'; import { getDefaultTextMessageRules } from '../markdown/rules.react'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { MessageListRouteName } from '../navigation/route-names'; import type { TooltipRoute } from '../navigation/tooltip.react'; function onPressGoToSidebar( route: | TooltipRoute<'RobotextMessageTooltipModal'> | TooltipRoute<'TextMessageTooltipModal'> | TooltipRoute<'MultimediaTooltipModal'>, dispatchFunctions: DispatchFunctions, bindServerCall: (serverCall: ActionFunc) => BoundServerCall, inputState: ?InputState, navigation: | AppNavigationProp<'RobotextMessageTooltipModal'> | AppNavigationProp<'TextMessageTooltipModal'> | AppNavigationProp<'MultimediaTooltipModal'>, ) { let threadCreatedFromMessage; // Necessary for Flow... if (route.name === 'RobotextMessageTooltipModal') { threadCreatedFromMessage = route.params.item.threadCreatedFromMessage; } else { threadCreatedFromMessage = route.params.item.threadCreatedFromMessage; } invariant( threadCreatedFromMessage, 'threadCreatedFromMessage should be set in onPressGoToSidebar', ); navigation.navigate({ name: MessageListRouteName, params: { - threadInfo: threadCreatedFromMessage, + thread: { + threadInfo: threadCreatedFromMessage, + }, }, key: `${MessageListRouteName}${threadCreatedFromMessage.id}`, }); } function onPressCreateSidebar( route: | TooltipRoute<'RobotextMessageTooltipModal'> | TooltipRoute<'TextMessageTooltipModal'> | TooltipRoute<'MultimediaTooltipModal'>, dispatchFunctions: DispatchFunctions, bindServerCall: (serverCall: ActionFunc) => BoundServerCall, inputState: ?InputState, navigation: | AppNavigationProp<'RobotextMessageTooltipModal'> | AppNavigationProp<'TextMessageTooltipModal'> | AppNavigationProp<'MultimediaTooltipModal'>, viewerID: ?string, ) { invariant( viewerID, 'viewerID should be set in TextMessageTooltipModal.onPressCreateSidebar', ); let itemFromParams; // Necessary for Flow... if (route.name === 'RobotextMessageTooltipModal') { itemFromParams = route.params.item; } else { itemFromParams = route.params.item; } const { messageInfo, threadInfo } = itemFromParams; const pendingSidebarInfo = createPendingSidebar( messageInfo, threadInfo, viewerID, getDefaultTextMessageRules().simpleMarkdownRules, ); const sourceMessageID = messageInfo.id; navigation.navigate({ name: MessageListRouteName, params: { - threadInfo: pendingSidebarInfo, - sidebarSourceMessageID: sourceMessageID, + thread: { + threadInfo: pendingSidebarInfo, + sourceMessageID, + }, }, key: `${MessageListRouteName}${pendingSidebarInfo.id}`, }); } export { onPressGoToSidebar, onPressCreateSidebar }; diff --git a/native/navigation/navigation-utils.js b/native/navigation/navigation-utils.js index ff6dbc56d..1c8c4ce87 100644 --- a/native/navigation/navigation-utils.js +++ b/native/navigation/navigation-utils.js @@ -1,160 +1,161 @@ // @flow import type { PossiblyStaleNavigationState, PossiblyStaleRoute, StaleLeafRoute, ScreenParams, } from '@react-navigation/native'; import invariant from 'invariant'; import { ComposeThreadRouteName, AppRouteName, threadRoutes, } from './route-names'; function getStateFromNavigatorRoute( route: PossiblyStaleRoute<>, ): PossiblyStaleNavigationState { const key = route.key ? route.key : `unkeyed ${route.name}`; invariant(route.state, `expecting Route for ${key} to be NavigationState`); return route.state; } function getThreadIDFromParams(params: ?ScreenParams): string { invariant( params && - params.threadInfo && - typeof params.threadInfo === 'object' && - params.threadInfo.id && - typeof params.threadInfo.id === 'string', + params.thread && + params.thread.threadInfo && + typeof params.thread.threadInfo === 'object' && + params.thread.threadInfo.id && + typeof params.thread.threadInfo.id === 'string', "there's no way in react-navigation/Flow to type this", ); - return params.threadInfo.id; + return params.thread.threadInfo.id; } function getParentThreadIDFromParams(params: ?ScreenParams): ?string { if (!params) { return undefined; } const { parentThreadInfo } = params; if (!parentThreadInfo) { return undefined; } invariant( typeof parentThreadInfo === 'object' && parentThreadInfo.id && typeof parentThreadInfo.id === 'string', "there's no way in react-navigation/Flow to type this", ); return parentThreadInfo.id; } function getThreadIDFromRoute( route: PossiblyStaleRoute<>, routes?: $ReadOnlyArray = threadRoutes, ) { if (!routes.includes(route.name)) { return null; } if (route.name === ComposeThreadRouteName) { return getParentThreadIDFromParams(route.params); } return getThreadIDFromParams(route.params); } function currentRouteRecurse(route: PossiblyStaleRoute<>): StaleLeafRoute<> { if (!route.state) { return route; } const state = getStateFromNavigatorRoute(route); return currentRouteRecurse(state.routes[state.index]); } function currentLeafRoute( state: PossiblyStaleNavigationState, ): StaleLeafRoute<> { return currentRouteRecurse(state.routes[state.index]); } function findRouteIndexWithKey( state: PossiblyStaleNavigationState, key: string, ): ?number { for (let i = 0; i < state.routes.length; i++) { const route = state.routes[i]; if (route.key === key) { return i; } } return null; } // This function walks from the back of the stack and calls filterFunc on each // screen until the stack is exhausted or filterFunc returns "break". A screen // will be removed if and only if filterFunc returns "remove" (not "break"). function removeScreensFromStack< Route, State: { +routes: $ReadOnlyArray, +index: number }, >( state: State, filterFunc: (route: Route) => 'keep' | 'remove' | 'break', ): State { const newRoutes = []; let newIndex = state.index; let screenRemoved = false; let breakActivated = false; for (let i = state.routes.length - 1; i >= 0; i--) { const route = state.routes[i]; if (breakActivated) { newRoutes.unshift(route); continue; } const result = filterFunc(route); if (result === 'break') { breakActivated = true; } if (breakActivated || result === 'keep') { newRoutes.unshift(route); continue; } screenRemoved = true; if (newIndex >= i) { invariant( newIndex !== 0, 'Attempting to remove current route and all before it', ); newIndex--; } } if (!screenRemoved) { return state; } return { ...state, index: newIndex, routes: newRoutes, }; } function validNavState(state: PossiblyStaleNavigationState) { if (state.routes.length === 0) { return false; } const [firstRoute] = state.routes; if (firstRoute.name !== AppRouteName) { return false; } return true; } export { getStateFromNavigatorRoute, getThreadIDFromParams, getThreadIDFromRoute, currentLeafRoute, findRouteIndexWithKey, removeScreensFromStack, validNavState, }; diff --git a/native/push/push-handler.react.js b/native/push/push-handler.react.js index 62799aa08..994cbdd02 100644 --- a/native/push/push-handler.react.js +++ b/native/push/push-handler.react.js @@ -1,657 +1,657 @@ // @flow import PropTypes from 'prop-types'; import * as React from 'react'; import { AppRegistry, Platform, Alert, Vibration, LogBox } from 'react-native'; import type { RemoteMessage, NotificationOpen } from 'react-native-firebase'; import { Notification as InAppNotification, TapticFeedback, } from 'react-native-in-app-message'; import NotificationsIOS from 'react-native-notifications'; import { useDispatch } from 'react-redux'; import { setDeviceTokenActionTypes, setDeviceToken, } from 'lib/actions/device-actions'; import { unreadCount, threadInfoSelector, } from 'lib/selectors/thread-selectors'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import { mergePrefixIntoBody } from 'lib/shared/notif-utils'; import type { Dispatch } from 'lib/types/redux-types'; import { type ConnectionInfo, connectionInfoPropType, } from 'lib/types/socket-types'; import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; import { addLifecycleListener, getCurrentLifecycleState, } from '../lifecycle/lifecycle'; import { replaceWithThreadActionType } from '../navigation/action-types'; import { activeMessageListSelector } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import type { RootNavigationProp } from '../navigation/root-navigator.react'; import { MessageListRouteName } from '../navigation/route-names'; import { recordNotifPermissionAlertActionType, clearAndroidNotificationsActionType, } from '../redux/action-types'; import { useSelector } from '../redux/redux-utils'; import { RootContext, type RootContextType, rootContextPropType, } from '../root-context'; import { type GlobalTheme, globalThemePropType } from '../types/themes'; import { type NotifPermissionAlertInfo, notifPermissionAlertInfoPropType, } from './alerts'; import { androidNotificationChannelID, handleAndroidMessage, androidBackgroundMessageTask, } from './android'; import { getFirebase } from './firebase'; import InAppNotif from './in-app-notif.react'; import { requestIOSPushPermissions, iosPushPermissionResponseReceived, } from './ios'; import { saveMessageInfos } from './utils'; LogBox.ignoreLogs([ // react-native-firebase 'Require cycle: ../node_modules/react-native-firebase', // react-native-in-app-message 'ForceTouchGestureHandler is not available', ]); const msInDay = 24 * 60 * 60 * 1000; const supportsTapticFeedback = Platform.OS === 'ios' && parseInt(Platform.Version, 10) >= 10; type BaseProps = {| +navigation: RootNavigationProp<'App'>, |}; type Props = {| ...BaseProps, // Navigation state +activeThread: ?string, // Redux state +unreadCount: number, +deviceToken: ?string, +threadInfos: { [id: string]: ThreadInfo }, +notifPermissionAlertInfo: NotifPermissionAlertInfo, +connection: ConnectionInfo, +updatesCurrentAsOf: number, +activeTheme: ?GlobalTheme, +loggedIn: boolean, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +setDeviceToken: (deviceToken: string) => Promise, // withRootContext +rootContext: ?RootContextType, |}; type State = {| +inAppNotifProps: ?{| customComponent: React.Node, blurType: ?('xlight' | 'dark'), onPress: () => void, |}, |}; class PushHandler extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ navigate: PropTypes.func.isRequired, }).isRequired, activeThread: PropTypes.string, unreadCount: PropTypes.number.isRequired, deviceToken: PropTypes.string, threadInfos: PropTypes.objectOf(threadInfoPropType).isRequired, notifPermissionAlertInfo: notifPermissionAlertInfoPropType.isRequired, connection: connectionInfoPropType.isRequired, updatesCurrentAsOf: PropTypes.number.isRequired, activeTheme: globalThemePropType, loggedIn: PropTypes.bool.isRequired, dispatch: PropTypes.func.isRequired, dispatchActionPromise: PropTypes.func.isRequired, setDeviceToken: PropTypes.func.isRequired, rootContext: rootContextPropType, }; state: State = { inAppNotifProps: null, }; currentState: ?string = getCurrentLifecycleState(); appStarted = 0; androidTokenListener: ?() => void = null; androidMessageListener: ?() => void = null; androidNotifOpenListener: ?() => void = null; initialAndroidNotifHandled = false; openThreadOnceReceived: Set = new Set(); lifecycleSubscription: ?{ +remove: () => void }; componentDidMount() { this.appStarted = Date.now(); this.lifecycleSubscription = addLifecycleListener( this.handleAppStateChange, ); this.onForeground(); if (Platform.OS === 'ios') { NotificationsIOS.addEventListener( 'remoteNotificationsRegistered', this.registerPushPermissions, ); NotificationsIOS.addEventListener( 'remoteNotificationsRegistrationFailed', this.failedToRegisterPushPermissions, ); NotificationsIOS.addEventListener( 'notificationReceivedForeground', this.iosForegroundNotificationReceived, ); NotificationsIOS.addEventListener( 'notificationOpened', this.iosNotificationOpened, ); } else if (Platform.OS === 'android') { const firebase = getFirebase(); const channel = new firebase.notifications.Android.Channel( androidNotificationChannelID, 'Default', firebase.notifications.Android.Importance.Max, ).setDescription('SquadCal notifications channel'); firebase.notifications().android.createChannel(channel); this.androidTokenListener = firebase .messaging() .onTokenRefresh(this.handleAndroidDeviceToken); this.androidMessageListener = firebase .messaging() .onMessage(this.androidMessageReceived); this.androidNotifOpenListener = firebase .notifications() .onNotificationOpened(this.androidNotificationOpened); } } componentWillUnmount() { if (this.lifecycleSubscription) { this.lifecycleSubscription.remove(); } if (Platform.OS === 'ios') { NotificationsIOS.removeEventListener( 'remoteNotificationsRegistered', this.registerPushPermissions, ); NotificationsIOS.removeEventListener( 'remoteNotificationsRegistrationFailed', this.failedToRegisterPushPermissions, ); NotificationsIOS.removeEventListener( 'notificationReceivedForeground', this.iosForegroundNotificationReceived, ); NotificationsIOS.removeEventListener( 'notificationOpened', this.iosNotificationOpened, ); } else if (Platform.OS === 'android') { if (this.androidTokenListener) { this.androidTokenListener(); this.androidTokenListener = null; } if (this.androidMessageListener) { this.androidMessageListener(); this.androidMessageListener = null; } if (this.androidNotifOpenListener) { this.androidNotifOpenListener(); this.androidNotifOpenListener = null; } } } handleAppStateChange = (nextState: ?string) => { if (!nextState || nextState === 'unknown') { return; } const lastState = this.currentState; this.currentState = nextState; if (lastState === 'background' && nextState === 'active') { this.onForeground(); this.clearNotifsOfThread(); } }; onForeground() { if (this.props.loggedIn) { this.ensurePushNotifsEnabled(); } else if (this.props.deviceToken) { // We do this in case there was a crash, so we can clear deviceToken from // any other cookies it might be set for this.setDeviceToken(this.props.deviceToken); } } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.activeThread !== prevProps.activeThread) { this.clearNotifsOfThread(); } if ( this.props.connection.status === 'connected' && (prevProps.connection.status !== 'connected' || this.props.unreadCount !== prevProps.unreadCount) ) { this.updateBadgeCount(); } for (let threadID of this.openThreadOnceReceived) { const threadInfo = this.props.threadInfos[threadID]; if (threadInfo) { this.navigateToThread(threadInfo, false); this.openThreadOnceReceived.clear(); break; } } if ( (this.props.loggedIn && !prevProps.loggedIn) || (!this.props.deviceToken && prevProps.deviceToken) ) { this.ensurePushNotifsEnabled(); } if (!this.props.loggedIn && prevProps.loggedIn) { this.clearAllNotifs(); } if ( this.state.inAppNotifProps && this.state.inAppNotifProps !== prevState.inAppNotifProps ) { if (supportsTapticFeedback) { TapticFeedback.impact(); } else { Vibration.vibrate(400); } InAppNotification.show(); } } updateBadgeCount() { const curUnreadCount = this.props.unreadCount; if (Platform.OS === 'ios') { NotificationsIOS.setBadgesCount(curUnreadCount); } else if (Platform.OS === 'android') { getFirebase().notifications().setBadge(curUnreadCount); } } clearAllNotifs() { if (Platform.OS === 'ios') { NotificationsIOS.removeAllDeliveredNotifications(); } else if (Platform.OS === 'android') { getFirebase().notifications().removeAllDeliveredNotifications(); } } clearNotifsOfThread() { const { activeThread } = this.props; if (!activeThread) { return; } if (Platform.OS === 'ios') { NotificationsIOS.getDeliveredNotifications((notifications) => PushHandler.clearDeliveredIOSNotificationsForThread( activeThread, notifications, ), ); } else if (Platform.OS === 'android') { this.props.dispatch({ type: clearAndroidNotificationsActionType, payload: { threadID: activeThread }, }); } } static clearDeliveredIOSNotificationsForThread( threadID: string, notifications: Object[], ) { const identifiersToClear = []; for (let notification of notifications) { if (notification['thread-id'] === threadID) { identifiersToClear.push(notification.identifier); } } if (identifiersToClear) { NotificationsIOS.removeDeliveredNotifications(identifiersToClear); } } async ensurePushNotifsEnabled() { if (!this.props.loggedIn) { return; } if (Platform.OS === 'ios') { const missingDeviceToken = this.props.deviceToken === null || this.props.deviceToken === undefined; await requestIOSPushPermissions(missingDeviceToken); } else if (Platform.OS === 'android') { await this.ensureAndroidPushNotifsEnabled(); } } async ensureAndroidPushNotifsEnabled() { const firebase = getFirebase(); const hasPermission = await firebase.messaging().hasPermission(); if (!hasPermission) { try { await firebase.messaging().requestPermission(); } catch { this.failedToRegisterPushPermissions(); return; } } const fcmToken = await firebase.messaging().getToken(); if (fcmToken) { await this.handleAndroidDeviceToken(fcmToken); } else { this.failedToRegisterPushPermissions(); } } handleAndroidDeviceToken = async (deviceToken: string) => { this.registerPushPermissions(deviceToken); await this.handleInitialAndroidNotification(); }; async handleInitialAndroidNotification() { if (this.initialAndroidNotifHandled) { return; } this.initialAndroidNotifHandled = true; const initialNotif = await getFirebase() .notifications() .getInitialNotification(); if (initialNotif) { await this.androidNotificationOpened(initialNotif); } } registerPushPermissions = (deviceToken: string) => { const deviceType = Platform.OS; if (deviceType !== 'android' && deviceType !== 'ios') { return; } if (deviceType === 'ios') { iosPushPermissionResponseReceived(); } if (deviceToken !== this.props.deviceToken) { this.setDeviceToken(deviceToken); } }; setDeviceToken(deviceToken: string) { this.props.dispatchActionPromise( setDeviceTokenActionTypes, this.props.setDeviceToken(deviceToken), undefined, deviceToken, ); } failedToRegisterPushPermissions = () => { if (!this.props.loggedIn) { return; } const deviceType = Platform.OS; if (deviceType === 'ios') { iosPushPermissionResponseReceived(); if (__DEV__) { // iOS simulator can't handle notifs return; } } const alertInfo = this.props.notifPermissionAlertInfo; if ( (alertInfo.totalAlerts > 3 && alertInfo.lastAlertTime > Date.now() - msInDay) || (alertInfo.totalAlerts > 6 && alertInfo.lastAlertTime > Date.now() - msInDay * 3) || (alertInfo.totalAlerts > 9 && alertInfo.lastAlertTime > Date.now() - msInDay * 7) ) { return; } this.props.dispatch({ type: recordNotifPermissionAlertActionType, payload: { time: Date.now() }, }); if (deviceType === 'ios') { Alert.alert( 'Need notif permissions', 'SquadCal needs notification permissions to keep you in the loop! ' + 'Please enable in Settings App -> Notifications -> SquadCal.', [{ text: 'OK' }], ); } else if (deviceType === 'android') { Alert.alert( 'Unable to initialize notifs!', 'Please check your network connection, make sure Google Play ' + 'services are installed and enabled, and confirm that your Google ' + 'Play credentials are valid in the Google Play Store.', undefined, { cancelable: true }, ); } }; navigateToThread(threadInfo: ThreadInfo, clearChatRoutes: boolean) { if (clearChatRoutes) { this.props.navigation.dispatch({ type: replaceWithThreadActionType, payload: { threadInfo }, }); } else { this.props.navigation.navigate({ name: MessageListRouteName, key: `${MessageListRouteName}${threadInfo.id}`, - params: { threadInfo }, + params: { thread: { threadInfo } }, }); } } onPressNotificationForThread(threadID: string, clearChatRoutes: boolean) { const threadInfo = this.props.threadInfos[threadID]; if (threadInfo) { this.navigateToThread(threadInfo, clearChatRoutes); } else { this.openThreadOnceReceived.add(threadID); } } saveMessageInfos(messageInfosString: string) { saveMessageInfos(messageInfosString, this.props.updatesCurrentAsOf); } iosForegroundNotificationReceived = (notification) => { if ( notification.getData() && notification.getData().managedAps && notification.getData().managedAps.action === 'CLEAR' ) { notification.finish(NotificationsIOS.FetchResult.NoData); return; } if (Date.now() < this.appStarted + 1500) { // On iOS, when the app is opened from a notif press, for some reason this // callback gets triggered before iosNotificationOpened. In fact this // callback shouldn't be triggered at all. To avoid weirdness we are // ignoring any foreground notification received within the first second // of the app being started, since they are most likely to be erroneous. notification.finish(NotificationsIOS.FetchResult.NoData); return; } const threadID = notification.getData().threadID; if (!threadID) { console.log('Notification with missing threadID received!'); notification.finish(NotificationsIOS.FetchResult.NoData); return; } const messageInfos = notification.getData().messageInfos; if (messageInfos) { this.saveMessageInfos(messageInfos); } let title = null; let body = notification.getMessage(); if (notification.getData().title) { ({ title, body } = mergePrefixIntoBody(notification.getData())); } this.showInAppNotification(threadID, body, title); notification.finish(NotificationsIOS.FetchResult.NewData); }; onPushNotifBootsApp() { if ( this.props.rootContext && this.props.rootContext.detectUnsupervisedBackground ) { this.props.rootContext.detectUnsupervisedBackground(false); } } iosNotificationOpened = (notification) => { this.onPushNotifBootsApp(); const threadID = notification.getData().threadID; if (!threadID) { console.log('Notification with missing threadID received!'); notification.finish(NotificationsIOS.FetchResult.NoData); return; } const messageInfos = notification.getData().messageInfos; if (messageInfos) { this.saveMessageInfos(messageInfos); } this.onPressNotificationForThread(threadID, true); notification.finish(NotificationsIOS.FetchResult.NewData); }; showInAppNotification(threadID: string, message: string, title?: ?string) { if (threadID === this.props.activeThread) { return; } this.setState({ inAppNotifProps: { customComponent: ( ), blurType: this.props.activeTheme === 'dark' ? 'xlight' : 'dark', onPress: () => { InAppNotification.hide(); this.onPressNotificationForThread(threadID, false); }, }, }); } androidNotificationOpened = async (notificationOpen: NotificationOpen) => { this.onPushNotifBootsApp(); const { threadID } = notificationOpen.notification.data; this.onPressNotificationForThread(threadID, true); }; androidMessageReceived = async (message: RemoteMessage) => { this.onPushNotifBootsApp(); handleAndroidMessage( message, this.props.updatesCurrentAsOf, this.handleAndroidNotificationIfActive, ); }; handleAndroidNotificationIfActive = ( threadID: string, texts: {| body: string, title: ?string |}, ) => { if (this.currentState !== 'active') { return false; } this.showInAppNotification(threadID, texts.body, texts.title); return true; }; render() { return ( ); } } AppRegistry.registerHeadlessTask( 'RNFirebaseBackgroundMessage', () => androidBackgroundMessageTask, ); export default React.memo(function ConnectedPushHandler( props: BaseProps, ) { const navContext = React.useContext(NavContext); const activeThread = activeMessageListSelector(navContext); const boundUnreadCount = useSelector(unreadCount); const deviceToken = useSelector((state) => state.deviceToken); const threadInfos = useSelector(threadInfoSelector); const notifPermissionAlertInfo = useSelector( (state) => state.notifPermissionAlertInfo, ); const connection = useSelector((state) => state.connection); const updatesCurrentAsOf = useSelector((state) => state.updatesCurrentAsOf); const activeTheme = useSelector((state) => state.globalThemeInfo.activeTheme); const loggedIn = useSelector(isLoggedIn); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const boundSetDeviceToken = useServerCall(setDeviceToken); const rootContext = React.useContext(RootContext); return ( ); });