diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index 8d88da3fd..b88773fd2 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,1940 +1,1940 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _omitBy from 'lodash/fp/omitBy.js'; import * as React from 'react'; import { getUserAvatarForThread } from './avatar-utils.js'; import { generatePendingThreadColor } from './color-utils.js'; import { type ParserRules } from './markdown.js'; import { extractUserMentionsFromText } from './mention-utils.js'; import { getMessageTitle, isInvalidSidebarSource } from './message-utils.js'; import { relationshipBlockedInEitherDirection } from './relationship-utils.js'; import { useForwardLookupSearchText } from './search-utils.js'; import threadWatcher from './thread-watcher.js'; import { fetchMostRecentMessagesActionTypes, useFetchMostRecentMessages, } from '../actions/message-actions.js'; import type { RemoveUsersFromThreadInput } from '../actions/thread-actions'; import { newThreadActionTypes, removeUsersFromThreadActionTypes, } from '../actions/thread-actions.js'; import { searchUsers as searchUserCall } from '../actions/user-actions.js'; import ashoat from '../facts/ashoat.js'; import genesis from '../facts/genesis.js'; import { useLoggedInUserInfo } from '../hooks/account-hooks.js'; import { hasPermission, permissionsToBitmaskHex, threadPermissionsFromBitmaskHex, } from '../permissions/minimally-encoded-thread-permissions.js'; import { permissionLookup, getAllThreadPermissions, makePermissionsBlob, } from '../permissions/thread-permissions.js'; import type { ChatThreadItem, ChatMessageInfoItem, } from '../selectors/chat-selectors.js'; import { useGlobalThreadSearchIndex } from '../selectors/nav-selectors.js'; import { threadInfoSelector, pendingToRealizedThreadIDsSelector, threadInfosSelectorForThreadType, } from '../selectors/thread-selectors.js'; import { getRelativeMemberInfos, usersWithPersonalThreadSelector, } from '../selectors/user-selectors.js'; import type { CalendarQuery } from '../types/entry-types.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type RobotextMessageInfo, type ComposableMessageInfo, } from '../types/message-types.js'; import type { RawThreadInfo, MinimallyEncodedThreadCurrentUserInfo, ThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { decodeMinimallyEncodedRoleInfo, minimallyEncodeRawThreadInfo, minimallyEncodeThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { threadPermissionPropagationPrefixes, threadPermissions, configurableCommunityPermissions, type ThreadPermission, type ThreadPermissionsInfo, type UserSurfacedPermission, threadPermissionFilterPrefixes, } from '../types/thread-permission-types.js'; import { type ThreadType, threadTypes, threadTypeIsCommunityRoot, assertThreadType, } from '../types/thread-types-enum.js'; import type { LegacyRawThreadInfo, LegacyThreadInfo, MemberInfo, ServerThreadInfo, ThreadCurrentUserInfo, RoleInfo, ServerMemberInfo, ClientNewThreadRequest, NewThreadResult, ChangeThreadSettingsPayload, UserProfileThreadInfo, RelativeMemberInfo, MixedRawThreadInfos, } from '../types/thread-types.js'; import { updateTypes } from '../types/update-types-enum.js'; import { type ClientUpdateInfo } from '../types/update-types.js'; import type { GlobalAccountUserInfo, UserInfos, AccountUserInfo, LoggedInUserInfo, UserInfo, } from '../types/user-types.js'; import { useLegacyAshoatKeyserverCall } from '../utils/action-utils.js'; import type { GetENSNames } from '../utils/ens-helpers.js'; import { ET, entityTextToRawString, getEntityTextAsString, type ThreadEntity, type UserEntity, } from '../utils/entity-text.js'; import { useDispatchActionPromise, type DispatchActionPromise, } from '../utils/redux-promise-utils.js'; import { useSelector } from '../utils/redux-utils.js'; import { firstLine } from '../utils/string-utils.js'; import { trimText } from '../utils/text-utils.js'; import { pendingThreadIDRegex } from '../utils/validation-utils.js'; function threadHasPermission( threadInfo: ?( | LegacyThreadInfo | ThreadInfo | LegacyRawThreadInfo | RawThreadInfo ), permission: ThreadPermission, ): boolean { if (!threadInfo) { return false; } 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.minimallyEncoded) { return hasPermission(threadInfo.currentUser.permissions, permission); } return permissionLookup(threadInfo.currentUser.permissions, permission); } function viewerIsMember( threadInfo: ?( | LegacyThreadInfo | ThreadInfo | LegacyRawThreadInfo | RawThreadInfo ), ): boolean { return !!( threadInfo && threadInfo.currentUser.role !== null && threadInfo.currentUser.role !== undefined ); } function threadIsInHome( threadInfo: ?(RawThreadInfo | LegacyThreadInfo | ThreadInfo), ): boolean { return !!(threadInfo && threadInfo.currentUser.subscription.home); } // Can have messages function threadInChatList( threadInfo: ?( | LegacyRawThreadInfo | RawThreadInfo | LegacyThreadInfo | ThreadInfo ), ): boolean { return ( viewerIsMember(threadInfo) && threadHasPermission(threadInfo, threadPermissions.VISIBLE) ); } function threadIsTopLevel( threadInfo: ?(RawThreadInfo | LegacyThreadInfo | ThreadInfo), ): boolean { return threadInChatList(threadInfo) && threadIsChannel(threadInfo); } function threadIsChannel( threadInfo: ?(RawThreadInfo | LegacyThreadInfo | ThreadInfo), ): boolean { return !!(threadInfo && threadInfo.type !== threadTypes.SIDEBAR); } function threadIsSidebar( threadInfo: ?(RawThreadInfo | LegacyThreadInfo | ThreadInfo), ): boolean { return threadInfo?.type === threadTypes.SIDEBAR; } function threadInBackgroundChatList( threadInfo: ?(RawThreadInfo | LegacyThreadInfo | ThreadInfo), ): boolean { return threadInChatList(threadInfo) && !threadIsInHome(threadInfo); } function threadInHomeChatList( threadInfo: ?(RawThreadInfo | LegacyThreadInfo | ThreadInfo), ): boolean { return threadInChatList(threadInfo) && threadIsInHome(threadInfo); } // Can have Calendar entries, // does appear as a top-level entity in the thread list function threadInFilterList( threadInfo: ?( | LegacyRawThreadInfo | RawThreadInfo | LegacyThreadInfo | ThreadInfo ), ): boolean { return ( threadInChatList(threadInfo) && !!threadInfo && threadInfo.type !== threadTypes.SIDEBAR ); } function userIsMember( threadInfo: ?( | LegacyRawThreadInfo | RawThreadInfo | LegacyThreadInfo | ThreadInfo ), userID: string, ): boolean { if (!threadInfo) { return false; } if (threadInfo.id === genesis.id) { return true; } return threadInfo.members.some(member => member.id === userID && member.role); } function threadActualMembers( memberInfos: $ReadOnlyArray, ): $ReadOnlyArray { return memberInfos .filter(memberInfo => memberInfo.role) .map(memberInfo => memberInfo.id); } function threadOtherMembers( memberInfos: $ReadOnlyArray, viewerID: ?string, ): $ReadOnlyArray { return memberInfos.filter( memberInfo => memberInfo.role && memberInfo.id !== viewerID, ); } function threadMembersWithoutAddedAshoat< T: LegacyRawThreadInfo | RawThreadInfo | LegacyThreadInfo | ThreadInfo, >(threadInfo: T): $PropertyType { if (threadInfo.community !== genesis.id) { return threadInfo.members; } return threadInfo.members.filter( member => member.id !== ashoat.id || member.role, ); } function threadIsGroupChat(threadInfo: LegacyThreadInfo | ThreadInfo): boolean { return threadInfo.members.length > 2; } function threadOrParentThreadIsGroupChat( threadInfo: | LegacyRawThreadInfo | RawThreadInfo | LegacyThreadInfo | ThreadInfo, ) { return threadMembersWithoutAddedAshoat(threadInfo).length > 2; } function threadIsPending(threadID: ?string): boolean { return !!threadID?.startsWith('pending'); } function threadIsPendingSidebar(threadID: ?string): boolean { return !!threadID?.startsWith('pending/sidebar/'); } function getSingleOtherUser( threadInfo: | LegacyRawThreadInfo | RawThreadInfo | LegacyThreadInfo | ThreadInfo, viewerID: ?string, ): ?string { if (!viewerID) { return undefined; } const otherMembers = threadOtherMembers(threadInfo.members, viewerID); if (otherMembers.length !== 1) { return undefined; } return otherMembers[0].id; } function getPendingThreadID( threadType: ThreadType, memberIDs: $ReadOnlyArray, sourceMessageID: ?string, ): string { const pendingThreadKey = sourceMessageID ? `sidebar/${sourceMessageID}` : [...memberIDs].sort().join('+'); const pendingThreadTypeString = sourceMessageID ? '' : `type${threadType}/`; return `pending/${pendingThreadTypeString}${pendingThreadKey}`; } type PendingThreadIDContents = { +threadType: ThreadType, +memberIDs: $ReadOnlyArray, +sourceMessageID: ?string, }; function parsePendingThreadID( pendingThreadID: string, ): ?PendingThreadIDContents { const pendingRegex = new RegExp(`^${pendingThreadIDRegex}$`); const pendingThreadIDMatches = pendingRegex.exec(pendingThreadID); if (!pendingThreadIDMatches) { return null; } const [threadTypeString, threadKey] = pendingThreadIDMatches[1].split('/'); const threadType = threadTypeString === 'sidebar' ? threadTypes.SIDEBAR : assertThreadType(Number(threadTypeString.replace('type', ''))); const memberIDs = threadTypeString === 'sidebar' ? [] : threadKey.split('+'); const sourceMessageID = threadTypeString === 'sidebar' ? threadKey : null; return { threadType, memberIDs, sourceMessageID, }; } type UserIDAndUsername = { +id: string, +username: string, ... }; type CreatePendingThreadArgs = { +viewerID: string, +threadType: ThreadType, +members: $ReadOnlyArray, +parentThreadInfo?: ?(LegacyThreadInfo | ThreadInfo), +threadColor?: ?string, +name?: ?string, +sourceMessageID?: string, }; function createPendingThread({ viewerID, threadType, members, parentThreadInfo, threadColor, name, sourceMessageID, }: CreatePendingThreadArgs): ThreadInfo { const now = Date.now(); if (!members.some(member => member.id === viewerID)) { throw new Error( 'createPendingThread should be called with the viewer as a member', ); } const memberIDs = members.map(member => member.id); const threadID = getPendingThreadID(threadType, memberIDs, sourceMessageID); 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), creationTime: now, parentThreadID: parentThreadInfo?.id ?? null, containingThreadID: getContainingThreadID(parentThreadInfo, threadType), community: getCommunity(parentThreadInfo), members: 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, sourceMessageID, pinnedCount: 0, }; const userInfos: { [string]: UserInfo } = {}; for (const member of members) { const { id, username } = member; userInfos[id] = { id, username }; } return threadInfoFromRawThreadInfo(rawThreadInfo, viewerID, userInfos); } type PendingPersonalThread = { +threadInfo: ThreadInfo, +pendingPersonalThreadUserInfo: UserInfo, }; function createPendingPersonalThread( loggedInUserInfo: LoggedInUserInfo, userID: string, username: string, ): PendingPersonalThread { const pendingPersonalThreadUserInfo = { id: userID, username: username, }; const threadInfo = createPendingThread({ viewerID: loggedInUserInfo.id, threadType: threadTypes.PERSONAL, members: [loggedInUserInfo, pendingPersonalThreadUserInfo], }); return { threadInfo, pendingPersonalThreadUserInfo }; } function createPendingThreadItem( loggedInUserInfo: LoggedInUserInfo, user: UserIDAndUsername, ): ChatThreadItem { const { threadInfo, pendingPersonalThreadUserInfo } = createPendingPersonalThread(loggedInUserInfo, user.id, user.username); return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo: null, mostRecentNonLocalMessage: null, lastUpdatedTime: threadInfo.creationTime, lastUpdatedTimeIncludingSidebars: threadInfo.creationTime, sidebars: [], pendingPersonalThreadUserInfo, }; } // Returns map from lowercase username to AccountUserInfo function memberLowercaseUsernameMap( members: $ReadOnlyArray, ): Map { const memberMap = new Map(); for (const member of members) { const { id, role, username } = member; if (!role || !username) { continue; } memberMap.set(username.toLowerCase(), { id, username }); } return memberMap; } // Returns map from user ID to AccountUserInfo function extractMentionedMembers( text: string, threadInfo: LegacyThreadInfo | ThreadInfo, ): Map { const memberMap = memberLowercaseUsernameMap(threadInfo.members); const mentions = extractUserMentionsFromText(text); const mentionedMembers = new Map(); for (const mention of mentions) { const userInfo = memberMap.get(mention.toLowerCase()); if (userInfo) { mentionedMembers.set(userInfo.id, userInfo); } } return mentionedMembers; } // When a member of the parent is mentioned in a sidebar, // they will be automatically added to that sidebar function extractNewMentionedParentMembers( messageText: string, threadInfo: LegacyThreadInfo | ThreadInfo, parentThreadInfo: LegacyThreadInfo | ThreadInfo, ): AccountUserInfo[] { const mentionedMembersOfParent = extractMentionedMembers( messageText, parentThreadInfo, ); for (const member of threadInfo.members) { if (member.role) { mentionedMembersOfParent.delete(member.id); } } return [...mentionedMembersOfParent.values()]; } type SharedCreatePendingSidebarInput = { +sourceMessageInfo: ComposableMessageInfo | RobotextMessageInfo, +parentThreadInfo: LegacyThreadInfo | ThreadInfo, +loggedInUserInfo: LoggedInUserInfo, }; type BaseCreatePendingSidebarInput = { ...SharedCreatePendingSidebarInput, +messageTitle: string, }; function baseCreatePendingSidebar( input: BaseCreatePendingSidebarInput, ): ThreadInfo { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, messageTitle, } = input; const { color, type: parentThreadType } = parentThreadInfo; const threadName = trimText(messageTitle, 30); const initialMembers = new Map(); const { id: viewerID, username: viewerUsername } = loggedInUserInfo; initialMembers.set(viewerID, { id: viewerID, username: viewerUsername }); if (userIsMember(parentThreadInfo, sourceMessageInfo.creator.id)) { const { id: sourceAuthorID, username: sourceAuthorUsername } = sourceMessageInfo.creator; invariant( sourceAuthorUsername, 'sourceAuthorUsername should be set in createPendingSidebar', ); const initialMemberUserInfo = { id: sourceAuthorID, username: sourceAuthorUsername, }; initialMembers.set(sourceAuthorID, initialMemberUserInfo); } const singleOtherUser = getSingleOtherUser(parentThreadInfo, viewerID); if (parentThreadType === threadTypes.PERSONAL && singleOtherUser) { const singleOtherUsername = parentThreadInfo.members.find( member => member.id === singleOtherUser, )?.username; invariant( singleOtherUsername, 'singleOtherUsername should be set in createPendingSidebar', ); const singleOtherUserInfo = { id: singleOtherUser, username: singleOtherUsername, }; initialMembers.set(singleOtherUser, singleOtherUserInfo); } if (sourceMessageInfo.type === messageTypes.TEXT) { const mentionedMembersOfParent = extractMentionedMembers( sourceMessageInfo.text, parentThreadInfo, ); for (const [memberID, member] of mentionedMembersOfParent) { initialMembers.set(memberID, member); } } return createPendingThread({ viewerID, threadType: threadTypes.SIDEBAR, members: [...initialMembers.values()], parentThreadInfo, threadColor: color, name: threadName, sourceMessageID: sourceMessageInfo.id, }); } // The message title here may have ETH addresses that aren't resolved to ENS // names. This function should only be used in cases where we're sure that we // don't care about the thread title. We should prefer createPendingSidebar // wherever possible type CreateUnresolvedPendingSidebarInput = { ...SharedCreatePendingSidebarInput, +markdownRules: ParserRules, }; function createUnresolvedPendingSidebar( input: CreateUnresolvedPendingSidebarInput, ): ThreadInfo { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, markdownRules, } = input; const messageTitleEntityText = getMessageTitle( sourceMessageInfo, parentThreadInfo, parentThreadInfo, markdownRules, ); const messageTitle = entityTextToRawString(messageTitleEntityText, { ignoreViewer: true, }); return baseCreatePendingSidebar({ sourceMessageInfo, parentThreadInfo, messageTitle, loggedInUserInfo, }); } type CreatePendingSidebarInput = { ...SharedCreatePendingSidebarInput, +markdownRules: ParserRules, +getENSNames: ?GetENSNames, }; async function createPendingSidebar( input: CreatePendingSidebarInput, ): Promise { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, markdownRules, getENSNames, } = input; const messageTitleEntityText = getMessageTitle( sourceMessageInfo, parentThreadInfo, parentThreadInfo, markdownRules, ); const messageTitle = await getEntityTextAsString( messageTitleEntityText, getENSNames, { ignoreViewer: true }, ); invariant( messageTitle !== null && messageTitle !== undefined, 'getEntityTextAsString only returns falsey when passed falsey', ); return baseCreatePendingSidebar({ sourceMessageInfo, parentThreadInfo, messageTitle, loggedInUserInfo, }); } function pendingThreadType(numberOfOtherMembers: number): 4 | 6 | 7 { if (numberOfOtherMembers === 0) { return threadTypes.PRIVATE; } else if (numberOfOtherMembers === 1) { return threadTypes.PERSONAL; } else { return threadTypes.LOCAL; } } function threadTypeCanBePending(threadType: ThreadType): boolean { return ( threadType === threadTypes.PERSONAL || threadType === threadTypes.LOCAL || threadType === threadTypes.SIDEBAR || threadType === threadTypes.PRIVATE ); } type CreateRealThreadParameters = { +threadInfo: LegacyThreadInfo | ThreadInfo, +dispatchActionPromise: DispatchActionPromise, +createNewThread: ClientNewThreadRequest => Promise, +sourceMessageID: ?string, +viewerID: ?string, +handleError?: () => mixed, +calendarQuery: CalendarQuery, }; async function createRealThreadFromPendingThread({ threadInfo, dispatchActionPromise, createNewThread, sourceMessageID, viewerID, calendarQuery, }: CreateRealThreadParameters): Promise { if (!threadIsPending(threadInfo.id)) { return threadInfo.id; } const otherMemberIDs = threadOtherMembers(threadInfo.members, viewerID).map( member => member.id, ); 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, calendarQuery, }); } 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, calendarQuery, }); } void dispatchActionPromise(newThreadActionTypes, resultPromise); const { newThreadID } = await resultPromise; return newThreadID; } type RawThreadInfoOptions = { +filterThreadEditAvatarPermission?: boolean, +excludePinInfo?: boolean, +filterManageInviteLinksPermission?: boolean, +filterVoicedInAnnouncementChannelsPermission?: boolean, +minimallyEncodePermissions?: boolean, }; function rawThreadInfoFromServerThreadInfo( serverThreadInfo: ServerThreadInfo, viewerID: string, options?: RawThreadInfoOptions, ): ?LegacyRawThreadInfo | ?RawThreadInfo { const filterThreadEditAvatarPermission = options?.filterThreadEditAvatarPermission; const excludePinInfo = options?.excludePinInfo; const filterManageInviteLinksPermission = options?.filterManageInviteLinksPermission; const filterVoicedInAnnouncementChannelsPermission = options?.filterVoicedInAnnouncementChannelsPermission; const shouldMinimallyEncodePermissions = options?.minimallyEncodePermissions; const filterThreadPermissions = _omitBy( (v, k) => (filterThreadEditAvatarPermission && [ threadPermissions.EDIT_THREAD_AVATAR, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_THREAD_AVATAR, ].includes(k)) || (excludePinInfo && [ threadPermissions.MANAGE_PINS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.MANAGE_PINS, ].includes(k)) || (filterManageInviteLinksPermission && [threadPermissions.MANAGE_INVITE_LINKS].includes(k)) || (filterVoicedInAnnouncementChannelsPermission && [ threadPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissionFilterPrefixes.TOP_LEVEL + threadPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS, ].includes(k)), ); const members = []; let currentUser; for (const serverMember of serverThreadInfo.members) { if ( serverThreadInfo.id === genesis.id && serverMember.id !== viewerID && serverMember.id !== ashoat.id ) { continue; } const memberPermissions = filterThreadPermissions(serverMember.permissions); members.push({ id: serverMember.id, role: serverMember.role, permissions: memberPermissions, isSender: serverMember.isSender, }); if (serverMember.id === viewerID) { currentUser = { role: serverMember.role, permissions: memberPermissions, subscription: serverMember.subscription, unread: serverMember.unread, }; } } let currentUserPermissions; if (currentUser) { currentUserPermissions = currentUser.permissions; } else { currentUserPermissions = filterThreadPermissions( 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 rolesWithFilteredThreadPermissions = _mapValues(role => ({ ...role, permissions: filterThreadPermissions(role.permissions), }))(serverThreadInfo.roles); let rawThreadInfo: any = { id: serverThreadInfo.id, type: serverThreadInfo.type, name: serverThreadInfo.name, description: serverThreadInfo.description, color: serverThreadInfo.color, creationTime: serverThreadInfo.creationTime, parentThreadID: serverThreadInfo.parentThreadID, members, roles: rolesWithFilteredThreadPermissions, currentUser, repliesCount: serverThreadInfo.repliesCount, containingThreadID: serverThreadInfo.containingThreadID, community: serverThreadInfo.community, }; const sourceMessageID = serverThreadInfo.sourceMessageID; if (sourceMessageID) { rawThreadInfo = { ...rawThreadInfo, sourceMessageID }; } if (serverThreadInfo.avatar) { rawThreadInfo = { ...rawThreadInfo, avatar: serverThreadInfo.avatar }; } if (!excludePinInfo) { rawThreadInfo = { ...rawThreadInfo, pinnedCount: serverThreadInfo.pinnedCount, }; } return shouldMinimallyEncodePermissions ? minimallyEncodeRawThreadInfo(rawThreadInfo) : rawThreadInfo; } function threadUIName(threadInfo: ThreadInfo): string | ThreadEntity { if (threadInfo.name) { return firstLine(threadInfo.name); } const threadMembers: $ReadOnlyArray = threadInfo.members.filter(memberInfo => memberInfo.role); const memberEntities: $ReadOnlyArray = threadMembers.map(member => ET.user({ userInfo: member }), ); return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'uiName', uiName: memberEntities, ifJustViewer: threadInfo.type === threadTypes.PRIVATE ? 'viewer_username' : 'just_you_string', }; } function threadInfoFromRawThreadInfo( rawThreadInfo: LegacyRawThreadInfo | RawThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadInfo { let threadInfo: LegacyThreadInfo | ThreadInfo; if (rawThreadInfo.minimallyEncoded) { threadInfo = { minimallyEncoded: true, id: rawThreadInfo.id, type: rawThreadInfo.type, name: rawThreadInfo.name, uiName: '', description: rawThreadInfo.description, color: rawThreadInfo.color, creationTime: rawThreadInfo.creationTime, parentThreadID: rawThreadInfo.parentThreadID, containingThreadID: rawThreadInfo.containingThreadID, community: rawThreadInfo.community, members: getRelativeMemberInfos(rawThreadInfo, viewerID, userInfos), roles: rawThreadInfo.roles, currentUser: getMinimallyEncodedCurrentUser( rawThreadInfo, viewerID, userInfos, ), repliesCount: rawThreadInfo.repliesCount, }; } else { threadInfo = { id: rawThreadInfo.id, type: rawThreadInfo.type, name: rawThreadInfo.name, uiName: '', description: rawThreadInfo.description, color: rawThreadInfo.color, creationTime: rawThreadInfo.creationTime, parentThreadID: rawThreadInfo.parentThreadID, containingThreadID: rawThreadInfo.containingThreadID, community: rawThreadInfo.community, members: getRelativeMemberInfos(rawThreadInfo, viewerID, userInfos), roles: rawThreadInfo.roles, currentUser: getCurrentUser(rawThreadInfo, viewerID, userInfos), repliesCount: rawThreadInfo.repliesCount, }; threadInfo = minimallyEncodeThreadInfo(threadInfo); } threadInfo = { ...threadInfo, uiName: threadUIName(threadInfo), }; const { sourceMessageID, avatar, pinnedCount } = rawThreadInfo; if (sourceMessageID) { threadInfo = { ...threadInfo, sourceMessageID }; } if (avatar) { threadInfo = { ...threadInfo, avatar }; } else if ( rawThreadInfo.type === threadTypes.PERSONAL || rawThreadInfo.type === threadTypes.PRIVATE ) { threadInfo = { ...threadInfo, avatar: getUserAvatarForThread(rawThreadInfo, viewerID, userInfos), }; } if (pinnedCount) { threadInfo = { ...threadInfo, pinnedCount }; } return threadInfo; } function getCurrentUser( threadInfo: LegacyRawThreadInfo | LegacyThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadCurrentUserInfo { if (!threadFrozenDueToBlock(threadInfo, viewerID, userInfos)) { return threadInfo.currentUser; } return { ...threadInfo.currentUser, permissions: { ...threadInfo.currentUser.permissions, ...disabledPermissions, }, }; } function getMinimallyEncodedCurrentUser( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): MinimallyEncodedThreadCurrentUserInfo { if (!threadFrozenDueToBlock(threadInfo, viewerID, userInfos)) { return threadInfo.currentUser; } const decodedPermissions = threadPermissionsFromBitmaskHex( threadInfo.currentUser.permissions, ); const updatedPermissions = { ...decodedPermissions, ...disabledPermissions, }; const encodedUpdatedPermissions = permissionsToBitmaskHex(updatedPermissions); return { ...threadInfo.currentUser, permissions: encodedUpdatedPermissions, }; } function threadIsWithBlockedUserOnly( threadInfo: | LegacyRawThreadInfo | RawThreadInfo | LegacyThreadInfo | 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: | LegacyRawThreadInfo | RawThreadInfo | LegacyThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos); } function threadFrozenDueToViewerBlock( threadInfo: RawThreadInfo | LegacyThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos, true); } const threadTypeDescriptions: { [ThreadType]: string } = { [threadTypes.COMMUNITY_OPEN_SUBTHREAD]: 'Anybody in the parent channel can see an open subchannel.', [threadTypes.COMMUNITY_SECRET_SUBTHREAD]: 'Only visible to its members and admins of ancestor channels.', }; function memberIsAdmin( memberInfo: RelativeMemberInfo | MemberInfo, threadInfo: RawThreadInfo | LegacyThreadInfo | ThreadInfo, ): boolean { 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 { if (memberInfo.minimallyEncoded) { return hasPermission(memberInfo.permissions, threadPermissions.CHANGE_ROLE); } return !!memberInfo.permissions[threadPermissions.CHANGE_ROLE]?.value; } function roleIsAdminRole(roleInfo: ?RoleInfo): boolean { return !!(roleInfo && !roleInfo.isDefault && roleInfo.name === 'Admins'); } function threadHasAdminRole( threadInfo: ?( | LegacyRawThreadInfo | RawThreadInfo | LegacyThreadInfo | ThreadInfo | ServerThreadInfo ), ): boolean { if (!threadInfo) { return false; } return !!_find({ name: 'Admins' })(threadInfo.roles); } function threadOrParentThreadHasAdminRole( threadInfo: | LegacyRawThreadInfo | RawThreadInfo | LegacyThreadInfo | ThreadInfo, ) { return ( threadMembersWithoutAddedAshoat(threadInfo).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_NAME, threadPermissions.EDIT_THREAD_COLOR, threadPermissions.EDIT_THREAD_DESCRIPTION, threadPermissions.CREATE_SUBCHANNELS, 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: string = `Background chats are just like normal chats, except they don't ` + `contribute to your unread count.\n\n` + `To move a chat over here, switch the “Background” option in its settings.`; function threadNoun(threadType: ThreadType, parentThreadID: ?string): string { if (threadType === threadTypes.SIDEBAR) { return 'thread'; } else if ( threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD && parentThreadID === genesis.id ) { return 'chat'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.GENESIS ) { return 'channel'; } else { return 'chat'; } } function threadLabel(threadType: ThreadType): string { if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD ) { return 'Open'; } else if (threadType === threadTypes.PERSONAL) { return 'Personal'; } else if (threadType === threadTypes.SIDEBAR) { return 'Thread'; } else if (threadType === threadTypes.PRIVATE) { return 'Private'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.GENESIS ) { return 'Community'; } else { return 'Secret'; } } function useWatchThread(threadInfo: ?(LegacyThreadInfo | ThreadInfo)) { const dispatchActionPromise = useDispatchActionPromise(); const callFetchMostRecentMessages = useFetchMostRecentMessages(); const threadID = threadInfo?.id; const threadNotInChatList = !threadInChatList(threadInfo); React.useEffect(() => { if (threadID && threadNotInChatList) { threadWatcher.watchID(threadID); void dispatchActionPromise( fetchMostRecentMessagesActionTypes, callFetchMostRecentMessages({ threadID }), ); } return () => { if (threadID && threadNotInChatList) { threadWatcher.removeID(threadID); } }; }, [ callFetchMostRecentMessages, dispatchActionPromise, threadNotInChatList, threadID, ]); } type ExistingThreadInfoFinderParams = { +searching: boolean, +userInfoInputArray: $ReadOnlyArray, }; type ExistingThreadInfoFinder = ( params: ExistingThreadInfoFinderParams, ) => ?ThreadInfo; function useExistingThreadInfoFinder( baseThreadInfo: ?ThreadInfo, ): ExistingThreadInfoFinder { const threadInfos = useSelector(threadInfoSelector); const loggedInUserInfo = useLoggedInUserInfo(); const userInfos = useSelector(state => state.userStore.userInfos); const pendingToRealizedThreadIDs = useSelector(state => pendingToRealizedThreadIDsSelector(state.threadStore.threadInfos), ); return React.useCallback( (params: ExistingThreadInfoFinderParams): ?ThreadInfo => { if (!baseThreadInfo) { return null; } const realizedThreadInfo = threadInfos[baseThreadInfo.id]; if (realizedThreadInfo) { return realizedThreadInfo; } if (!loggedInUserInfo || !threadIsPending(baseThreadInfo.id)) { return baseThreadInfo; } const viewerID = loggedInUserInfo?.id; invariant( threadTypeCanBePending(baseThreadInfo.type), `ThreadInfo has pending ID ${baseThreadInfo.id}, but type that ` + `should not be pending ${baseThreadInfo.type}`, ); const { searching, userInfoInputArray } = params; const { sourceMessageID } = baseThreadInfo; const pendingThreadID = searching ? getPendingThreadID( pendingThreadType(userInfoInputArray.length), [...userInfoInputArray.map(user => user.id), viewerID], sourceMessageID, ) : getPendingThreadID( baseThreadInfo.type, baseThreadInfo.members.map(member => member.id), sourceMessageID, ); const realizedThreadID = pendingToRealizedThreadIDs.get(pendingThreadID); if (realizedThreadID && threadInfos[realizedThreadID]) { return threadInfos[realizedThreadID]; } const updatedThread = searching ? createPendingThread({ viewerID, threadType: pendingThreadType(userInfoInputArray.length), members: [loggedInUserInfo, ...userInfoInputArray], }) : baseThreadInfo; return { ...updatedThread, currentUser: getMinimallyEncodedCurrentUser( updatedThread, viewerID, userInfos, ), }; }, [ baseThreadInfo, threadInfos, loggedInUserInfo, pendingToRealizedThreadIDs, userInfos, ], ); } type ThreadTypeParentRequirement = 'optional' | 'required' | 'disabled'; function getThreadTypeParentRequirement( threadType: ThreadType, ): ThreadTypeParentRequirement { if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD || //threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.SIDEBAR ) { return 'required'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.GENESIS || threadType === threadTypes.PERSONAL || threadType === threadTypes.PRIVATE ) { return 'disabled'; } else { return 'optional'; } } function threadMemberHasPermission( threadInfo: | ServerThreadInfo | LegacyRawThreadInfo | RawThreadInfo | LegacyThreadInfo | ThreadInfo, memberID: string, permission: ThreadPermission, ): boolean { for (const member of threadInfo.members) { if (member.id !== memberID) { continue; } if (member.minimallyEncoded) { return hasPermission(member.permissions, permission); } return permissionLookup(member.permissions, permission); } return false; } function useCanCreateSidebarFromMessage( threadInfo: LegacyThreadInfo | ThreadInfo, messageInfo: ComposableMessageInfo | RobotextMessageInfo, ): boolean { const messageCreatorUserInfo = useSelector( state => state.userStore.userInfos[messageInfo.creator.id], ); if ( !messageInfo.id || threadInfo.sourceMessageID === messageInfo.id || isInvalidSidebarSource(messageInfo) ) { return false; } const messageCreatorRelationship = messageCreatorUserInfo?.relationshipStatus; const creatorRelationshipHasBlock = messageCreatorRelationship && relationshipBlockedInEitherDirection(messageCreatorRelationship); const hasCreateSidebarsPermission = threadHasPermission( threadInfo, threadPermissions.CREATE_SIDEBARS, ); return hasCreateSidebarsPermission && !creatorRelationshipHasBlock; } function useSidebarExistsOrCanBeCreated( threadInfo: LegacyThreadInfo | ThreadInfo, messageItem: ChatMessageInfoItem, ): boolean { const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( threadInfo, messageItem.messageInfo, ); return !!messageItem.threadCreatedFromMessage || canCreateSidebarFromMessage; } function checkIfDefaultMembersAreVoiced( threadInfo: LegacyThreadInfo | ThreadInfo, ): boolean { const defaultRoleID = Object.keys(threadInfo.roles).find( roleID => threadInfo.roles[roleID].isDefault, ); invariant( defaultRoleID !== undefined, 'all threads should have a default role', ); const defaultRole = threadInfo.roles[defaultRoleID]; const defaultRolePermissions = defaultRole.minimallyEncoded ? decodeMinimallyEncodedRoleInfo(defaultRole).permissions : defaultRole.permissions; return !!defaultRolePermissions[threadPermissions.VOICED]; } const draftKeySuffix = '/message_composer'; function draftKeyFromThreadID(threadID: string): string { return `${threadID}${draftKeySuffix}`; } function getContainingThreadID( parentThreadInfo: | ?ServerThreadInfo | LegacyRawThreadInfo | RawThreadInfo | LegacyThreadInfo | ThreadInfo, threadType: ThreadType, ): ?string { if (!parentThreadInfo) { return null; } if (threadType === threadTypes.SIDEBAR) { return parentThreadInfo.id; } if (!parentThreadInfo.containingThreadID) { return parentThreadInfo.id; } return parentThreadInfo.containingThreadID; } function getCommunity( parentThreadInfo: | ?ServerThreadInfo | LegacyRawThreadInfo | RawThreadInfo | LegacyThreadInfo | ThreadInfo, ): ?string { if (!parentThreadInfo) { return null; } const { id, community, type } = parentThreadInfo; if (community !== null && community !== undefined) { return community; } if (threadTypeIsCommunityRoot(type)) { return id; } return null; } function getThreadListSearchResults( chatListData: $ReadOnlyArray, searchText: string, - threadFilter: (LegacyThreadInfo | ThreadInfo) => boolean, + threadFilter: ThreadInfo => boolean, threadSearchResults: $ReadOnlySet, usersSearchResults: $ReadOnlyArray, loggedInUserInfo: ?LoggedInUserInfo, ): $ReadOnlyArray { if (!searchText) { return chatListData.filter( item => threadIsTopLevel(item.threadInfo) && threadFilter(item.threadInfo), ); } const privateThreads = []; const personalThreads = []; const otherThreads = []; for (const item of chatListData) { if (!threadSearchResults.has(item.threadInfo.id)) { continue; } if (item.threadInfo.type === threadTypes.PRIVATE) { privateThreads.push({ ...item, sidebars: [] }); } else if (item.threadInfo.type === threadTypes.PERSONAL) { personalThreads.push({ ...item, sidebars: [] }); } else { otherThreads.push({ ...item, sidebars: [] }); } } const chatItems: ChatThreadItem[] = [ ...privateThreads, ...personalThreads, ...otherThreads, ]; if (loggedInUserInfo) { chatItems.push( ...usersSearchResults.map(user => createPendingThreadItem(loggedInUserInfo, user), ), ); } return chatItems; } type ThreadListSearchResult = { +threadSearchResults: $ReadOnlySet, +usersSearchResults: $ReadOnlyArray, }; function useThreadListSearch( searchText: string, viewerID: ?string, ): ThreadListSearchResult { const callSearchUsers = useLegacyAshoatKeyserverCall(searchUserCall); const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); const forwardLookupSearchText = useForwardLookupSearchText(searchText); const searchUsers = React.useCallback( async (usernamePrefix: string) => { if (usernamePrefix.length === 0) { return ([]: GlobalAccountUserInfo[]); } const { userInfos } = await callSearchUsers(usernamePrefix); return userInfos.filter( info => !usersWithPersonalThread.has(info.id) && info.id !== viewerID, ); }, [callSearchUsers, usersWithPersonalThread, viewerID], ); const [threadSearchResults, setThreadSearchResults] = React.useState( new Set(), ); const [usersSearchResults, setUsersSearchResults] = React.useState< $ReadOnlyArray, >([]); const threadSearchIndex = useGlobalThreadSearchIndex(); React.useEffect(() => { void (async () => { const results = threadSearchIndex.getSearchResults(searchText); setThreadSearchResults(new Set(results)); const usersResults = await searchUsers(forwardLookupSearchText); setUsersSearchResults(usersResults); })(); }, [searchText, forwardLookupSearchText, threadSearchIndex, searchUsers]); return { threadSearchResults, usersSearchResults }; } function removeMemberFromThread( threadInfo: LegacyThreadInfo | ThreadInfo, memberInfo: RelativeMemberInfo, dispatchActionPromise: DispatchActionPromise, removeUserFromThreadServerCall: ( input: RemoveUsersFromThreadInput, ) => Promise, ) { const customKeyName = `${removeUsersFromThreadActionTypes.started}:${memberInfo.id}`; void dispatchActionPromise( removeUsersFromThreadActionTypes, removeUserFromThreadServerCall({ threadID: threadInfo.id, memberIDs: [memberInfo.id], }), { customKeyName }, ); } function getAvailableThreadMemberActions( memberInfo: RelativeMemberInfo, threadInfo: LegacyThreadInfo | ThreadInfo, canEdit: ?boolean = true, ): $ReadOnlyArray<'change_role' | 'remove_user'> { const role = memberInfo.role; if (!canEdit || !role) { return []; } const canRemoveMembers = threadHasPermission( threadInfo, threadPermissions.REMOVE_MEMBERS, ); const canChangeRoles = threadHasPermission( threadInfo, threadPermissions.CHANGE_ROLE, ); const result = []; if (canChangeRoles && memberInfo.username && threadHasAdminRole(threadInfo)) { result.push('change_role'); } if ( canRemoveMembers && !memberInfo.isViewer && (canChangeRoles || threadInfo.roles[role]?.isDefault) ) { result.push('remove_user'); } return result; } function patchThreadInfoToIncludeMentionedMembersOfParent( threadInfo: ThreadInfo, parentThreadInfo: ThreadInfo, messageText: string, viewerID: string, ): ThreadInfo { const members: UserIDAndUsername[] = threadInfo.members .map(({ id, username }) => username ? ({ id, username }: UserIDAndUsername) : null, ) .filter(Boolean); const mentionedNewMembers = extractNewMentionedParentMembers( messageText, threadInfo, parentThreadInfo, ); if (mentionedNewMembers.length === 0) { return threadInfo; } members.push(...mentionedNewMembers); return createPendingThread({ viewerID, threadType: threadTypes.SIDEBAR, members, parentThreadInfo, threadColor: threadInfo.color, name: threadInfo.name, sourceMessageID: threadInfo.sourceMessageID, }); } function threadInfoInsideCommunity( threadInfo: RawThreadInfo | LegacyThreadInfo | ThreadInfo, communityID: string, ): boolean { return threadInfo.community === communityID || threadInfo.id === communityID; } type RoleAndMemberCount = { [roleName: string]: number, }; function useRoleMemberCountsForCommunity( threadInfo: LegacyThreadInfo | ThreadInfo, ): RoleAndMemberCount { return React.useMemo(() => { const roleIDsToNames: { [string]: string } = {}; Object.keys(threadInfo.roles).forEach(roleID => { roleIDsToNames[roleID] = threadInfo.roles[roleID].name; }); const roleNamesToMemberCount: RoleAndMemberCount = {}; threadInfo.members.forEach(({ role: roleID }) => { invariant(roleID, 'Community member should have a role'); const roleName = roleIDsToNames[roleID]; roleNamesToMemberCount[roleName] = (roleNamesToMemberCount[roleName] ?? 0) + 1; }); // For all community roles with no members, add them to the list with 0 Object.keys(roleIDsToNames).forEach(roleName => { if (roleNamesToMemberCount[roleIDsToNames[roleName]] === undefined) { roleNamesToMemberCount[roleIDsToNames[roleName]] = 0; } }); return roleNamesToMemberCount; }, [threadInfo]); } type RoleUserSurfacedPermissions = { +[roleName: string]: $ReadOnlySet, }; // Iterates through the existing roles in the community and 'reverse maps' // the set of permission literals for each role to user-facing permission enums // to help pre-populate the permission checkboxes when editing roles. function useRoleUserSurfacedPermissions( threadInfo: LegacyThreadInfo | ThreadInfo, ): RoleUserSurfacedPermissions { return React.useMemo(() => { const roleNamesToPermissions: { [string]: Set } = {}; Object.keys(threadInfo.roles).forEach(roleID => { const roleName = threadInfo.roles[roleID].name; const rolePermissions = threadInfo.minimallyEncoded ? Object.keys( decodeMinimallyEncodedRoleInfo(threadInfo.roles[roleID]) .permissions, ) : Object.keys(threadInfo.roles[roleID].permissions); const setOfUserSurfacedPermissions = new Set(); rolePermissions.forEach(rolePermission => { const userSurfacedPermission = Object.keys( configurableCommunityPermissions, ).find(key => configurableCommunityPermissions[key].has(rolePermission), ); if (userSurfacedPermission) { setOfUserSurfacedPermissions.add(userSurfacedPermission); } }); roleNamesToPermissions[roleName] = setOfUserSurfacedPermissions; }); return roleNamesToPermissions; }, [threadInfo]); } function communityOrThreadNoun( threadInfo: RawThreadInfo | LegacyThreadInfo | ThreadInfo, ): string { return threadTypeIsCommunityRoot(threadInfo.type) ? 'community' : threadNoun(threadInfo.type, threadInfo.parentThreadID); } function getThreadsToDeleteText( threadInfo: RawThreadInfo | LegacyThreadInfo | ThreadInfo, ): string { return `${ threadTypeIsCommunityRoot(threadInfo.type) ? 'Subchannels and threads' : 'Threads' } within this ${communityOrThreadNoun(threadInfo)}`; } function useUserProfileThreadInfo(userInfo: ?UserInfo): ?UserProfileThreadInfo { const userID = userInfo?.id; const username = userInfo?.username; const loggedInUserInfo = useLoggedInUserInfo(); const isViewerProfile = loggedInUserInfo?.id === userID; const privateThreadInfosSelector = threadInfosSelectorForThreadType( threadTypes.PRIVATE, ); const privateThreadInfos = useSelector(privateThreadInfosSelector); const personalThreadInfosSelector = threadInfosSelectorForThreadType( threadTypes.PERSONAL, ); const personalThreadInfos = useSelector(personalThreadInfosSelector); const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); return React.useMemo(() => { if (!loggedInUserInfo || !userID || !username) { return null; } if (isViewerProfile) { const privateThreadInfo: ?ThreadInfo = privateThreadInfos[0]; return privateThreadInfo ? { threadInfo: privateThreadInfo } : null; } if (usersWithPersonalThread.has(userID)) { const personalThreadInfo: ?ThreadInfo = personalThreadInfos.find( threadInfo => userID === getSingleOtherUser(threadInfo, loggedInUserInfo.id), ); return personalThreadInfo ? { threadInfo: personalThreadInfo } : null; } const pendingPersonalThreadInfo = createPendingPersonalThread( loggedInUserInfo, userID, username, ); return pendingPersonalThreadInfo; }, [ isViewerProfile, loggedInUserInfo, personalThreadInfos, privateThreadInfos, userID, username, usersWithPersonalThread, ]); } function assertAllThreadInfosAreLegacy(rawThreadInfos: MixedRawThreadInfos): { [id: string]: LegacyRawThreadInfo, } { return Object.fromEntries( Object.entries(rawThreadInfos).map(([id, rawThreadInfo]) => { invariant( !rawThreadInfo.minimallyEncoded, `rawThreadInfos shouldn't be minimallyEncoded`, ); return [id, rawThreadInfo]; }), ); } export { threadHasPermission, viewerIsMember, threadInChatList, threadIsTopLevel, threadIsChannel, threadIsSidebar, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadOtherMembers, threadIsGroupChat, threadIsPending, threadIsPendingSidebar, getSingleOtherUser, getPendingThreadID, parsePendingThreadID, createPendingThread, createUnresolvedPendingSidebar, extractNewMentionedParentMembers, createPendingSidebar, pendingThreadType, createRealThreadFromPendingThread, getCurrentUser, getMinimallyEncodedCurrentUser, threadFrozenDueToBlock, threadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, threadUIName, threadInfoFromRawThreadInfo, threadTypeDescriptions, memberIsAdmin, memberHasAdminPowers, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadNoun, threadLabel, useWatchThread, useExistingThreadInfoFinder, getThreadTypeParentRequirement, threadMemberHasPermission, useCanCreateSidebarFromMessage, useSidebarExistsOrCanBeCreated, checkIfDefaultMembersAreVoiced, draftKeySuffix, draftKeyFromThreadID, threadTypeCanBePending, getContainingThreadID, getCommunity, getThreadListSearchResults, useThreadListSearch, removeMemberFromThread, getAvailableThreadMemberActions, threadMembersWithoutAddedAshoat, patchThreadInfoToIncludeMentionedMembersOfParent, threadInfoInsideCommunity, useRoleMemberCountsForCommunity, useRoleUserSurfacedPermissions, getThreadsToDeleteText, useUserProfileThreadInfo, assertAllThreadInfosAreLegacy, }; diff --git a/native/avatars/edit-thread-avatar.react.js b/native/avatars/edit-thread-avatar.react.js index 160af9ad0..ceca760cd 100644 --- a/native/avatars/edit-thread-avatar.react.js +++ b/native/avatars/edit-thread-avatar.react.js @@ -1,125 +1,124 @@ // @flow import { useNavigation } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { ActivityIndicator, TouchableOpacity, View } from 'react-native'; import { EditThreadAvatarContext } from 'lib/components/base-edit-thread-avatar-provider.react.js'; import type { ThreadInfo, RawThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { useNativeSetThreadAvatar, useSelectFromGalleryAndUpdateThreadAvatar, useShowAvatarActionSheet, } from './avatar-hooks.js'; import EditAvatarBadge from './edit-avatar-badge.react.js'; import ThreadAvatar from './thread-avatar.react.js'; import { EmojiThreadAvatarCreationRouteName, ThreadAvatarCameraModalRouteName, } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; type Props = { - +threadInfo: RawThreadInfo | LegacyThreadInfo | ThreadInfo, + +threadInfo: RawThreadInfo | ThreadInfo, +disabled?: boolean, }; function EditThreadAvatar(props: Props): React.Node { const styles = useStyles(unboundStyles); const { threadInfo, disabled } = props; const editThreadAvatarContext = React.useContext(EditThreadAvatarContext); invariant(editThreadAvatarContext, 'editThreadAvatarContext should be set'); const { threadAvatarSaveInProgress } = editThreadAvatarContext; const nativeSetThreadAvatar = useNativeSetThreadAvatar(); const selectFromGalleryAndUpdateThreadAvatar = useSelectFromGalleryAndUpdateThreadAvatar(); const { navigate } = useNavigation(); const navigateToThreadEmojiAvatarCreation = React.useCallback(() => { navigate<'EmojiThreadAvatarCreation'>({ name: EmojiThreadAvatarCreationRouteName, params: { threadInfo, }, }); }, [navigate, threadInfo]); const selectFromGallery = React.useCallback( () => selectFromGalleryAndUpdateThreadAvatar(threadInfo.id), [selectFromGalleryAndUpdateThreadAvatar, threadInfo.id], ); const navigateToCamera = React.useCallback(() => { navigate<'ThreadAvatarCameraModal'>({ name: ThreadAvatarCameraModalRouteName, params: { threadID: threadInfo.id }, }); }, [navigate, threadInfo.id]); const removeAvatar = React.useCallback( () => nativeSetThreadAvatar(threadInfo.id, { type: 'remove' }), [nativeSetThreadAvatar, threadInfo.id], ); const actionSheetConfig = React.useMemo(() => { const configOptions = [ { id: 'emoji', onPress: navigateToThreadEmojiAvatarCreation }, { id: 'image', onPress: selectFromGallery }, { id: 'camera', onPress: navigateToCamera }, ]; if (threadInfo.avatar) { configOptions.push({ id: 'remove', onPress: removeAvatar }); } return configOptions; }, [ navigateToCamera, navigateToThreadEmojiAvatarCreation, removeAvatar, selectFromGallery, threadInfo.avatar, ]); const showAvatarActionSheet = useShowAvatarActionSheet(actionSheetConfig); let spinner; if (threadAvatarSaveInProgress) { spinner = ( ); } return ( {spinner} {!disabled ? : null} ); } const unboundStyles = { spinnerContainer: { position: 'absolute', alignItems: 'center', justifyContent: 'center', top: 0, bottom: 0, left: 0, right: 0, }, }; export default EditThreadAvatar; diff --git a/native/avatars/thread-avatar.react.js b/native/avatars/thread-avatar.react.js index 540c39ff5..667782cc7 100644 --- a/native/avatars/thread-avatar.react.js +++ b/native/avatars/thread-avatar.react.js @@ -1,58 +1,53 @@ // @flow import * as React from 'react'; import { useAvatarForThread, useENSResolvedAvatar, } from 'lib/shared/avatar-utils.js'; import { getSingleOtherUser } from 'lib/shared/thread-utils.js'; import type { AvatarSize } from 'lib/types/avatar-types.js'; import type { ThreadInfo, ResolvedThreadInfo, RawThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import Avatar from './avatar.react.js'; import { useSelector } from '../redux/redux-utils.js'; type Props = { - +threadInfo: - | RawThreadInfo - | LegacyThreadInfo - | ThreadInfo - | ResolvedThreadInfo, + +threadInfo: RawThreadInfo | ThreadInfo | ResolvedThreadInfo, +size: AvatarSize, }; function ThreadAvatar(props: Props): React.Node { const { threadInfo, size } = props; const avatarInfo = useAvatarForThread(threadInfo); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); let displayUserIDForThread; if (threadInfo.type === threadTypes.PRIVATE) { displayUserIDForThread = viewerID; } else if (threadInfo.type === threadTypes.PERSONAL) { displayUserIDForThread = getSingleOtherUser(threadInfo, viewerID); } const displayUser = useSelector(state => displayUserIDForThread ? state.userStore.userInfos[displayUserIDForThread] : null, ); const resolvedThreadAvatar = useENSResolvedAvatar(avatarInfo, displayUser); return ; } export default ThreadAvatar; diff --git a/native/chat/chat-thread-list.react.js b/native/chat/chat-thread-list.react.js index bc424ac18..8060f8dea 100644 --- a/native/chat/chat-thread-list.react.js +++ b/native/chat/chat-thread-list.react.js @@ -1,496 +1,494 @@ // @flow import IonIcon from '@expo/vector-icons/Ionicons.js'; import type { BottomTabNavigationEventMap, BottomTabOptions, StackNavigationEventMap, StackNavigationState, StackOptions, TabNavigationState, } from '@react-navigation/core'; import invariant from 'invariant'; import * as React from 'react'; import { BackHandler, FlatList, Platform, TextInput, TouchableWithoutFeedback, View, } from 'react-native'; import { FloatingAction } from 'react-native-floating-action'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { type ChatThreadItem, useFlattenedChatListData, } from 'lib/selectors/chat-selectors.js'; import { createPendingThread, getThreadListSearchResults, useThreadListSearch, } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import type { UserInfo } from 'lib/types/user-types.js'; import { ChatThreadListItem } from './chat-thread-list-item.react.js'; import ChatThreadListSearch from './chat-thread-list-search.react.js'; import { getItemLayout, keyExtractor } from './chat-thread-list-utils.js'; import type { ChatNavigationProp, ChatTopTabsNavigationProp, } from './chat.react.js'; import { useNavigateToThread } from './message-list-types.js'; import { BackgroundChatThreadListRouteName, HomeChatThreadListRouteName, type NavigationRoute, type ScreenParamList, SidebarListModalRouteName, } from '../navigation/route-names.js'; import type { TabNavigationProp } from '../navigation/tab-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { indicatorStyleSelector, useStyles } from '../themes/colors.js'; import type { ScrollEvent } from '../types/react-native.js'; const floatingActions = [ { text: 'Compose', icon: , name: 'compose', position: 1, }, ]; export 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: LegacyThreadInfo | ThreadInfo) => boolean, + +filterThreads: (threadItem: ThreadInfo) => boolean, +emptyItem?: React.ComponentType<{}>, }; export type SearchStatus = 'inactive' | 'activating' | 'active'; function ChatThreadList(props: BaseProps): React.Node { const boundChatListData = useFlattenedChatListData(); const loggedInUserInfo = useLoggedInUserInfo(); const styles = useStyles(unboundStyles); const indicatorStyle = useSelector(indicatorStyleSelector); const navigateToThread = useNavigateToThread(); const { navigation, route, filterThreads, emptyItem } = props; const [searchText, setSearchText] = React.useState(''); const [searchStatus, setSearchStatus] = React.useState('inactive'); const { threadSearchResults, usersSearchResults } = useThreadListSearch( searchText, loggedInUserInfo?.id, ); const [openedSwipeableID, setOpenedSwipeableID] = React.useState(''); const [numItemsToDisplay, setNumItemsToDisplay] = React.useState(25); const onChangeSearchText = React.useCallback((updatedSearchText: string) => { setSearchText(updatedSearchText); setNumItemsToDisplay(25); }, []); const scrollPos = React.useRef(0); const flatListRef = React.useRef>(); const onScroll = React.useCallback( (event: ScrollEvent) => { const oldScrollPos = scrollPos.current; scrollPos.current = event.nativeEvent.contentOffset.y; if (scrollPos.current !== 0 || oldScrollPos === 0) { return; } if (searchStatus === 'activating') { setSearchStatus('active'); } }, [searchStatus], ); const onSwipeableWillOpen = React.useCallback( - (threadInfo: LegacyThreadInfo | ThreadInfo) => - setOpenedSwipeableID(threadInfo.id), + (threadInfo: ThreadInfo) => setOpenedSwipeableID(threadInfo.id), [], ); const composeThread = React.useCallback(() => { if (!loggedInUserInfo) { return; } const threadInfo = createPendingThread({ viewerID: loggedInUserInfo.id, threadType: threadTypes.PRIVATE, members: [loggedInUserInfo], }); navigateToThread({ threadInfo, searching: true }); }, [loggedInUserInfo, navigateToThread]); const onSearchFocus = React.useCallback(() => { if (searchStatus !== 'inactive') { return; } if (scrollPos.current === 0) { setSearchStatus('active'); } else { setSearchStatus('activating'); } }, [searchStatus]); const clearSearch = React.useCallback(() => { if (scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: false }); } setSearchStatus('inactive'); }, []); const onSearchBlur = React.useCallback(() => { if (searchStatus !== 'active') { return; } clearSearch(); }, [clearSearch, searchStatus]); const onSearchCancel = React.useCallback(() => { onChangeSearchText(''); clearSearch(); }, [clearSearch, onChangeSearchText]); const searchInputRef = React.useRef>(); const onPressItem = React.useCallback( (threadInfo: ThreadInfo, pendingPersonalThreadUserInfo?: UserInfo) => { onChangeSearchText(''); if (searchInputRef.current) { searchInputRef.current.blur(); } navigateToThread({ threadInfo, pendingPersonalThreadUserInfo }); }, [navigateToThread, onChangeSearchText], ); const onPressSeeMoreSidebars = React.useCallback( - (threadInfo: LegacyThreadInfo | ThreadInfo) => { + (threadInfo: ThreadInfo) => { onChangeSearchText(''); if (searchInputRef.current) { searchInputRef.current.blur(); } navigation.navigate<'SidebarListModal'>({ name: SidebarListModalRouteName, params: { threadInfo }, }); }, [navigation, onChangeSearchText], ); const hardwareBack = React.useCallback(() => { if (!navigation.isFocused()) { return false; } const isActiveOrActivating = searchStatus === 'active' || searchStatus === 'activating'; if (!isActiveOrActivating) { return false; } onSearchCancel(); return true; }, [navigation, onSearchCancel, searchStatus]); const searchItem = React.useMemo( () => ( ), [ onChangeSearchText, onSearchBlur, onSearchCancel, onSearchFocus, searchStatus, searchText, styles.searchContainer, ], ); const renderItem = React.useCallback( (row: { item: Item, ... }) => { const item = row.item; if (item.type === 'search') { return searchItem; } if (item.type === 'empty') { const EmptyItem = item.emptyItem; return ; } return ( ); }, [ onPressItem, onPressSeeMoreSidebars, onSwipeableWillOpen, openedSwipeableID, searchItem, ], ); const listData: $ReadOnlyArray = React.useMemo(() => { const chatThreadItems = getThreadListSearchResults( boundChatListData, searchText, filterThreads, threadSearchResults, usersSearchResults, loggedInUserInfo, ); const chatItems: Item[] = [...chatThreadItems]; if (emptyItem && chatItems.length === 0) { chatItems.push({ type: 'empty', emptyItem }); } if (searchStatus === 'inactive' || searchStatus === 'activating') { chatItems.unshift({ type: 'search', searchText }); } return chatItems; }, [ boundChatListData, emptyItem, filterThreads, loggedInUserInfo, searchStatus, searchText, threadSearchResults, usersSearchResults, ]); const partialListData: $ReadOnlyArray = React.useMemo( () => listData.slice(0, numItemsToDisplay), [listData, numItemsToDisplay], ); const onEndReached = React.useCallback(() => { if (partialListData.length === listData.length) { return; } setNumItemsToDisplay(prevNumItems => prevNumItems + 25); }, [listData.length, partialListData.length]); const floatingAction = React.useMemo(() => { if (Platform.OS !== 'android') { return null; } return ( ); }, [composeThread]); const fixedSearch = React.useMemo(() => { if (searchStatus !== 'active') { return null; } return ( ); }, [ onChangeSearchText, onSearchBlur, onSearchCancel, searchStatus, searchText, styles.searchContainer, ]); const scrollEnabled = searchStatus === 'inactive' || searchStatus === 'active'; // viewerID is in extraData since it's used by MessagePreview // within ChatThreadListItem const viewerID = loggedInUserInfo?.id; const extraData = `${viewerID || ''} ${openedSwipeableID}`; const chatThreadList = React.useMemo( () => ( {fixedSearch} {floatingAction} ), [ extraData, fixedSearch, floatingAction, indicatorStyle, onEndReached, onScroll, partialListData, renderItem, scrollEnabled, styles.container, styles.flatList, ], ); const onTabPress = React.useCallback(() => { if (!navigation.isFocused()) { return; } if (scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: true }); } else if (route.name === BackgroundChatThreadListRouteName) { navigation.navigate({ name: HomeChatThreadListRouteName }); } }, [navigation, route.name]); React.useEffect(() => { const clearNavigationBlurListener = navigation.addListener('blur', () => { setNumItemsToDisplay(25); }); return () => { // `.addListener` returns function that can be called to unsubscribe. // https://reactnavigation.org/docs/navigation-events/#navigationaddlistener clearNavigationBlurListener(); }; }, [navigation]); React.useEffect(() => { const chatNavigation = navigation.getParent< ScreenParamList, 'ChatThreadList', StackNavigationState, StackOptions, StackNavigationEventMap, ChatNavigationProp<'ChatThreadList'>, >(); invariant(chatNavigation, 'ChatNavigator should be within TabNavigator'); const tabNavigation = chatNavigation.getParent< ScreenParamList, 'Chat', TabNavigationState, BottomTabOptions, BottomTabNavigationEventMap, TabNavigationProp<'Chat'>, >(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', onTabPress); return () => { tabNavigation.removeListener('tabPress', onTabPress); }; }, [navigation, onTabPress]); React.useEffect(() => { BackHandler.addEventListener('hardwareBackPress', hardwareBack); return () => { BackHandler.removeEventListener('hardwareBackPress', hardwareBack); }; }, [hardwareBack]); React.useEffect(() => { if (scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: false }); } }, [searchText]); const isSearchActivating = searchStatus === 'activating'; React.useEffect(() => { if (isSearchActivating && scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: true }); } }, [isSearchActivating]); return chatThreadList; } const unboundStyles = { icon: { fontSize: 28, }, container: { flex: 1, }, searchContainer: { backgroundColor: 'listBackground', display: 'flex', justifyContent: 'center', flexDirection: 'row', }, flatList: { flex: 1, backgroundColor: 'listBackground', }, }; export default ChatThreadList; diff --git a/native/chat/fullscreen-thread-media-gallery.react.js b/native/chat/fullscreen-thread-media-gallery.react.js index 8af0582a1..bb98930b9 100644 --- a/native/chat/fullscreen-thread-media-gallery.react.js +++ b/native/chat/fullscreen-thread-media-gallery.react.js @@ -1,180 +1,179 @@ // @flow import * as React from 'react'; import { Text, TouchableOpacity, View } from 'react-native'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import type { ChatNavigationProp } from './chat.react.js'; import ThreadSettingsMediaGallery from './settings/thread-settings-media-gallery.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; import type { VerticalBounds } from '../types/layout-types.js'; export type FullScreenThreadMediaGalleryParams = { - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, }; const Tabs = { All: 'ALL', Images: 'IMAGES', Videos: 'VIDEOS', }; type FilterBarProps = { +setActiveTab: (tab: string) => void, +activeTab: string, }; function FilterBar(props: FilterBarProps): React.Node { const styles = useStyles(unboundStyles); const { setActiveTab, activeTab } = props; const allTabsOnPress = React.useCallback( () => setActiveTab(Tabs.All), [setActiveTab], ); const imagesTabOnPress = React.useCallback( () => setActiveTab(Tabs.Images), [setActiveTab], ); const videosTabOnPress = React.useCallback( () => setActiveTab(Tabs.Videos), [setActiveTab], ); const tabStyles = (currentTab: string) => currentTab === activeTab ? styles.tabActiveItem : styles.tabItem; return ( {Tabs.All} {Tabs.Images} {Tabs.Videos} ); } type FullScreenThreadMediaGalleryProps = { +navigation: ChatNavigationProp<'FullScreenThreadMediaGallery'>, +route: NavigationRoute<'FullScreenThreadMediaGallery'>, }; function FullScreenThreadMediaGallery( props: FullScreenThreadMediaGalleryProps, ): React.Node { const { threadInfo } = props.route.params; const { id } = threadInfo; const styles = useStyles(unboundStyles); const [activeTab, setActiveTab] = React.useState(Tabs.All); const flatListContainerRef = React.useRef>(); const [verticalBounds, setVerticalBounds] = React.useState(null); const onFlatListContainerLayout = React.useCallback(() => { if (!flatListContainerRef.current) { return; } flatListContainerRef.current.measure( (x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } setVerticalBounds({ height, y: pageY }); }, ); }, [flatListContainerRef]); return ( ); } const unboundStyles = { container: { marginBottom: 120, }, filterBar: { display: 'flex', flexDirection: 'column', alignItems: 'center', marginTop: 20, marginBottom: 40, }, tabNavigator: { display: 'flex', flexDirection: 'row', alignItems: 'flex-start', position: 'absolute', width: '90%', padding: 0, }, tabActiveItem: { display: 'flex', justifyContent: 'center', alignItems: 'center', backgroundColor: 'floatingButtonBackground', flex: 1, height: 30, borderRadius: 8, }, tabItem: { display: 'flex', justifyContent: 'center', alignItems: 'center', backgroundColor: 'listInputBackground', flex: 1, height: 30, }, tabText: { color: 'floatingButtonLabel', }, }; const MemoizedFullScreenMediaGallery: React.ComponentType = React.memo(FullScreenThreadMediaGallery); export default MemoizedFullScreenMediaGallery; diff --git a/native/chat/inline-engagement.react.js b/native/chat/inline-engagement.react.js index 1794c20df..8386d08ca 100644 --- a/native/chat/inline-engagement.react.js +++ b/native/chat/inline-engagement.react.js @@ -1,568 +1,567 @@ // @flow import { useNavigation } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { Text, View } from 'react-native'; import Animated, { Extrapolate, interpolateNode, } from 'react-native-reanimated'; import type { ReactionInfo } from 'lib/selectors/chat-selectors.js'; import { getInlineEngagementSidebarText } from 'lib/shared/inline-engagement-utils.js'; import { useNextLocalID } from 'lib/shared/message-utils.js'; import type { MessageInfo } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { avatarOffset, composedMessageStyle, inlineEngagementCenterStyle, inlineEngagementLabelStyle, inlineEngagementRightStyle, inlineEngagementStyle, } from './chat-constants.js'; import { useNavigateToThread } from './message-list-types.js'; import { useSendReaction } from './reaction-message-utils.js'; import CommIcon from '../components/comm-icon.react.js'; import GestureTouchableOpacity from '../components/gesture-touchable-opacity.react.js'; import { MessageReactionsModalRouteName } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; function dummyNodeForInlineEngagementHeightMeasurement( - sidebarInfo: ?LegacyThreadInfo | ?ThreadInfo, + sidebarInfo: ?ThreadInfo, reactions: ReactionInfo, ): React.Element { return ( ); } type DummyInlineEngagementNodeProps = { ...React.ElementConfig, +editedLabel?: ?string, - +sidebarInfo: ?LegacyThreadInfo | ?ThreadInfo, + +sidebarInfo: ?ThreadInfo, +reactions: ReactionInfo, }; function DummyInlineEngagementNode( props: DummyInlineEngagementNodeProps, ): React.Node { const { editedLabel, sidebarInfo, reactions, ...rest } = props; const dummyEditedLabel = React.useMemo(() => { if (!editedLabel) { return null; } return ( {editedLabel} ); }, [editedLabel]); const dummySidebarItem = React.useMemo(() => { if (!sidebarInfo) { return null; } const repliesText = getInlineEngagementSidebarText(sidebarInfo); return ( {repliesText} ); }, [sidebarInfo]); const dummyReactionsList = React.useMemo(() => { if (Object.keys(reactions).length === 0) { return null; } return Object.keys(reactions).map(reaction => { const numOfReacts = reactions[reaction].users.length; return ( {`${reaction} ${numOfReacts}`} ); }); }, [reactions]); const dummyContainerStyle = React.useMemo( () => [unboundStyles.inlineEngagement, unboundStyles.dummyInlineEngagement], [], ); if (!dummyEditedLabel && !dummySidebarItem && !dummyReactionsList) { return null; } return ( {dummyEditedLabel} {dummySidebarItem} {dummyReactionsList} ); } type Props = { +messageInfo: MessageInfo, +threadInfo: ThreadInfo, +sidebarThreadInfo: ?ThreadInfo, +reactions: ReactionInfo, +disabled?: boolean, +positioning?: 'left' | 'right' | 'center', +label?: ?string, }; function InlineEngagement(props: Props): React.Node { const { messageInfo, threadInfo, sidebarThreadInfo, reactions, disabled = false, positioning, label, } = props; const isLeft = positioning === 'left'; const isRight = positioning === 'right'; const isCenter = positioning === 'center'; const navigateToThread = useNavigateToThread(); const { navigate } = useNavigation(); const styles = useStyles(unboundStyles); const editedLabelStyle = React.useMemo(() => { const stylesResult = [styles.messageLabel, styles.messageLabelColor]; if (isLeft) { stylesResult.push(styles.messageLabelLeft); } else { stylesResult.push(styles.messageLabelRight); } return stylesResult; }, [ isLeft, styles.messageLabel, styles.messageLabelColor, styles.messageLabelLeft, styles.messageLabelRight, ]); const editedLabel = React.useMemo(() => { if (!label) { return null; } return ( {label} ); }, [editedLabelStyle, label]); const unreadStyle = sidebarThreadInfo?.currentUser.unread ? styles.unread : null; const repliesStyles = React.useMemo( () => [styles.repliesText, styles.repliesTextColor, unreadStyle], [styles.repliesText, styles.repliesTextColor, unreadStyle], ); const onPressSidebar = React.useCallback(() => { if (sidebarThreadInfo && !disabled) { navigateToThread({ threadInfo: sidebarThreadInfo }); } }, [disabled, navigateToThread, sidebarThreadInfo]); const repliesText = getInlineEngagementSidebarText(sidebarThreadInfo); const sidebarStyle = React.useMemo(() => { const stylesResult = [styles.sidebar, styles.sidebarColor]; if (Object.keys(reactions).length === 0) { return stylesResult; } if (isRight) { stylesResult.push(styles.sidebarMarginLeft); } else { stylesResult.push(styles.sidebarMarginRight); } return stylesResult; }, [ isRight, reactions, styles.sidebar, styles.sidebarColor, styles.sidebarMarginLeft, styles.sidebarMarginRight, ]); const sidebarItem = React.useMemo(() => { if (!sidebarThreadInfo) { return null; } return ( {repliesText} ); }, [ sidebarThreadInfo, onPressSidebar, sidebarStyle, styles.icon, repliesStyles, repliesText, ]); const localID = useNextLocalID(); const sendReaction = useSendReaction( messageInfo.id, localID, threadInfo.id, reactions, ); const onPressReaction = React.useCallback( (reaction: string) => sendReaction(reaction), [sendReaction], ); const onLongPressReaction = React.useCallback(() => { navigate<'MessageReactionsModal'>({ name: MessageReactionsModalRouteName, params: { reactions }, }); }, [navigate, reactions]); const reactionStyle = React.useMemo(() => { const stylesResult = [ styles.reactionsContainer, styles.reactionsContainerColor, ]; if (isRight) { stylesResult.push(styles.reactionsContainerMarginLeft); } else { stylesResult.push(styles.reactionsContainerMarginRight); } return stylesResult; }, [ isRight, styles.reactionsContainer, styles.reactionsContainerColor, styles.reactionsContainerMarginLeft, styles.reactionsContainerMarginRight, ]); const reactionList = React.useMemo(() => { if (Object.keys(reactions).length === 0) { return null; } return Object.keys(reactions).map(reaction => { const reactionInfo = reactions[reaction]; const numOfReacts = reactionInfo.users.length; const style = reactionInfo.viewerReacted ? [...reactionStyle, styles.reactionsContainerSelected] : reactionStyle; return ( onPressReaction(reaction)} onLongPress={onLongPressReaction} activeOpacity={0.7} key={reaction} > {`${reaction} ${numOfReacts}`} ); }); }, [ onLongPressReaction, onPressReaction, reactionStyle, reactions, styles.reaction, styles.reactionColor, styles.reactionsContainerSelected, ]); const inlineEngagementPositionStyle = React.useMemo(() => { const styleResult = [styles.inlineEngagement]; if (isRight) { styleResult.push(styles.rightInlineEngagement); } else if (isCenter) { styleResult.push(styles.centerInlineEngagement); } return styleResult; }, [ isCenter, isRight, styles.centerInlineEngagement, styles.inlineEngagement, styles.rightInlineEngagement, ]); return ( {editedLabel} {sidebarItem} {reactionList} ); } const unboundStyles = { inlineEngagement: { flexDirection: 'row', marginBottom: inlineEngagementStyle.marginBottom, marginLeft: avatarOffset, flexWrap: 'wrap', top: inlineEngagementStyle.topOffset, }, dummyInlineEngagement: { marginRight: 8, }, centerInlineEngagement: { marginLeft: 20, marginRight: 20, justifyContent: 'center', }, rightInlineEngagement: { flexDirection: 'row-reverse', marginLeft: inlineEngagementRightStyle.marginLeft, }, sidebar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, paddingVertical: 4, borderRadius: 8, marginTop: inlineEngagementStyle.marginTop, }, dummySidebar: { paddingRight: 8, // 14 (icon) + 4 (marginRight of icon) + 8 (original left padding) paddingLeft: 26, marginRight: 4, }, sidebarColor: { backgroundColor: 'inlineEngagementBackground', }, sidebarMarginLeft: { marginLeft: 4, }, sidebarMarginRight: { marginRight: 4, }, icon: { color: 'inlineEngagementLabel', marginRight: 4, }, repliesText: { fontSize: 14, lineHeight: 22, }, repliesTextColor: { color: 'inlineEngagementLabel', }, unread: { color: 'listForegroundLabel', fontWeight: 'bold', }, reactionsContainer: { display: 'flex', flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, paddingVertical: 4, borderRadius: 8, marginTop: inlineEngagementStyle.marginTop, }, dummyReactionContainer: { marginRight: 4, }, reactionsContainerColor: { backgroundColor: 'inlineEngagementBackground', }, reactionsContainerSelected: { borderWidth: 1, borderColor: 'inlineEngagementLabel', paddingHorizontal: 7, paddingVertical: 3, }, reactionsContainerMarginLeft: { marginLeft: 4, }, reactionsContainerMarginRight: { marginRight: 4, }, reaction: { fontSize: 14, lineHeight: 22, }, reactionColor: { color: 'inlineEngagementLabel', }, messageLabel: { paddingHorizontal: 3, fontSize: 13, top: inlineEngagementLabelStyle.topOffset, height: inlineEngagementLabelStyle.height, marginTop: inlineEngagementStyle.marginTop, }, dummyMessageLabel: { marginLeft: 9, marginRight: 4, }, messageLabelColor: { color: 'messageLabel', }, messageLabelLeft: { marginLeft: 9, marginRight: 4, }, messageLabelRight: { marginRight: 10, marginLeft: 4, }, avatarOffset: { width: avatarOffset, }, }; type TooltipInlineEngagementProps = { +item: ChatMessageInfoItemWithHeight, +isOpeningSidebar: boolean, +progress: Animated.Node, +windowWidth: number, +positioning: 'left' | 'right' | 'center', +initialCoordinates: { +x: number, +y: number, +width: number, +height: number, }, }; function TooltipInlineEngagement( props: TooltipInlineEngagementProps, ): React.Node { const { item, isOpeningSidebar, progress, windowWidth, initialCoordinates, positioning, } = props; // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return const inlineEngagementStyles = React.useMemo(() => { if (positioning === 'left') { return { position: 'absolute', top: inlineEngagementStyle.marginTop + inlineEngagementStyle.topOffset, left: composedMessageStyle.marginLeft, }; } else if (positioning === 'right') { return { position: 'absolute', right: inlineEngagementRightStyle.marginLeft + composedMessageStyle.marginRight, top: inlineEngagementStyle.marginTop + inlineEngagementStyle.topOffset, }; } else if (positioning === 'center') { return { alignSelf: 'center', top: inlineEngagementCenterStyle.topOffset, }; } invariant( false, `${positioning} is not a valid positioning value for InlineEngagement`, ); }, [positioning]); const inlineEngagementContainer = React.useMemo(() => { const opacity = isOpeningSidebar ? 0 : interpolateNode(progress, { inputRange: [0, 1], outputRange: [1, 0], extrapolate: Extrapolate.CLAMP, }); return { position: 'absolute', width: windowWidth, top: initialCoordinates.height, left: -initialCoordinates.x, opacity, }; }, [ initialCoordinates.height, initialCoordinates.x, isOpeningSidebar, progress, windowWidth, ]); return ( ); } export { InlineEngagement, TooltipInlineEngagement, DummyInlineEngagementNode, dummyNodeForInlineEngagementHeightMeasurement, }; diff --git a/native/chat/inner-robotext-message.react.js b/native/chat/inner-robotext-message.react.js index 30e938a98..9c1a9dfbd 100644 --- a/native/chat/inner-robotext-message.react.js +++ b/native/chat/inner-robotext-message.react.js @@ -1,176 +1,175 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, TouchableWithoutFeedback, View } from 'react-native'; import type { ReactionInfo } from 'lib/selectors/chat-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { type EntityText, entityTextToRawString, entityTextToReact, useENSNamesForEntityText, } from 'lib/utils/entity-text.js'; import { DummyInlineEngagementNode } from './inline-engagement.react.js'; import { useNavigateToThread } from './message-list-types.js'; import Markdown from '../markdown/markdown.react.js'; import { inlineMarkdownRules } from '../markdown/rules.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useOverlayStyles } from '../themes/colors.js'; import type { ChatRobotextMessageInfoItemWithHeight } from '../types/chat-types.js'; import { useNavigateToUserProfileBottomSheet } from '../user-profile/user-profile-utils.js'; function dummyNodeForRobotextMessageHeightMeasurement( robotext: EntityText, threadID: string, - sidebarInfo: ?LegacyThreadInfo | ?ThreadInfo, + sidebarInfo: ?ThreadInfo, reactions: ReactionInfo, ): React.Element { return ( {entityTextToRawString(robotext, { threadID })} ); } type InnerRobotextMessageProps = { +item: ChatRobotextMessageInfoItemWithHeight, +onPress: () => void, +onLongPress?: () => void, }; function InnerRobotextMessage(props: InnerRobotextMessageProps): React.Node { const { item, onLongPress, onPress } = props; const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const styles = useOverlayStyles(unboundStyles); const { messageInfo, robotext } = item; const { threadID } = messageInfo; const robotextWithENSNames = useENSNamesForEntityText(robotext); invariant( robotextWithENSNames, 'useENSNamesForEntityText only returns falsey when passed falsey', ); const textParts = React.useMemo(() => { const darkColor = activeTheme === 'dark'; return entityTextToReact(robotextWithENSNames, threadID, { renderText: ({ text }) => ( {text} ), renderThread: ({ id, name }) => , renderUser: ({ userID, usernameText }) => ( ), renderColor: ({ hex }) => , }); }, [robotextWithENSNames, activeTheme, threadID, styles.robotext]); return ( {textParts} ); } type ThreadEntityProps = { +id: string, +name: string, }; function ThreadEntity(props: ThreadEntityProps) { const threadID = props.id; const threadInfo = useSelector(state => threadInfoSelector(state)[threadID]); const styles = useOverlayStyles(unboundStyles); const navigateToThread = useNavigateToThread(); const onPressThread = React.useCallback(() => { invariant(threadInfo, 'onPressThread should have threadInfo'); navigateToThread({ threadInfo }); }, [threadInfo, navigateToThread]); if (!threadInfo) { return {props.name}; } return ( {props.name} ); } type UserEntityProps = { +userID: string, +usernameText: string, }; function UserEntity(props: UserEntityProps) { const { userID, usernameText } = props; const styles = useOverlayStyles(unboundStyles); const navigateToUserProfileBottomSheet = useNavigateToUserProfileBottomSheet(); const onPressUser = React.useCallback( () => navigateToUserProfileBottomSheet(userID), [navigateToUserProfileBottomSheet, userID], ); return ( {usernameText} ); } 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', }, }; const MemoizedInnerRobotextMessage: React.ComponentType = React.memo(InnerRobotextMessage); export { dummyNodeForRobotextMessageHeightMeasurement, MemoizedInnerRobotextMessage as InnerRobotextMessage, }; diff --git a/native/chat/inner-text-message.react.js b/native/chat/inner-text-message.react.js index d8492c76b..46dbc540e 100644 --- a/native/chat/inner-text-message.react.js +++ b/native/chat/inner-text-message.react.js @@ -1,223 +1,222 @@ // @flow import * as React from 'react'; import { StyleSheet, TouchableWithoutFeedback, View } from 'react-native'; import Animated from 'react-native-reanimated'; import type { ReactionInfo } from 'lib/selectors/chat-selectors.js'; import { colorIsDark } from 'lib/shared/color-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { useComposedMessageMaxWidth } from './composed-message-width.js'; import { DummyInlineEngagementNode } from './inline-engagement.react.js'; import { useTextMessageMarkdownRules } from './message-list-types.js'; import { allCorners, filterCorners, getRoundedContainerStyle, } from './rounded-corners.js'; import { TextMessageMarkdownContext, useTextMessageMarkdown, } from './text-message-markdown-context.js'; import GestureTouchableOpacity from '../components/gesture-touchable-opacity.react.js'; import Markdown from '../markdown/markdown.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { colors, useColors } from '../themes/colors.js'; import type { ChatTextMessageInfoItemWithHeight } from '../types/chat-types.js'; const { Node } = Animated; function dummyNodeForTextMessageHeightMeasurement( text: string, editedLabel?: ?string, - sidebarInfo: ?LegacyThreadInfo | ?ThreadInfo, + sidebarInfo: ?ThreadInfo, reactions: ReactionInfo, ): React.Element { return ( {text} ); } type DummyTextNodeProps = { ...React.ElementConfig, +children: string, }; function DummyTextNode(props: DummyTextNodeProps): React.Node { const { children, style, ...rest } = props; const maxWidth = useComposedMessageMaxWidth(); const viewStyle = [props.style, styles.dummyMessage, { maxWidth }]; const rules = useTextMessageMarkdownRules(false); return ( {children} ); } type Props = { +item: ChatTextMessageInfoItemWithHeight, +onPress: () => void, +messageRef?: (message: ?React.ElementRef) => void, +threadColorOverride?: ?Node, +isThreadColorDarkOverride?: ?boolean, }; function InnerTextMessage(props: Props): React.Node { const { item } = props; const { text, creator } = item.messageInfo; const { isViewer } = creator; const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const boundColors = useColors(); const darkColor = !isViewer ? activeTheme === 'dark' : props.isThreadColorDarkOverride ?? colorIsDark(item.threadInfo.color); const messageStyle = React.useMemo( () => ({ backgroundColor: !isViewer ? boundColors.listChatBubble : props.threadColorOverride ?? `#${item.threadInfo.color}`, }), [ boundColors.listChatBubble, isViewer, item.threadInfo.color, props.threadColorOverride, ], ); const cornerStyle = React.useMemo( () => getRoundedContainerStyle(filterCorners(allCorners, item)), [item], ); const rules = useTextMessageMarkdownRules(darkColor); const textMessageMarkdown = useTextMessageMarkdown(item.messageInfo); const markdownStyles = React.useMemo(() => { const textStyle = { color: darkColor ? colors.dark.listForegroundLabel : colors.light.listForegroundLabel, }; return [styles.text, textStyle]; }, [darkColor]); // If we need to render a Text with an onPress prop inside, we're going to // have an issue: the GestureTouchableOpacity below will trigger too when the // the onPress is pressed. We have to use a GestureTouchableOpacity in order // for the message touch gesture to play nice with the message swipe gesture, // so we need to find a way to disable the GestureTouchableOpacity. // // Our solution is to keep using the GestureTouchableOpacity for the padding // around the text, and to have the Texts inside ALL implement an onPress prop // that will default to the message touch gesture. Luckily, Text with onPress // plays nice with the message swipe gesture. const secondMessageStyle = React.useMemo( () => [StyleSheet.absoluteFill, styles.message], [], ); const secondMessage = React.useMemo(() => { if (!textMessageMarkdown.markdownHasPressable) { return undefined; } return ( {text} ); }, [ markdownStyles, rules, secondMessageStyle, text, textMessageMarkdown.markdownHasPressable, ]); const gestureTouchableOpacityStyle = React.useMemo( () => [styles.message, cornerStyle], [cornerStyle], ); const message = React.useMemo( () => ( {text} {secondMessage} ), [ gestureTouchableOpacityStyle, markdownStyles, messageStyle, props.onPress, rules, secondMessage, text, textMessageMarkdown, ], ); // We need to set onLayout in order to allow .measure() to be on the ref const onLayout = React.useCallback(() => {}, []); const { messageRef } = props; const innerTextMessage = React.useMemo(() => { if (!messageRef) { return message; } return ( {message} ); }, [message, messageRef, onLayout]); return innerTextMessage; } const styles = StyleSheet.create({ dummyMessage: { paddingHorizontal: 12, paddingVertical: 6, }, message: { overflow: 'hidden', paddingHorizontal: 12, paddingVertical: 6, }, text: { fontFamily: 'Arial', fontSize: 18, }, }); export { InnerTextMessage, dummyNodeForTextMessageHeightMeasurement }; diff --git a/native/chat/message-list.react.js b/native/chat/message-list.react.js index 6e1ba1502..3cfea1b9c 100644 --- a/native/chat/message-list.react.js +++ b/native/chat/message-list.react.js @@ -1,374 +1,373 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find.js'; import * as React from 'react'; import { TouchableWithoutFeedback, View } from 'react-native'; import { createSelector } from 'reselect'; import { fetchMessagesBeforeCursorActionTypes, type FetchMessagesBeforeCursorInput, fetchMostRecentMessagesActionTypes, type FetchMostRecentMessagesInput, useFetchMessagesBeforeCursor, useFetchMostRecentMessages, } from 'lib/actions/message-actions.js'; import { useOldestMessageServerID } from 'lib/hooks/message-hooks.js'; import { registerFetchKey } from 'lib/reducers/loading-reducer.js'; import { messageKey } from 'lib/shared/message-utils.js'; import { useWatchThread } from 'lib/shared/thread-utils.js'; import type { FetchMessageInfosPayload } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import ChatList from './chat-list.react.js'; import type { ChatNavigationProp } from './chat.react.js'; import Message from './message.react.js'; import RelationshipPrompt from './relationship-prompt.react.js'; import ListLoadingIndicator from '../components/list-loading-indicator.react.js'; import { KeyboardContext, type KeyboardState, } from '../keyboard/keyboard-state.js'; import { defaultStackScreenOptions } from '../navigation/options.js'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type IndicatorStyle, useIndicatorStyle, useStyles, } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight, ChatMessageItemWithHeight, } from '../types/chat-types.js'; import type { VerticalBounds } from '../types/layout-types.js'; import type { ViewableItemsChange } from '../types/react-native.js'; const unboundStyles = { container: { backgroundColor: 'listBackground', flex: 1, }, header: { height: 12, }, listLoadingIndicator: { flex: 1, }, }; type BaseProps = { - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, +messageListData: $ReadOnlyArray, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, }; type Props = { ...BaseProps, +startReached: boolean, +styles: $ReadOnly, +indicatorStyle: IndicatorStyle, +dispatchActionPromise: DispatchActionPromise, +fetchMessagesBeforeCursor: ( input: FetchMessagesBeforeCursorInput, ) => Promise, +fetchMostRecentMessages: ( input: FetchMostRecentMessagesInput, ) => Promise, +overlayContext: ?OverlayContextType, +keyboardState: ?KeyboardState, +oldestMessageServerID: ?string, }; type State = { +focusedMessageKey: ?string, +messageListVerticalBounds: ?VerticalBounds, +loadingFromScroll: boolean, }; type PropsAndState = { ...Props, ...State, }; type FlatListExtraData = { messageListVerticalBounds: ?VerticalBounds, focusedMessageKey: ?string, navigation: ChatNavigationProp<'MessageList'>, route: NavigationRoute<'MessageList'>, }; class MessageList extends React.PureComponent { state: State = { focusedMessageKey: null, messageListVerticalBounds: null, loadingFromScroll: false, }; flatListContainer: ?React.ElementRef; flatListExtraDataSelector: PropsAndState => FlatListExtraData = createSelector( (propsAndState: PropsAndState) => propsAndState.messageListVerticalBounds, (propsAndState: PropsAndState) => propsAndState.focusedMessageKey, (propsAndState: PropsAndState) => propsAndState.navigation, (propsAndState: PropsAndState) => propsAndState.route, ( messageListVerticalBounds: ?VerticalBounds, focusedMessageKey: ?string, navigation: ChatNavigationProp<'MessageList'>, route: NavigationRoute<'MessageList'>, ) => ({ messageListVerticalBounds, focusedMessageKey, navigation, route, }), ); get flatListExtraData(): FlatListExtraData { return this.flatListExtraDataSelector({ ...this.props, ...this.state }); } static getOverlayContext(props: Props): OverlayContextType { const { overlayContext } = props; invariant(overlayContext, 'MessageList should have OverlayContext'); return overlayContext; } static scrollDisabled(props: Props): boolean { const overlayContext = MessageList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus !== 'closed'; } static modalOpen(props: Props): boolean { const overlayContext = MessageList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus === 'open'; } componentDidUpdate(prevProps: Props) { const modalIsOpen = MessageList.modalOpen(this.props); const modalWasOpen = MessageList.modalOpen(prevProps); if (!modalIsOpen && modalWasOpen) { this.setState({ focusedMessageKey: null }); } if (defaultStackScreenOptions.gestureEnabled) { const scrollIsDisabled = MessageList.scrollDisabled(this.props); const scrollWasDisabled = MessageList.scrollDisabled(prevProps); if (!scrollWasDisabled && scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: false }); } else if (scrollWasDisabled && !scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: true }); } } } dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; renderItem = (row: { item: ChatMessageItemWithHeight, ... }): React.Node => { if (row.item.itemType === 'loader') { return ( ); } const messageInfoItem: ChatMessageInfoItemWithHeight = row.item; const { messageListVerticalBounds, focusedMessageKey, navigation, route } = this.flatListExtraData; const focused = messageKey(messageInfoItem.messageInfo) === focusedMessageKey; return ( ); }; toggleMessageFocus = (inMessageKey: string) => { if (this.state.focusedMessageKey === inMessageKey) { this.setState({ focusedMessageKey: null }); } else { this.setState({ focusedMessageKey: inMessageKey }); } }; // Actually header, it's just that our FlatList is inverted ListFooterComponent = (): React.Node => ( ); render(): React.Node { const { messageListData, startReached } = this.props; const footer = startReached ? this.ListFooterComponent : undefined; let relationshipPrompt = null; if (this.props.threadInfo.type === threadTypes.PERSONAL) { relationshipPrompt = ( ); } return ( {relationshipPrompt} ); } flatListContainerRef = ( flatListContainer: ?React.ElementRef, ) => { this.flatListContainer = flatListContainer; }; onFlatListContainerLayout = () => { const { flatListContainer } = this; if (!flatListContainer) { return; } flatListContainer.measure((x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } this.setState({ messageListVerticalBounds: { height, y: pageY } }); }); }; onViewableItemsChanged = (info: ViewableItemsChange) => { if (this.state.focusedMessageKey) { let focusedMessageVisible = false; for (const token of info.viewableItems) { if ( token.item.itemType === 'message' && messageKey(token.item.messageInfo) === this.state.focusedMessageKey ) { focusedMessageVisible = true; break; } } if (!focusedMessageVisible) { this.setState({ focusedMessageKey: null }); } } const loader = _find({ key: 'loader' })(info.viewableItems); if (!loader || this.state.loadingFromScroll) { return; } this.setState({ loadingFromScroll: true }); const { oldestMessageServerID } = this.props; const threadID = this.props.threadInfo.id; void (async () => { try { if (oldestMessageServerID) { await this.props.dispatchActionPromise( fetchMessagesBeforeCursorActionTypes, this.props.fetchMessagesBeforeCursor({ threadID, beforeMessageID: oldestMessageServerID, }), ); } else { await this.props.dispatchActionPromise( fetchMostRecentMessagesActionTypes, this.props.fetchMostRecentMessages({ threadID }), ); } } finally { this.setState({ loadingFromScroll: false }); } })(); }; } registerFetchKey(fetchMessagesBeforeCursorActionTypes); registerFetchKey(fetchMostRecentMessagesActionTypes); const ConnectedMessageList: React.ComponentType = React.memo(function ConnectedMessageList(props: BaseProps) { const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); const threadID = props.threadInfo.id; const startReached = useSelector( state => !!( state.messageStore.threads[threadID] && state.messageStore.threads[threadID].startReached ), ); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const dispatchActionPromise = useDispatchActionPromise(); const callFetchMessagesBeforeCursor = useFetchMessagesBeforeCursor(); const callFetchMostRecentMessages = useFetchMostRecentMessages(); const oldestMessageServerID = useOldestMessageServerID(threadID); useWatchThread(props.threadInfo); return ( ); }); export default ConnectedMessageList; diff --git a/native/chat/message-preview.react.js b/native/chat/message-preview.react.js index 03d08bc00..bd37943e6 100644 --- a/native/chat/message-preview.react.js +++ b/native/chat/message-preview.react.js @@ -1,94 +1,93 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text } from 'react-native'; import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js'; import { useMessagePreview } from 'lib/shared/message-utils.js'; import { type MessageInfo } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import SingleLine from '../components/single-line.react.js'; import { getDefaultTextMessageRules } from '../markdown/rules.react.js'; import { useStyles } from '../themes/colors.js'; type Props = { +messageInfo: MessageInfo, - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, }; function MessagePreview(props: Props): React.Node { const { messageInfo, threadInfo } = props; const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo); const messagePreviewResult = useMessagePreview( messageInfo, threadInfo, getDefaultTextMessageRules(chatMentionCandidates).simpleMarkdownRules, ); invariant( messagePreviewResult, 'useMessagePreview should only return falsey if pass null or undefined', ); const { message, username } = messagePreviewResult; let messageStyle; const styles = useStyles(unboundStyles); if (message.style === 'unread') { messageStyle = styles.unread; } else if (message.style === 'primary') { messageStyle = styles.primary; } else if (message.style === 'secondary') { messageStyle = styles.secondary; } invariant( messageStyle, `MessagePreview doesn't support ${message.style} style for message, ` + 'only unread, primary, and secondary', ); if (!username) { return ( {message.text} ); } let usernameStyle; if (username.style === 'unread') { usernameStyle = styles.unread; } else if (username.style === 'secondary') { usernameStyle = styles.secondary; } invariant( usernameStyle, `MessagePreview doesn't support ${username.style} style for username, ` + 'only unread and secondary', ); return ( {`${username.text}: `} {message.text} ); } const unboundStyles = { lastMessage: { flex: 1, fontSize: 14, }, primary: { color: 'listForegroundTertiaryLabel', }, secondary: { color: 'listForegroundTertiaryLabel', }, unread: { color: 'listForegroundLabel', }, }; export default MessagePreview; diff --git a/native/chat/relationship-prompt.react.js b/native/chat/relationship-prompt.react.js index 2d386ecfb..744a392f5 100644 --- a/native/chat/relationship-prompt.react.js +++ b/native/chat/relationship-prompt.react.js @@ -1,168 +1,167 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome5.js'; import * as React from 'react'; import { Text, View } from 'react-native'; import { useRelationshipPrompt } from 'lib/hooks/relationship-prompt.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from 'lib/types/relationship-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import type { UserInfo } from 'lib/types/user-types.js'; import Button from '../components/button.react.js'; import { useStyles } from '../themes/colors.js'; import Alert from '../utils/alert.js'; type Props = { +pendingPersonalThreadUserInfo: ?UserInfo, - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, }; const RelationshipPrompt: React.ComponentType = React.memo( function RelationshipPrompt({ pendingPersonalThreadUserInfo, threadInfo, }: Props) { const onErrorCallback = React.useCallback(() => { Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }]); }, []); const { otherUserInfo, callbacks: { blockUser, unblockUser, friendUser, unfriendUser }, } = useRelationshipPrompt( threadInfo, onErrorCallback, pendingPersonalThreadUserInfo, ); const styles = useStyles(unboundStyles); if ( !otherUserInfo || !otherUserInfo.username || otherUserInfo.relationshipStatus === userRelationshipStatus.FRIEND ) { return null; } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.BLOCKED_VIEWER ) { return ( ); } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.BOTH_BLOCKED || otherUserInfo.relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ) { return ( ); } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED ) { return ( ); } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.REQUEST_SENT ) { return ( ); } return ( ); }, ); const unboundStyles = { container: { paddingVertical: 10, paddingHorizontal: 5, backgroundColor: 'panelBackground', flexDirection: 'row', }, button: { padding: 10, borderRadius: 5, flex: 1, flexDirection: 'row', justifyContent: 'center', marginHorizontal: 5, }, greenButton: { backgroundColor: 'vibrantGreenButton', }, redButton: { backgroundColor: 'vibrantRedButton', }, buttonText: { fontSize: 11, color: 'white', fontWeight: 'bold', textAlign: 'center', marginLeft: 5, }, }; export default RelationshipPrompt; diff --git a/native/chat/settings/add-users-modal.react.js b/native/chat/settings/add-users-modal.react.js index 59a52bbe3..54ad7940f 100644 --- a/native/chat/settings/add-users-modal.react.js +++ b/native/chat/settings/add-users-modal.react.js @@ -1,286 +1,285 @@ // @flow import * as React from 'react'; import { ActivityIndicator, Text, View } from 'react-native'; import { changeThreadSettingsActionTypes, useChangeThreadSettings, } from 'lib/actions/thread-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userInfoSelectorForPotentialMembers } from 'lib/selectors/user-selectors.js'; import { usePotentialMemberItems } from 'lib/shared/search-utils.js'; import { threadActualMembers } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { type AccountUserInfo } from 'lib/types/user-types.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import Button from '../../components/button.react.js'; import Modal from '../../components/modal.react.js'; import { type BaseTagInput, createTagInput, } from '../../components/tag-input.react.js'; import UserList from '../../components/user-list.react.js'; import type { RootNavigationProp } from '../../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { useStyles } from '../../themes/colors.js'; import Alert from '../../utils/alert.js'; const TagInput = createTagInput(); const tagInputProps = { placeholder: 'Select users to add', autoFocus: true, returnKeyType: 'go', }; const tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; export type AddUsersModalParams = { +presentedFrom: string, - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, }; type Props = { +navigation: RootNavigationProp<'AddUsersModal'>, +route: NavigationRoute<'AddUsersModal'>, }; function AddUsersModal(props: Props): React.Node { const [usernameInputText, setUsernameInputText] = React.useState(''); const [userInfoInputArray, setUserInfoInputArray] = React.useState< $ReadOnlyArray, >([]); const tagInputRef = React.useRef>(); const onUnknownErrorAlertAcknowledged = React.useCallback(() => { setUsernameInputText(''); setUserInfoInputArray([]); tagInputRef.current?.focus(); }, []); const { navigation } = props; const { goBackOnce } = navigation; const close = React.useCallback(() => { goBackOnce(); }, [goBackOnce]); const callChangeThreadSettings = useChangeThreadSettings(); const userInfoInputIDs = userInfoInputArray.map(userInfo => userInfo.id); const { route } = props; const { threadInfo } = route.params; const threadID = threadInfo.id; const addUsersToThread = React.useCallback(async () => { try { const result = await callChangeThreadSettings({ threadID: threadID, changes: { newMemberIDs: userInfoInputIDs }, }); close(); return result; } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); throw e; } }, [ callChangeThreadSettings, threadID, userInfoInputIDs, close, onUnknownErrorAlertAcknowledged, ]); const inputLength = userInfoInputArray.length; const dispatchActionPromise = useDispatchActionPromise(); const userInfoInputArrayEmpty = inputLength === 0; const onPressAdd = React.useCallback(() => { if (userInfoInputArrayEmpty) { return; } void dispatchActionPromise( changeThreadSettingsActionTypes, addUsersToThread(), ); }, [userInfoInputArrayEmpty, dispatchActionPromise, addUsersToThread]); const changeThreadSettingsLoadingStatus = useSelector( createLoadingStatusSelector(changeThreadSettingsActionTypes), ); const isLoading = changeThreadSettingsLoadingStatus === 'loading'; const styles = useStyles(unboundStyles); let addButton = null; if (inputLength > 0) { let activityIndicator = null; if (isLoading) { activityIndicator = ( ); } const addButtonText = `Add (${inputLength})`; addButton = ( ); } let cancelButton; if (!isLoading) { cancelButton = ( ); } else { cancelButton = ; } const threadMemberIDs = React.useMemo( () => threadActualMembers(threadInfo.members), [threadInfo.members], ); const excludeUserIDs = React.useMemo( () => userInfoInputIDs.concat(threadMemberIDs), [userInfoInputIDs, threadMemberIDs], ); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const { parentThreadID, community } = props.route.params.threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const communityThreadInfo = useSelector(state => community ? threadInfoSelector(state)[community] : null, ); const userSearchResults = usePotentialMemberItems({ text: usernameInputText, userInfos: otherUserInfos, excludeUserIDs, inputParentThreadInfo: parentThreadInfo, inputCommunityThreadInfo: communityThreadInfo, threadType: threadInfo.type, }); const onChangeTagInput = React.useCallback( (newUserInfoInputArray: $ReadOnlyArray) => { if (!isLoading) { setUserInfoInputArray(newUserInfoInputArray); } }, [isLoading], ); const onChangeTagInputText = React.useCallback( (text: string) => { if (!isLoading) { setUsernameInputText(text); } }, [isLoading], ); const onUserSelect = React.useCallback( ({ id }: AccountUserInfo) => { if (isLoading) { return; } if (userInfoInputIDs.some(existingUserID => id === existingUserID)) { return; } setUserInfoInputArray(oldUserInfoInputArray => [ ...oldUserInfoInputArray, otherUserInfos[id], ]); setUsernameInputText(''); }, [isLoading, userInfoInputIDs, otherUserInfos], ); const inputProps = React.useMemo( () => ({ ...tagInputProps, onSubmitEditing: onPressAdd, }), [onPressAdd], ); const userSearchResultWithENSNames = useENSNames(userSearchResults); const userInfoInputArrayWithENSNames = useENSNames(userInfoInputArray); return ( {cancelButton} {addButton} ); } const unboundStyles = { activityIndicator: { paddingRight: 6, }, addButton: { backgroundColor: 'vibrantGreenButton', borderRadius: 3, flexDirection: 'row', paddingHorizontal: 10, paddingVertical: 4, }, addText: { color: 'white', fontSize: 18, }, buttons: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 12, }, cancelButton: { backgroundColor: 'modalButton', borderRadius: 3, paddingHorizontal: 10, paddingVertical: 4, }, cancelText: { color: 'modalButtonLabel', fontSize: 18, }, }; const MemoizedAddUsersModal: React.ComponentType = React.memo(AddUsersModal); export default MemoizedAddUsersModal; diff --git a/native/chat/settings/color-selector-modal.react.js b/native/chat/settings/color-selector-modal.react.js index 0dc6e805f..3be36642a 100644 --- a/native/chat/settings/color-selector-modal.react.js +++ b/native/chat/settings/color-selector-modal.react.js @@ -1,193 +1,192 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome.js'; import * as React from 'react'; import { TouchableHighlight } from 'react-native'; import { changeThreadSettingsActionTypes, useChangeThreadSettings, } from 'lib/actions/thread-actions.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { type ChangeThreadSettingsPayload, type UpdateThreadRequest, - type LegacyThreadInfo, } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import ColorSelector from '../../components/color-selector.react.js'; import Modal from '../../components/modal.react.js'; import type { RootNavigationProp } from '../../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../../themes/colors.js'; import Alert from '../../utils/alert.js'; export type ColorSelectorModalParams = { +presentedFrom: string, +color: string, - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, +setColor: (color: string) => void, }; const unboundStyles = { closeButton: { borderRadius: 3, height: 18, position: 'absolute', right: 5, top: 5, width: 18, }, closeButtonIcon: { color: 'modalBackgroundSecondaryLabel', left: 3, position: 'absolute', }, colorSelector: { bottom: 10, left: 10, position: 'absolute', right: 10, top: 10, }, colorSelectorContainer: { backgroundColor: 'modalBackground', borderRadius: 5, flex: 0, marginHorizontal: 15, marginVertical: 20, }, }; type BaseProps = { +navigation: RootNavigationProp<'ColorSelectorModal'>, +route: NavigationRoute<'ColorSelectorModal'>, }; type Props = { ...BaseProps, // Redux state +colors: Colors, +styles: $ReadOnly, +windowWidth: number, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeThreadSettings: ( request: UpdateThreadRequest, ) => Promise, }; function ColorSelectorModal(props: Props): React.Node { const { changeThreadSettings: updateThreadSettings, dispatchActionPromise, windowWidth, } = props; const { threadInfo, setColor } = props.route.params; const close = props.navigation.goBackOnce; const onErrorAcknowledged = React.useCallback(() => { setColor(threadInfo.color); }, [setColor, threadInfo.color]); const editColor = React.useCallback( async (newColor: string) => { const threadID = threadInfo.id; try { return await updateThreadSettings({ threadID, changes: { color: newColor }, }); } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: onErrorAcknowledged }], { cancelable: false }, ); throw e; } }, [onErrorAcknowledged, threadInfo.id, updateThreadSettings], ); const onColorSelected = React.useCallback( (color: string) => { const colorEditValue = color.substr(1); setColor(colorEditValue); close(); const action = changeThreadSettingsActionTypes.started; const threadID = props.route.params.threadInfo.id; void dispatchActionPromise( changeThreadSettingsActionTypes, editColor(colorEditValue), { customKeyName: `${action}:${threadID}:color`, }, ); }, [ setColor, close, dispatchActionPromise, editColor, props.route.params.threadInfo.id, ], ); const { colorSelectorContainer, closeButton, closeButtonIcon } = props.styles; // Based on the assumption we are always in portrait, // and consequently width is the lowest dimensions const modalStyle = React.useMemo( () => [colorSelectorContainer, { height: 0.75 * windowWidth }], [colorSelectorContainer, windowWidth], ); const { modalIosHighlightUnderlay } = props.colors; const { color } = props.route.params; return ( ); } const ConnectedColorSelectorModal: React.ComponentType = React.memo(function ConnectedColorSelectorModal(props: BaseProps) { const styles = useStyles(unboundStyles); const colors = useColors(); const windowWidth = useSelector(state => state.dimensions.width); const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useChangeThreadSettings(); return ( ); }); export default ConnectedColorSelectorModal; diff --git a/native/chat/settings/delete-thread.react.js b/native/chat/settings/delete-thread.react.js index b3f48d5ad..9950d10e0 100644 --- a/native/chat/settings/delete-thread.react.js +++ b/native/chat/settings/delete-thread.react.js @@ -1,296 +1,293 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { ActivityIndicator, Text, TextInput as BaseTextInput, View, } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import type { DeleteThreadInput } from 'lib/actions/thread-actions.js'; import { deleteThreadActionTypes, useDeleteThread, } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { containedThreadInfos, threadInfoSelector, } from 'lib/selectors/thread-selectors.js'; import { getThreadsToDeleteText, identifyInvalidatedThreads, } from 'lib/shared/thread-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ResolvedThreadInfo, ThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { - LeaveThreadPayload, - LegacyThreadInfo, -} from 'lib/types/thread-types.js'; +import type { LeaveThreadPayload } from 'lib/types/thread-types.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import Button from '../../components/button.react.js'; import { clearThreadsActionType } from '../../navigation/action-types.js'; import { type NavAction, NavContext, } from '../../navigation/navigation-context.js'; import type { NavigationRoute } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../../themes/colors.js'; import Alert from '../../utils/alert.js'; import type { ChatNavigationProp } from '../chat.react.js'; export type DeleteThreadParams = { - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, }; const unboundStyles = { deleteButton: { backgroundColor: 'vibrantRedButton', borderRadius: 5, flex: 1, marginHorizontal: 24, marginVertical: 12, padding: 12, }, deleteText: { color: 'white', fontSize: 18, textAlign: 'center', }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, input: { color: 'panelForegroundLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, paddingVertical: 0, borderBottomColor: 'transparent', }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, flexDirection: 'row', justifyContent: 'space-between', marginBottom: 24, paddingHorizontal: 24, paddingVertical: 12, }, warningText: { color: 'panelForegroundLabel', fontSize: 16, marginBottom: 24, marginHorizontal: 24, textAlign: 'center', }, }; type BaseProps = { +navigation: ChatNavigationProp<'DeleteThread'>, +route: NavigationRoute<'DeleteThread'>, }; type Props = { ...BaseProps, // Redux state +threadInfo: ResolvedThreadInfo, +shouldUseDeleteConfirmationAlert: boolean, +loadingStatus: LoadingStatus, +colors: Colors, +styles: $ReadOnly, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +deleteThread: (input: DeleteThreadInput) => Promise, // withNavContext +navDispatch: (action: NavAction) => void, }; class DeleteThread extends React.PureComponent { mounted = false; passwordInput: ?React.ElementRef; componentDidMount() { this.mounted = true; } componentWillUnmount() { this.mounted = false; } render(): React.Node { const buttonContent = this.props.loadingStatus === 'loading' ? ( ) : ( Delete chat ); const { threadInfo } = this.props; return ( {`The chat "${threadInfo.uiName}" will be permanently deleted. `} There is no way to reverse this. ); } passwordInputRef = ( passwordInput: ?React.ElementRef, ) => { this.passwordInput = passwordInput; }; focusPasswordInput = () => { invariant(this.passwordInput, 'passwordInput should be set'); this.passwordInput.focus(); }; dispatchDeleteThreadAction = () => { void this.props.dispatchActionPromise( deleteThreadActionTypes, this.deleteThread(), ); }; submitDeletion = () => { if (!this.props.shouldUseDeleteConfirmationAlert) { this.dispatchDeleteThreadAction(); return; } Alert.alert( 'Warning', `${getThreadsToDeleteText( this.props.threadInfo, )} will also be permanently deleted.`, [ { text: 'Cancel', style: 'cancel' }, { text: 'Continue', onPress: this.dispatchDeleteThreadAction }, ], { cancelable: false }, ); }; async deleteThread(): Promise { const { threadInfo, navDispatch } = this.props; navDispatch({ type: clearThreadsActionType, payload: { threadIDs: [threadInfo.id] }, }); try { const result = await this.props.deleteThread({ threadID: threadInfo.id }); const invalidated = identifyInvalidatedThreads( result.updatesResult.newUpdates, ); navDispatch({ type: clearThreadsActionType, payload: { threadIDs: [...invalidated] }, }); return result; } catch (e) { if (e.message === 'invalid_credentials') { Alert.alert( 'Permission not granted', 'You do not have permission to delete this thread', [{ text: 'OK' }], { cancelable: false }, ); } else { Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], { cancelable: false, }); } throw e; } } } const loadingStatusSelector = createLoadingStatusSelector( deleteThreadActionTypes, ); const ConnectedDeleteThread: React.ComponentType = React.memo(function ConnectedDeleteThread(props: BaseProps) { const threadID = props.route.params.threadInfo.id; const reduxThreadInfo = useSelector( state => threadInfoSelector(state)[threadID], ); const reduxContainedThreadInfos = useSelector( state => containedThreadInfos(state)[threadID], ); const { setParams } = props.navigation; React.useEffect(() => { if (reduxThreadInfo) { setParams({ threadInfo: reduxThreadInfo }); } }, [reduxThreadInfo, setParams]); const threadInfo = reduxThreadInfo ?? props.route.params.threadInfo; const resolvedThreadInfo = useResolvedThreadInfo(threadInfo); const loadingStatus = useSelector(loadingStatusSelector); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callDeleteThread = useDeleteThread(); const navContext = React.useContext(NavContext); invariant(navContext, 'NavContext should be set in DeleteThread'); const navDispatch = navContext.dispatch; const shouldUseDeleteConfirmationAlert = reduxContainedThreadInfos && reduxContainedThreadInfos.length > 0; return ( ); }); export default ConnectedDeleteThread; diff --git a/native/chat/settings/emoji-thread-avatar-creation.react.js b/native/chat/settings/emoji-thread-avatar-creation.react.js index ed9b76a58..22107b820 100644 --- a/native/chat/settings/emoji-thread-avatar-creation.react.js +++ b/native/chat/settings/emoji-thread-avatar-creation.react.js @@ -1,64 +1,63 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { EditThreadAvatarContext } from 'lib/components/base-edit-thread-avatar-provider.react.js'; import { savedEmojiAvatarSelectorForThread } from 'lib/selectors/thread-selectors.js'; import type { UpdateUserAvatarRequest } from 'lib/types/avatar-types.js'; import type { ThreadInfo, RawThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { useNativeSetThreadAvatar } from '../../avatars/avatar-hooks.js'; import EmojiAvatarCreation from '../../avatars/emoji-avatar-creation.react.js'; import type { ChatNavigationProp } from '../../chat/chat.react.js'; import { displayActionResultModal } from '../../navigation/action-result-modal.js'; import type { NavigationRoute } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; export type EmojiThreadAvatarCreationParams = { - +threadInfo: RawThreadInfo | LegacyThreadInfo | ThreadInfo, + +threadInfo: RawThreadInfo | ThreadInfo, }; type Props = { +navigation: ChatNavigationProp<'EmojiThreadAvatarCreation'>, +route: NavigationRoute<'EmojiThreadAvatarCreation'>, }; function EmojiThreadAvatarCreation(props: Props): React.Node { const { id: threadID, containingThreadID } = props.route.params.threadInfo; const selector = savedEmojiAvatarSelectorForThread( threadID, containingThreadID, ); const savedEmojiAvatarFunc = useSelector(selector); const editThreadAvatarContext = React.useContext(EditThreadAvatarContext); invariant(editThreadAvatarContext, 'editThreadAvatarContext should be set'); const { threadAvatarSaveInProgress } = editThreadAvatarContext; const nativeSetThreadAvatar = useNativeSetThreadAvatar(); const setAvatar = React.useCallback( async (avatarRequest: UpdateUserAvatarRequest) => { const result = await nativeSetThreadAvatar(threadID, avatarRequest); displayActionResultModal('Avatar updated!'); return result; }, [nativeSetThreadAvatar, threadID], ); return ( ); } export default EmojiThreadAvatarCreation; diff --git a/native/chat/settings/thread-settings-color.react.js b/native/chat/settings/thread-settings-color.react.js index 686137844..dbe812240 100644 --- a/native/chat/settings/thread-settings-color.react.js +++ b/native/chat/settings/thread-settings-color.react.js @@ -1,126 +1,125 @@ // @flow import * as React from 'react'; import { ActivityIndicator, Platform, Text, View } from 'react-native'; import { changeThreadSettingsActionTypes } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import type { ThreadSettingsNavigate } from './thread-settings.react.js'; import ColorSplotch from '../../components/color-splotch.react.js'; import EditSettingButton from '../../components/edit-setting-button.react.js'; import { ColorSelectorModalRouteName } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../../themes/colors.js'; const unboundStyles = { colorLine: { lineHeight: (Platform.select({ android: 22, default: 25 }): number), }, colorRow: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingBottom: 8, paddingHorizontal: 24, paddingTop: 4, }, currentValue: { flex: 1, paddingLeft: 4, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, }; type BaseProps = { - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, +colorEditValue: string, +setColorEditValue: (color: string) => void, +canChangeSettings: boolean, +navigate: ThreadSettingsNavigate, +threadSettingsRouteKey: string, }; type Props = { ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +colors: Colors, +styles: $ReadOnly, }; class ThreadSettingsColor extends React.PureComponent { render(): React.Node { let colorButton; if (this.props.loadingStatus !== 'loading') { colorButton = ( ); } else { colorButton = ( ); } return ( Color {colorButton} ); } onPressEditColor = () => { this.props.navigate<'ColorSelectorModal'>({ name: ColorSelectorModalRouteName, params: { presentedFrom: this.props.threadSettingsRouteKey, color: this.props.colorEditValue, threadInfo: this.props.threadInfo, setColor: this.props.setColorEditValue, }, }); }; } const ConnectedThreadSettingsColor: React.ComponentType = React.memo(function ConnectedThreadSettingsColor( props: BaseProps, ) { const threadID = props.threadInfo.id; const loadingStatus = useSelector( createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${threadID}:color`, ), ); const colors = useColors(); const styles = useStyles(unboundStyles); return ( ); }); export default ConnectedThreadSettingsColor; diff --git a/native/chat/settings/thread-settings-description.react.js b/native/chat/settings/thread-settings-description.react.js index 1b39c170e..c5e692808 100644 --- a/native/chat/settings/thread-settings-description.react.js +++ b/native/chat/settings/thread-settings-description.react.js @@ -1,325 +1,324 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { ActivityIndicator, Text, TextInput as BaseTextInput, View, } from 'react-native'; import { changeThreadSettingsActionTypes, useChangeThreadSettings, } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadHasPermission } from 'lib/shared/thread-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { type ChangeThreadSettingsPayload, type UpdateThreadRequest, - type LegacyThreadInfo, } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import SaveSettingButton from './save-setting-button.react.js'; import { ThreadSettingsCategoryFooter, ThreadSettingsCategoryHeader, } from './thread-settings-category.react.js'; import Button from '../../components/button.react.js'; import EditSettingButton from '../../components/edit-setting-button.react.js'; import SWMansionIcon from '../../components/swmansion-icon.react.js'; import TextInput from '../../components/text-input.react.js'; import { useSelector } from '../../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../../themes/colors.js'; import type { ContentSizeChangeEvent, LayoutEvent, } from '../../types/react-native.js'; import Alert from '../../utils/alert.js'; const unboundStyles = { addDescriptionButton: { flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 10, }, addDescriptionText: { color: 'panelForegroundTertiaryLabel', flex: 1, fontSize: 16, }, editIcon: { color: 'panelForegroundTertiaryLabel', paddingLeft: 10, textAlign: 'right', }, outlineCategory: { backgroundColor: 'panelForeground', borderColor: 'panelForegroundBorder', borderRadius: 1, borderStyle: 'dashed', borderWidth: 1, marginLeft: -1, marginRight: -1, }, row: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 4, }, text: { color: 'panelForegroundSecondaryLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, margin: 0, padding: 0, borderBottomColor: 'transparent', }, }; type BaseProps = { - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, +descriptionEditValue: ?string, +setDescriptionEditValue: (value: ?string, callback?: () => void) => void, +descriptionTextHeight: ?number, +setDescriptionTextHeight: (number: number) => void, +canChangeSettings: boolean, }; type Props = { ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +colors: Colors, +styles: $ReadOnly, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeThreadSettings: ( update: UpdateThreadRequest, ) => Promise, }; class ThreadSettingsDescription extends React.PureComponent { textInput: ?React.ElementRef; render(): React.Node { if ( this.props.descriptionEditValue !== null && this.props.descriptionEditValue !== undefined ) { const textInputStyle: { height?: number } = {}; if ( this.props.descriptionTextHeight !== undefined && this.props.descriptionTextHeight !== null ) { textInputStyle.height = this.props.descriptionTextHeight; } return ( {this.renderButton()} ); } if (this.props.threadInfo.description) { return ( {this.props.threadInfo.description} {this.renderButton()} ); } const canEditThreadDescription = threadHasPermission( this.props.threadInfo, threadPermissions.EDIT_THREAD_DESCRIPTION, ); const { panelIosHighlightUnderlay } = this.props.colors; if (canEditThreadDescription) { return ( ); } return null; } renderButton(): React.Node { if (this.props.loadingStatus === 'loading') { return ( ); } else if ( this.props.descriptionEditValue === null || this.props.descriptionEditValue === undefined ) { return ( ); } return ; } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; onLayoutText = (event: LayoutEvent) => { this.props.setDescriptionTextHeight(event.nativeEvent.layout.height); }; onTextInputContentSizeChange = (event: ContentSizeChangeEvent) => { this.props.setDescriptionTextHeight(event.nativeEvent.contentSize.height); }; onPressEdit = () => { this.props.setDescriptionEditValue(this.props.threadInfo.description); }; onSubmit = () => { invariant( this.props.descriptionEditValue !== null && this.props.descriptionEditValue !== undefined, 'should be set', ); const description = this.props.descriptionEditValue.trim(); if (description === this.props.threadInfo.description) { this.props.setDescriptionEditValue(null); return; } const editDescriptionPromise = this.editDescription(description); const action = changeThreadSettingsActionTypes.started; const threadID = this.props.threadInfo.id; void this.props.dispatchActionPromise( changeThreadSettingsActionTypes, editDescriptionPromise, { customKeyName: `${action}:${threadID}:description`, }, ); void editDescriptionPromise.then(() => { this.props.setDescriptionEditValue(null); }); }; async editDescription( newDescription: string, ): Promise { try { return await this.props.changeThreadSettings({ threadID: this.props.threadInfo.id, changes: { description: newDescription }, }); } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onErrorAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { this.props.setDescriptionEditValue( this.props.threadInfo.description, () => { invariant(this.textInput, 'textInput should be set'); this.textInput.focus(); }, ); }; } const ConnectedThreadSettingsDescription: React.ComponentType = React.memo(function ConnectedThreadSettingsDescription( props: BaseProps, ) { const threadID = props.threadInfo.id; const loadingStatus = useSelector( createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${threadID}:description`, ), ); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useChangeThreadSettings(); return ( ); }); export default ConnectedThreadSettingsDescription; diff --git a/native/chat/settings/thread-settings-edit-relationship.react.js b/native/chat/settings/thread-settings-edit-relationship.react.js index 6f51e5edc..d8bb27b01 100644 --- a/native/chat/settings/thread-settings-edit-relationship.react.js +++ b/native/chat/settings/thread-settings-edit-relationship.react.js @@ -1,130 +1,129 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, View } from 'react-native'; import { updateRelationships as serverUpdateRelationships, updateRelationshipsActionTypes, } from 'lib/actions/relationship-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { getRelationshipActionText, getRelationshipDispatchAction, } from 'lib/shared/relationship-utils.js'; import { getSingleOtherUser } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { type RelationshipAction, type RelationshipButton, } from 'lib/types/relationship-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { useLegacyAshoatKeyserverCall } from 'lib/utils/action-utils.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import Button from '../../components/button.react.js'; import { useSelector } from '../../redux/redux-utils.js'; import { useColors, useStyles } from '../../themes/colors.js'; import type { ViewStyle } from '../../types/styles.js'; import Alert from '../../utils/alert.js'; type Props = { - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, +buttonStyle: ViewStyle, +relationshipButton: RelationshipButton, }; const ThreadSettingsEditRelationship: React.ComponentType = React.memo(function ThreadSettingsEditRelationship(props: Props) { const otherUserInfoFromRedux = useSelector(state => { const currentUserID = state.currentUserInfo?.id; const otherUserID = getSingleOtherUser(props.threadInfo, currentUserID); invariant(otherUserID, 'Other user should be specified'); const { userInfos } = state.userStore; return userInfos[otherUserID]; }); invariant(otherUserInfoFromRedux, 'Other user info should be specified'); const [otherUserInfo] = useENSNames([otherUserInfoFromRedux]); const callUpdateRelationships = useLegacyAshoatKeyserverCall( serverUpdateRelationships, ); const updateRelationship = React.useCallback( async (action: RelationshipAction) => { try { return await callUpdateRelationships({ action, userIDs: [otherUserInfo.id], }); } catch (e) { Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], { cancelable: true, }); throw e; } }, [callUpdateRelationships, otherUserInfo], ); const { relationshipButton } = props; const relationshipAction = React.useMemo( () => getRelationshipDispatchAction(relationshipButton), [relationshipButton], ); const dispatchActionPromise = useDispatchActionPromise(); const onButtonPress = React.useCallback(() => { void dispatchActionPromise( updateRelationshipsActionTypes, updateRelationship(relationshipAction), ); }, [dispatchActionPromise, relationshipAction, updateRelationship]); const colors = useColors(); const { panelIosHighlightUnderlay } = colors; const styles = useStyles(unboundStyles); const otherUserInfoUsername = otherUserInfo.username; invariant(otherUserInfoUsername, 'Other user username should be specified'); const relationshipButtonText = React.useMemo( () => getRelationshipActionText(relationshipButton, otherUserInfoUsername), [otherUserInfoUsername, relationshipButton], ); return ( ); }); const unboundStyles = { button: { flexDirection: 'row', paddingHorizontal: 12, paddingVertical: 10, }, container: { backgroundColor: 'panelForeground', paddingHorizontal: 12, }, text: { color: 'redText', flex: 1, fontSize: 16, }, }; export default ThreadSettingsEditRelationship; diff --git a/native/chat/settings/thread-settings-home-notifs.react.js b/native/chat/settings/thread-settings-home-notifs.react.js index c74c0e458..37c023b11 100644 --- a/native/chat/settings/thread-settings-home-notifs.react.js +++ b/native/chat/settings/thread-settings-home-notifs.react.js @@ -1,119 +1,118 @@ // @flow import * as React from 'react'; import { Switch, View } from 'react-native'; import { updateSubscriptionActionTypes, useUpdateSubscription, } from 'lib/actions/user-actions.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResult, } from 'lib/types/subscription-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import SingleLine from '../../components/single-line.react.js'; import { useStyles } from '../../themes/colors.js'; const unboundStyles = { currentValue: { alignItems: 'flex-end', margin: 0, paddingLeft: 4, paddingRight: 0, paddingVertical: 0, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, flex: 1, }, row: { alignItems: 'center', backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 3, }, }; type BaseProps = { - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, // Redux state +styles: $ReadOnly, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +updateSubscription: ( subscriptionUpdate: SubscriptionUpdateRequest, ) => Promise, }; type State = { +currentValue: boolean, }; class ThreadSettingsHomeNotifs extends React.PureComponent { constructor(props: Props) { super(props); this.state = { currentValue: !props.threadInfo.currentUser.subscription.home, }; } render(): React.Node { const componentLabel = 'Background'; return ( {componentLabel} ); } onValueChange = (value: boolean) => { this.setState({ currentValue: value }); void this.props.dispatchActionPromise( updateSubscriptionActionTypes, this.props.updateSubscription({ threadID: this.props.threadInfo.id, updatedFields: { home: !value, }, }), ); }; } const ConnectedThreadSettingsHomeNotifs: React.ComponentType = React.memo(function ConnectedThreadSettingsHomeNotifs( props: BaseProps, ) { const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateSubscription = useUpdateSubscription(); return ( ); }); export default ConnectedThreadSettingsHomeNotifs; diff --git a/native/chat/settings/thread-settings-leave-thread.react.js b/native/chat/settings/thread-settings-leave-thread.react.js index f95a1baf4..456747a17 100644 --- a/native/chat/settings/thread-settings-leave-thread.react.js +++ b/native/chat/settings/thread-settings-leave-thread.react.js @@ -1,188 +1,185 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { ActivityIndicator, Text, View } from 'react-native'; import type { LeaveThreadInput } from 'lib/actions/thread-actions.js'; import { leaveThreadActionTypes, useLeaveThread, } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { otherUsersButNoOtherAdmins } from 'lib/selectors/thread-selectors.js'; import { identifyInvalidatedThreads } from 'lib/shared/thread-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { - LeaveThreadPayload, - LegacyThreadInfo, -} from 'lib/types/thread-types.js'; +import type { LeaveThreadPayload } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import Button from '../../components/button.react.js'; import { clearThreadsActionType } from '../../navigation/action-types.js'; import { NavContext, type NavContextType, } from '../../navigation/navigation-context.js'; import { useSelector } from '../../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../../themes/colors.js'; import type { ViewStyle } from '../../types/styles.js'; import Alert from '../../utils/alert.js'; const unboundStyles = { button: { flexDirection: 'row', paddingHorizontal: 12, paddingVertical: 10, }, container: { backgroundColor: 'panelForeground', paddingHorizontal: 12, }, text: { color: 'redText', flex: 1, fontSize: 16, }, }; type BaseProps = { - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, +buttonStyle: ViewStyle, }; type Props = { ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +otherUsersButNoOtherAdmins: boolean, +colors: Colors, +styles: $ReadOnly, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +leaveThread: (input: LeaveThreadInput) => Promise, // withNavContext +navContext: ?NavContextType, }; class ThreadSettingsLeaveThread extends React.PureComponent { render(): React.Node { const { panelIosHighlightUnderlay, panelForegroundSecondaryLabel } = this.props.colors; const loadingIndicator = this.props.loadingStatus === 'loading' ? ( ) : null; return ( ); } onPress = () => { if (this.props.otherUsersButNoOtherAdmins) { Alert.alert( 'Need another admin', 'Make somebody else an admin before you leave!', undefined, { cancelable: true }, ); return; } Alert.alert( 'Confirm action', 'Are you sure you want to leave this chat?', [ { text: 'Cancel', style: 'cancel' }, { text: 'OK', onPress: this.onConfirmLeaveThread }, ], { cancelable: true }, ); }; onConfirmLeaveThread = () => { const threadID = this.props.threadInfo.id; void this.props.dispatchActionPromise( leaveThreadActionTypes, this.leaveThread(), { customKeyName: `${leaveThreadActionTypes.started}:${threadID}`, }, ); }; async leaveThread(): Promise { const threadID = this.props.threadInfo.id; const { navContext } = this.props; invariant(navContext, 'navContext should exist in leaveThread'); navContext.dispatch({ type: clearThreadsActionType, payload: { threadIDs: [threadID] }, }); try { const result = await this.props.leaveThread({ threadID }); const invalidated = identifyInvalidatedThreads( result.updatesResult.newUpdates, ); navContext.dispatch({ type: clearThreadsActionType, payload: { threadIDs: [...invalidated] }, }); return result; } catch (e) { Alert.alert('Unknown error', 'Uhh... try again?', undefined, { cancelable: true, }); throw e; } } } const ConnectedThreadSettingsLeaveThread: React.ComponentType = React.memo(function ConnectedThreadSettingsLeaveThread( props: BaseProps, ) { const threadID = props.threadInfo.id; const loadingStatus = useSelector( createLoadingStatusSelector( leaveThreadActionTypes, `${leaveThreadActionTypes.started}:${threadID}`, ), ); const otherUsersButNoOtherAdminsValue = useSelector( otherUsersButNoOtherAdmins(props.threadInfo.id), ); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callLeaveThread = useLeaveThread(); const navContext = React.useContext(NavContext); return ( ); }); export default ConnectedThreadSettingsLeaveThread; diff --git a/native/chat/settings/thread-settings-member-tooltip-modal.react.js b/native/chat/settings/thread-settings-member-tooltip-modal.react.js index 77023a890..3326519d6 100644 --- a/native/chat/settings/thread-settings-member-tooltip-modal.react.js +++ b/native/chat/settings/thread-settings-member-tooltip-modal.react.js @@ -1,115 +1,112 @@ // @flow import * as React from 'react'; import { useRemoveUsersFromThread } from 'lib/actions/thread-actions.js'; import { removeMemberFromThread } from 'lib/shared/thread-utils.js'; import { stringForUser } from 'lib/shared/user-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { - LegacyThreadInfo, - RelativeMemberInfo, -} from 'lib/types/thread-types.js'; +import type { RelativeMemberInfo } from 'lib/types/thread-types.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import ThreadSettingsMemberTooltipButton from './thread-settings-member-tooltip-button.react.js'; import type { AppNavigationProp } from '../../navigation/app-navigator.react'; import { ChangeRolesScreenRouteName } from '../../navigation/route-names.js'; import { type BaseTooltipProps, createTooltip, type TooltipMenuProps, type TooltipParams, type TooltipRoute, } from '../../tooltip/tooltip.react.js'; import Alert from '../../utils/alert.js'; export type ThreadSettingsMemberTooltipModalParams = TooltipParams<{ +memberInfo: RelativeMemberInfo, - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, }>; function useOnRemoveUser( route: TooltipRoute<'ThreadSettingsMemberTooltipModal'>, ) { const { memberInfo, threadInfo } = route.params; const boundRemoveUsersFromThread = useRemoveUsersFromThread(); const dispatchActionPromise = useDispatchActionPromise(); const onConfirmRemoveUser = React.useCallback( () => removeMemberFromThread( threadInfo, memberInfo, dispatchActionPromise, boundRemoveUsersFromThread, ), [threadInfo, memberInfo, dispatchActionPromise, boundRemoveUsersFromThread], ); const userText = stringForUser(memberInfo); return React.useCallback(() => { Alert.alert( 'Confirm removal', `Are you sure you want to remove ${userText} from this chat?`, [ { text: 'Cancel', style: 'cancel' }, { text: 'OK', onPress: onConfirmRemoveUser }, ], { cancelable: true }, ); }, [onConfirmRemoveUser, userText]); } function useOnChangeRole( route: TooltipRoute<'ThreadSettingsMemberTooltipModal'>, navigation: AppNavigationProp<'ThreadSettingsMemberTooltipModal'>, ) { const { threadInfo, memberInfo } = route.params; return React.useCallback(() => { navigation.navigate<'ChangeRolesScreen'>({ name: ChangeRolesScreenRouteName, params: { threadInfo, memberInfo, role: memberInfo.role, }, key: route.key, }); }, [navigation, route.key, threadInfo, memberInfo]); } function TooltipMenu( props: TooltipMenuProps<'ThreadSettingsMemberTooltipModal'>, ): React.Node { const { route, navigation, tooltipItem: TooltipItem } = props; const onChangeRole = useOnChangeRole(route, navigation); const onRemoveUser = useOnRemoveUser(route); return ( <> ); } const ThreadSettingsMemberTooltipModal: React.ComponentType< BaseTooltipProps<'ThreadSettingsMemberTooltipModal'>, > = createTooltip<'ThreadSettingsMemberTooltipModal'>( ThreadSettingsMemberTooltipButton, TooltipMenu, ); export default ThreadSettingsMemberTooltipModal; diff --git a/native/chat/settings/thread-settings-member.react.js b/native/chat/settings/thread-settings-member.react.js index b330e030a..f6acf0b5b 100644 --- a/native/chat/settings/thread-settings-member.react.js +++ b/native/chat/settings/thread-settings-member.react.js @@ -1,303 +1,300 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { ActivityIndicator, Platform, Text, TouchableOpacity, View, } from 'react-native'; import { changeThreadMemberRolesActionTypes, removeUsersFromThreadActionTypes, } from 'lib/actions/thread-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { getAvailableThreadMemberActions } from 'lib/shared/thread-utils.js'; import { stringForUser } from 'lib/shared/user-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { - LegacyThreadInfo, - RelativeMemberInfo, -} from 'lib/types/thread-types.js'; +import type { RelativeMemberInfo } from 'lib/types/thread-types.js'; import { useRolesFromCommunityThreadInfo } from 'lib/utils/role-utils.js'; import type { ThreadSettingsNavigate } from './thread-settings.react.js'; import UserAvatar from '../../avatars/user-avatar.react.js'; import PencilIcon from '../../components/pencil-icon.react.js'; import SingleLine from '../../components/single-line.react.js'; import { KeyboardContext, type KeyboardState, } from '../../keyboard/keyboard-state.js'; import { OverlayContext, type OverlayContextType, } from '../../navigation/overlay-context.js'; import { ThreadSettingsMemberTooltipModalRouteName } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../../themes/colors.js'; import type { VerticalBounds } from '../../types/layout-types.js'; import { useNavigateToUserProfileBottomSheet } from '../../user-profile/user-profile-utils.js'; const unboundStyles = { container: { backgroundColor: 'panelForeground', flex: 1, paddingHorizontal: 24, paddingVertical: 8, }, editButton: { paddingLeft: 10, }, topBorder: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, lastContainer: { paddingBottom: Platform.OS === 'ios' ? 12 : 10, }, role: { color: 'panelForegroundTertiaryLabel', flex: 1, fontSize: 14, paddingTop: 4, }, row: { flex: 1, flexDirection: 'row', }, userInfoContainer: { flex: 1, flexDirection: 'row', alignItems: 'center', }, username: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, lineHeight: 20, marginLeft: 8, }, anonymous: { color: 'panelForegroundTertiaryLabel', fontStyle: 'italic', }, }; type BaseProps = { +memberInfo: RelativeMemberInfo, - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, +canEdit: boolean, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +threadSettingsRouteKey: string, }; type Props = { ...BaseProps, // Redux state +roleName: ?string, +removeUserLoadingStatus: LoadingStatus, +changeRoleLoadingStatus: LoadingStatus, +colors: Colors, +styles: $ReadOnly, // withKeyboardState +keyboardState: ?KeyboardState, // withOverlayContext +overlayContext: ?OverlayContextType, +navigateToUserProfileBottomSheet: (userID: string) => mixed, }; class ThreadSettingsMember extends React.PureComponent { editButton: ?React.ElementRef; render(): React.Node { const userText = stringForUser(this.props.memberInfo); let usernameInfo = null; if (this.props.memberInfo.username) { usernameInfo = ( {userText} ); } else { usernameInfo = ( {userText} ); } let editButton = null; if ( this.props.removeUserLoadingStatus === 'loading' || this.props.changeRoleLoadingStatus === 'loading' ) { editButton = ( ); } else if ( getAvailableThreadMemberActions( this.props.memberInfo, this.props.threadInfo, this.props.canEdit, ).length !== 0 ) { editButton = ( ); } const roleInfo = ( {this.props.roleName} ); const firstItem = this.props.firstListItem ? null : this.props.styles.topBorder; const lastItem = this.props.lastListItem ? this.props.styles.lastContainer : null; return ( {usernameInfo} {editButton} {roleInfo} ); } onPressUser = () => { this.props.navigateToUserProfileBottomSheet(this.props.memberInfo.id); }; editButtonRef = (editButton: ?React.ElementRef) => { this.editButton = editButton; }; onEditButtonLayout = () => {}; onPressEdit = () => { if (this.dismissKeyboardIfShowing()) { return; } const { editButton, props: { verticalBounds }, } = this; if (!editButton || !verticalBounds) { return; } const { overlayContext } = this.props; invariant( overlayContext, 'ThreadSettingsMember should have OverlayContext', ); overlayContext.setScrollBlockingModalStatus('open'); editButton.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; this.props.navigate<'ThreadSettingsMemberTooltipModal'>({ name: ThreadSettingsMemberTooltipModalRouteName, params: { presentedFrom: this.props.threadSettingsRouteKey, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs: getAvailableThreadMemberActions( this.props.memberInfo, this.props.threadInfo, this.props.canEdit, ), memberInfo: this.props.memberInfo, threadInfo: this.props.threadInfo, }, }); }); }; dismissKeyboardIfShowing = (): boolean => { const { keyboardState } = this.props; return !!(keyboardState && keyboardState.dismissKeyboardIfShowing()); }; } const ConnectedThreadSettingsMember: React.ComponentType = React.memo(function ConnectedThreadSettingsMember( props: BaseProps, ) { const memberID = props.memberInfo.id; const removeUserLoadingStatus = useSelector(state => createLoadingStatusSelector( removeUsersFromThreadActionTypes, `${removeUsersFromThreadActionTypes.started}:${memberID}`, )(state), ); const changeRoleLoadingStatus = useSelector(state => createLoadingStatusSelector( changeThreadMemberRolesActionTypes, `${changeThreadMemberRolesActionTypes.started}:${memberID}`, )(state), ); const [memberInfo] = useENSNames([props.memberInfo]); const colors = useColors(); const styles = useStyles(unboundStyles); const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); const navigateToUserProfileBottomSheet = useNavigateToUserProfileBottomSheet(); const roles = useRolesFromCommunityThreadInfo(props.threadInfo, [ props.memberInfo, ]); const roleName = roles.get(props.memberInfo.id)?.name; return ( ); }); export default ConnectedThreadSettingsMember; diff --git a/native/chat/settings/thread-settings-promote-sidebar.react.js b/native/chat/settings/thread-settings-promote-sidebar.react.js index 0a40cabfb..e0cf7c647 100644 --- a/native/chat/settings/thread-settings-promote-sidebar.react.js +++ b/native/chat/settings/thread-settings-promote-sidebar.react.js @@ -1,115 +1,114 @@ // @flow import * as React from 'react'; import { ActivityIndicator, Text, View } from 'react-native'; import { usePromoteSidebar } from 'lib/hooks/promote-sidebar.react.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import Button from '../../components/button.react.js'; import { type Colors, useColors, useStyles } from '../../themes/colors.js'; import type { ViewStyle } from '../../types/styles.js'; import Alert from '../../utils/alert.js'; const unboundStyles = { button: { flexDirection: 'row', paddingHorizontal: 12, paddingVertical: 10, }, container: { backgroundColor: 'panelForeground', paddingHorizontal: 12, }, text: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, }, }; type BaseProps = { - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, +buttonStyle: ViewStyle, }; type Props = { ...BaseProps, +loadingStatus: LoadingStatus, +colors: Colors, +styles: $ReadOnly, +promoteSidebar: () => mixed, }; class ThreadSettingsPromoteSidebar extends React.PureComponent { onClick = () => { Alert.alert( 'Are you sure?', 'Promoting a thread to a channel cannot be undone.', [ { text: 'Cancel', style: 'cancel', }, { text: 'Yes', onPress: this.props.promoteSidebar, }, ], ); }; render(): React.Node { const { panelIosHighlightUnderlay, panelForegroundSecondaryLabel } = this.props.colors; const loadingIndicator = this.props.loadingStatus === 'loading' ? ( ) : null; return ( ); } } const onError = () => { Alert.alert('Unknown error', 'Uhh... try again?', undefined, { cancelable: true, }); }; const ConnectedThreadSettingsPromoteSidebar: React.ComponentType = React.memo(function ConnectedThreadSettingsPromoteSidebar( props: BaseProps, ) { const { threadInfo } = props; const colors = useColors(); const styles = useStyles(unboundStyles); const { onPromoteSidebar, loading } = usePromoteSidebar( threadInfo, onError, ); return ( ); }); export default ConnectedThreadSettingsPromoteSidebar; diff --git a/native/chat/settings/thread-settings-push-notifs.react.js b/native/chat/settings/thread-settings-push-notifs.react.js index f3d2c57bf..3eab0c6cf 100644 --- a/native/chat/settings/thread-settings-push-notifs.react.js +++ b/native/chat/settings/thread-settings-push-notifs.react.js @@ -1,197 +1,196 @@ // @flow import * as React from 'react'; import { Platform, Switch, TouchableOpacity, View } from 'react-native'; import Linking from 'react-native/Libraries/Linking/Linking.js'; import { updateSubscriptionActionTypes, useUpdateSubscription, } from 'lib/actions/user-actions.js'; import { extractKeyserverIDFromID } from 'lib/keyserver-conn/keyserver-call-utils.js'; import { deviceTokenSelector } from 'lib/selectors/keyserver-selectors.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResult, } from 'lib/types/subscription-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import SingleLine from '../../components/single-line.react.js'; import SWMansionIcon from '../../components/swmansion-icon.react.js'; import { CommAndroidNotifications } from '../../push/android.js'; import { useSelector } from '../../redux/redux-utils.js'; import { useStyles } from '../../themes/colors.js'; import Alert from '../../utils/alert.js'; const unboundStyles = { currentValue: { alignItems: 'flex-end', margin: 0, paddingLeft: 4, paddingRight: 0, paddingVertical: 0, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, flex: 1, }, row: { alignItems: 'center', backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 3, }, infoIcon: { paddingRight: 20, }, }; type BaseProps = { - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, // Redux state +styles: $ReadOnly, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +hasPushPermissions: boolean, +updateSubscription: ( subscriptionUpdate: SubscriptionUpdateRequest, ) => Promise, }; type State = { +currentValue: boolean, }; class ThreadSettingsPushNotifs extends React.PureComponent { constructor(props: Props) { super(props); this.state = { currentValue: props.threadInfo.currentUser.subscription.pushNotifs, }; } render(): React.Node { const componentLabel = 'Push notifs'; let notificationsSettingsLinkingIcon: React.Node = undefined; if (!this.props.hasPushPermissions) { notificationsSettingsLinkingIcon = ( ); } return ( {componentLabel} {notificationsSettingsLinkingIcon} ); } onValueChange = (value: boolean) => { this.setState({ currentValue: value }); void this.props.dispatchActionPromise( updateSubscriptionActionTypes, this.props.updateSubscription({ threadID: this.props.threadInfo.id, updatedFields: { pushNotifs: value, }, }), ); }; onNotificationsSettingsLinkingIconPress = async () => { let platformRequestsPermission; if (Platform.OS !== 'android') { platformRequestsPermission = true; } else { platformRequestsPermission = await CommAndroidNotifications.canRequestNotificationsPermissionFromUser(); } const alertTitle = platformRequestsPermission ? 'Need notif permissions' : 'Unable to initialize notifs'; const notificationsSettingsPath = Platform.OS === 'ios' ? 'Settings App → Notifications → Comm' : 'Settings → Apps → Comm → Notifications'; let alertMessage; if (platformRequestsPermission && this.state.currentValue) { alertMessage = 'Notifs for this chat are enabled, but cannot be delivered ' + 'to this device because you haven’t granted notif permissions to Comm. ' + 'Please enable them in ' + notificationsSettingsPath; } else if (platformRequestsPermission) { alertMessage = 'In order to enable push notifs for this chat, ' + 'you need to first grant notif permissions to Comm. ' + 'Please enable them in ' + notificationsSettingsPath; } else { alertMessage = '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.'; } Alert.alert(alertTitle, alertMessage, [ { text: 'Go to settings', onPress: () => Linking.openSettings(), }, { text: 'Cancel', style: 'cancel', }, ]); }; } const ConnectedThreadSettingsPushNotifs: React.ComponentType = React.memo(function ConnectedThreadSettingsPushNotifs( props: BaseProps, ) { const keyserverID = extractKeyserverIDFromID(props.threadInfo.id); const deviceToken = useSelector(deviceTokenSelector(keyserverID)); const hasPushPermissions = deviceToken !== null && deviceToken !== undefined; const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateSubscription = useUpdateSubscription(); return ( ); }); export default ConnectedThreadSettingsPushNotifs; diff --git a/native/chat/settings/thread-settings-visibility.react.js b/native/chat/settings/thread-settings-visibility.react.js index d35b0de9e..9213a9ebd 100644 --- a/native/chat/settings/thread-settings-visibility.react.js +++ b/native/chat/settings/thread-settings-visibility.react.js @@ -1,45 +1,44 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import ThreadVisibility from '../../components/thread-visibility.react.js'; import { useColors, useStyles } from '../../themes/colors.js'; type Props = { - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, }; function ThreadSettingsVisibility(props: Props): React.Node { const styles = useStyles(unboundStyles); const colors = useColors(); return ( Visibility ); } const unboundStyles = { label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, row: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 4, alignItems: 'center', }, }; export default ThreadSettingsVisibility; diff --git a/native/chat/sidebar-list-modal.react.js b/native/chat/sidebar-list-modal.react.js index 1ee5416cd..52be903b8 100644 --- a/native/chat/sidebar-list-modal.react.js +++ b/native/chat/sidebar-list-modal.react.js @@ -1,142 +1,142 @@ // @flow import * as React from 'react'; import { View } from 'react-native'; import { useSearchSidebars } from 'lib/hooks/search-threads.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo, SidebarInfo } from 'lib/types/thread-types.js'; +import type { SidebarInfo } from 'lib/types/thread-types.js'; import { SidebarItem } from './sidebar-item.react.js'; import ThreadListModal from './thread-list-modal.react.js'; import Button from '../components/button.react.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useColors, useStyles } from '../themes/colors.js'; import ExtendedArrow from '../vectors/arrow-extended.react.js'; import Arrow from '../vectors/arrow.react.js'; export type SidebarListModalParams = { - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, }; type Props = { +navigation: RootNavigationProp<'SidebarListModal'>, +route: NavigationRoute<'SidebarListModal'>, }; function SidebarListModal(props: Props): React.Node { const { listData, searchState, setSearchState, onChangeSearchInputText } = useSearchSidebars(props.route.params.threadInfo); const numOfSidebarsWithExtendedArrow = listData.length - 1; const createRenderItem = React.useCallback( - (onPressItem: (threadInfo: LegacyThreadInfo | ThreadInfo) => void) => + (onPressItem: (threadInfo: ThreadInfo) => void) => // eslint-disable-next-line react/display-name (row: { +item: SidebarInfo, +index: number, ... }) => { let extendArrow: boolean = false; if (row.index < numOfSidebarsWithExtendedArrow) { extendArrow = true; } return ( ); }, [numOfSidebarsWithExtendedArrow], ); return ( ); } function Item(props: { item: SidebarInfo, - onPressItem: (threadInfo: LegacyThreadInfo | ThreadInfo) => void, + onPressItem: (threadInfo: ThreadInfo) => void, extendArrow: boolean, }): React.Node { const { item, onPressItem, extendArrow } = props; const { threadInfo } = item; const onPressButton = React.useCallback( () => onPressItem(threadInfo), [onPressItem, threadInfo], ); const colors = useColors(); const styles = useStyles(unboundStyles); let arrow; if (extendArrow) { arrow = ( ); } else { arrow = ( ); } return ( ); } const unboundStyles = { arrow: { position: 'absolute', top: -12, }, extendedArrow: { position: 'absolute', top: -6, }, sidebar: { paddingLeft: 0, paddingRight: 5, height: 38, }, sidebarItemContainer: { flex: 1, }, sidebarRowContainer: { flex: 1, flexDirection: 'row', }, spacer: { width: 30, }, }; export default SidebarListModal; diff --git a/native/chat/subchannels-list-modal.react.js b/native/chat/subchannels-list-modal.react.js index 73ec60546..119d9c67d 100644 --- a/native/chat/subchannels-list-modal.react.js +++ b/native/chat/subchannels-list-modal.react.js @@ -1,97 +1,96 @@ // @flow import * as React from 'react'; import { View } from 'react-native'; import { useSearchSubchannels } from 'lib/hooks/search-threads.js'; import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import SubchannelItem from './subchannel-item.react.js'; import ThreadListModal from './thread-list-modal.react.js'; import Button from '../components/button.react.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useColors, useStyles } from '../themes/colors.js'; export type SubchannelListModalParams = { - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, }; type Props = { +navigation: RootNavigationProp<'SubchannelsListModal'>, +route: NavigationRoute<'SubchannelsListModal'>, }; function SubchannelListModal(props: Props): React.Node { const { listData, searchState, setSearchState, onChangeSearchInputText } = useSearchSubchannels(props.route.params.threadInfo); return ( ); } const createRenderItem = - (onPressItem: (threadInfo: LegacyThreadInfo | ThreadInfo) => void) => + (onPressItem: (threadInfo: ThreadInfo) => void) => // eslint-disable-next-line react/display-name (row: { +item: ChatThreadItem, +index: number, ... }) => { return ; }; function Item(props: { - onPressItem: (threadInfo: LegacyThreadInfo | ThreadInfo) => void, + onPressItem: (threadInfo: ThreadInfo) => void, subchannelInfo: ChatThreadItem, }): React.Node { const { onPressItem, subchannelInfo } = props; const { threadInfo } = subchannelInfo; const onPressButton = React.useCallback( () => onPressItem(threadInfo), [onPressItem, threadInfo], ); const colors = useColors(); const styles = useStyles(unboundStyles); return ( ); } const unboundStyles = { subchannel: { paddingLeft: 0, paddingRight: 5, }, subchannelItemContainer: { flex: 1, }, subchannelRowContainer: { flex: 1, flexDirection: 'row', }, }; export default SubchannelListModal; diff --git a/native/components/community-actions-button.react.js b/native/components/community-actions-button.react.js index a401590a2..a2333ab7f 100644 --- a/native/components/community-actions-button.react.js +++ b/native/components/community-actions-button.react.js @@ -1,157 +1,156 @@ // @flow import { useActionSheet } from '@expo/react-native-action-sheet'; import { useNavigation } from '@react-navigation/native'; import * as React from 'react'; import { TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { primaryInviteLinksSelector } from 'lib/selectors/invite-links-selectors.js'; import { threadHasPermission } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import SWMansionIcon from './swmansion-icon.react.js'; import { CommunityRolesScreenRouteName, InviteLinkNavigatorRouteName, ManagePublicLinkRouteName, RolesNavigatorRouteName, ViewInviteLinksRouteName, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; type Props = { - +community: LegacyThreadInfo | ThreadInfo, + +community: ThreadInfo, }; function CommunityActionsButton(props: Props): React.Node { const { community } = props; const inviteLink = useSelector(primaryInviteLinksSelector)[community.id]; const { navigate } = useNavigation(); const navigateToInviteLinksView = React.useCallback(() => { if (!inviteLink || !community) { return; } navigate<'InviteLinkNavigator'>(InviteLinkNavigatorRouteName, { screen: ViewInviteLinksRouteName, params: { community, }, }); }, [community, inviteLink, navigate]); const navigateToManagePublicLinkView = React.useCallback(() => { navigate<'InviteLinkNavigator'>(InviteLinkNavigatorRouteName, { screen: ManagePublicLinkRouteName, params: { community, }, }); }, [community, navigate]); const navigateToCommunityRolesScreen = React.useCallback(() => { navigate<'RolesNavigator'>(RolesNavigatorRouteName, { screen: CommunityRolesScreenRouteName, params: { threadInfo: community, }, }); }, [community, navigate]); const insets = useSafeAreaInsets(); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const styles = useStyles(unboundStyles); const { showActionSheetWithOptions } = useActionSheet(); const actions = React.useMemo(() => { if (!community) { return null; } const result = []; const canManageLinks = threadHasPermission( community, threadPermissions.MANAGE_INVITE_LINKS, ); if (canManageLinks) { result.push({ label: 'Manage invite links', action: navigateToManagePublicLinkView, }); } if (inviteLink) { result.push({ label: 'Invite link', action: navigateToInviteLinksView, }); } const canChangeRoles = threadHasPermission( community, threadPermissions.CHANGE_ROLE, ); if (canChangeRoles) { result.push({ label: 'Manage roles', action: navigateToCommunityRolesScreen, }); } if (result.length > 0) { return result; } return null; }, [ community, inviteLink, navigateToInviteLinksView, navigateToManagePublicLinkView, navigateToCommunityRolesScreen, ]); const openActionSheet = React.useCallback(() => { if (!actions) { return; } const options = [...actions.map(a => a.label), 'Cancel']; showActionSheetWithOptions( { options, cancelButtonIndex: options.length - 1, containerStyle: { paddingBottom: insets.bottom, }, userInterfaceStyle: activeTheme ?? 'dark', }, selectedIndex => { if (selectedIndex !== undefined && selectedIndex < actions.length) { actions[selectedIndex].action(); } }, ); }, [actions, activeTheme, insets.bottom, showActionSheetWithOptions]); let button = null; if (actions) { button = ( ); } return {button}; } const unboundStyles = { button: { color: 'drawerItemLabelLevel0', }, container: { width: 22, }, }; export default CommunityActionsButton; diff --git a/native/components/thread-ancestors.react.js b/native/components/thread-ancestors.react.js index b4b473800..ff2d33afe 100644 --- a/native/components/thread-ancestors.react.js +++ b/native/components/thread-ancestors.react.js @@ -1,113 +1,112 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome5.js'; import * as React from 'react'; import { View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { ancestorThreadInfos } from 'lib/selectors/thread-selectors.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import Button from './button.react.js'; import CommunityPill from './community-pill.react.js'; import ThreadPill from './thread-pill.react.js'; import { useNavigateToThread } from '../chat/message-list-types.js'; import { useSelector } from '../redux/redux-utils.js'; import { useColors, useStyles } from '../themes/colors.js'; type Props = { - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, }; function ThreadAncestors(props: Props): React.Node { const { threadInfo } = props; const styles = useStyles(unboundStyles); const colors = useColors(); const ancestorThreads: $ReadOnlyArray = useSelector( ancestorThreadInfos(threadInfo.id), ); const rightArrow = React.useMemo( () => ( ), [colors.panelForegroundLabel], ); const navigateToThread = useNavigateToThread(); const pathElements = React.useMemo(() => { const elements = []; for (const [idx, ancestorThreadInfo] of ancestorThreads.entries()) { const isLastThread = idx === ancestorThreads.length - 1; const pill = idx === 0 ? ( ) : ( ); elements.push( {!isLastThread ? rightArrow : null} , ); } return {elements}; }, [ ancestorThreads, navigateToThread, rightArrow, styles.pathItem, styles.row, ]); return ( {pathElements} ); } const height = 48; const unboundStyles = { arrowIcon: { paddingHorizontal: 8, }, container: { height, backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', }, contentContainer: { paddingHorizontal: 12, }, pathItem: { alignItems: 'center', flexDirection: 'row', height, }, row: { flexDirection: 'row', }, }; export default ThreadAncestors; diff --git a/native/invite-links/manage-public-link-screen.react.js b/native/invite-links/manage-public-link-screen.react.js index 288a18979..28ba46072 100644 --- a/native/invite-links/manage-public-link-screen.react.js +++ b/native/invite-links/manage-public-link-screen.react.js @@ -1,224 +1,223 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import { inviteLinkURL } from 'lib/facts/links.js'; import { useInviteLinksActions } from 'lib/hooks/invite-links.js'; import { primaryInviteLinksSelector } from 'lib/selectors/invite-links-selectors.js'; import { defaultErrorMessage, inviteLinkErrorMessages, } from 'lib/shared/invite-links.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import Button from '../components/button.react.js'; import TextInput from '../components/text-input.react.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import Alert from '../utils/alert.js'; export type ManagePublicLinkScreenParams = { - +community: LegacyThreadInfo | ThreadInfo, + +community: ThreadInfo, }; type Props = { +navigation: RootNavigationProp<'ManagePublicLink'>, +route: NavigationRoute<'ManagePublicLink'>, }; function ManagePublicLinkScreen(props: Props): React.Node { const { community } = props.route.params; const inviteLink = useSelector(primaryInviteLinksSelector)[community.id]; const { error, isLoading, name, setName, createOrUpdateInviteLink, disableInviteLink, } = useInviteLinksActions(community.id, inviteLink); const styles = useStyles(unboundStyles); let errorComponent = null; if (error) { errorComponent = ( {inviteLinkErrorMessages[error] ?? defaultErrorMessage} ); } const onDisableButtonClick = React.useCallback(() => { Alert.alert( 'Disable public link', 'Are you sure you want to disable your public link?\n' + '\n' + 'Other communities will be able to claim the same URL.', [ { text: 'Confirm disable', style: 'destructive', onPress: disableInviteLink, }, { text: 'Cancel', }, ], { cancelable: true, }, ); }, [disableInviteLink]); let disablePublicLinkSection = null; if (inviteLink) { disablePublicLinkSection = ( You may also disable the community public link. ); } return ( Invite links make it easy for your friends to join your community. Anybody who knows your community’s invite link will be able to join it. Note that if you change your public link’s URL, other communities will be able to claim the old URL. INVITE URL {inviteLinkURL('')} {errorComponent} {disablePublicLinkSection} ); } const unboundStyles = { sectionTitle: { fontSize: 14, fontWeight: '400', lineHeight: 20, color: 'modalBackgroundLabel', paddingHorizontal: 16, paddingBottom: 4, }, section: { borderBottomColor: 'modalSeparator', borderBottomWidth: 1, borderTopColor: 'modalSeparator', borderTopWidth: 1, backgroundColor: 'modalForeground', padding: 16, marginBottom: 24, }, disableLinkSection: { marginTop: 16, }, sectionText: { fontSize: 14, fontWeight: '400', lineHeight: 22, color: 'modalBackgroundLabel', }, withMargin: { marginBottom: 12, }, inviteLink: { flexDirection: 'row', alignItems: 'center', marginBottom: 8, }, inviteLinkPrefix: { fontSize: 14, fontWeight: '400', lineHeight: 22, color: 'disabledButtonText', marginRight: 2, }, input: { color: 'panelForegroundLabel', borderColor: 'panelSecondaryForegroundBorder', borderWidth: 1, borderRadius: 8, paddingVertical: 13, paddingHorizontal: 16, flex: 1, }, button: { borderRadius: 8, paddingVertical: 12, paddingHorizontal: 24, marginTop: 8, }, buttonPrimary: { backgroundColor: 'purpleButton', }, destructiveButton: { borderWidth: 1, borderRadius: 8, borderColor: 'vibrantRedButton', }, destructiveButtonText: { fontSize: 16, fontWeight: '500', lineHeight: 24, color: 'vibrantRedButton', textAlign: 'center', }, buttonText: { color: 'whiteText', textAlign: 'center', fontWeight: '500', fontSize: 16, lineHeight: 24, }, error: { fontSize: 12, fontWeight: '400', lineHeight: 18, textAlign: 'center', color: 'redText', }, }; export default ManagePublicLinkScreen; diff --git a/native/invite-links/view-invite-links-screen.react.js b/native/invite-links/view-invite-links-screen.react.js index d73fa494b..b5b74e74a 100644 --- a/native/invite-links/view-invite-links-screen.react.js +++ b/native/invite-links/view-invite-links-screen.react.js @@ -1,170 +1,169 @@ // @flow import Clipboard from '@react-native-clipboard/clipboard'; import * as React from 'react'; import { Text, View } from 'react-native'; import { TouchableOpacity } from 'react-native-gesture-handler'; import { inviteLinkURL } from 'lib/facts/links.js'; import { primaryInviteLinksSelector } from 'lib/selectors/invite-links-selectors.js'; import { threadHasPermission } from 'lib/shared/thread-utils.js'; import type { InviteLink } from 'lib/types/link-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import SingleLine from '../components/single-line.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { displayActionResultModal } from '../navigation/action-result-modal.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import { ManagePublicLinkRouteName, type NavigationRoute, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useColors, useStyles } from '../themes/colors.js'; export type ViewInviteLinksScreenParams = { - +community: LegacyThreadInfo | ThreadInfo, + +community: ThreadInfo, }; type Props = { +navigation: RootNavigationProp<'ViewInviteLinks'>, +route: NavigationRoute<'ViewInviteLinks'>, }; const confirmCopy = () => displayActionResultModal('copied!'); function ViewInviteLinksScreen(props: Props): React.Node { const { community } = props.route.params; const inviteLink: ?InviteLink = useSelector(primaryInviteLinksSelector)[ community.id ]; const styles = useStyles(unboundStyles); const { modalForegroundLabel } = useColors(); const linkUrl = inviteLinkURL(inviteLink?.name ?? ''); const onPressCopy = React.useCallback(() => { Clipboard.setString(linkUrl); setTimeout(confirmCopy); }, [linkUrl]); const { navigate } = props.navigation; const onEditButtonClick = React.useCallback(() => { navigate<'ManagePublicLink'>({ name: ManagePublicLinkRouteName, params: { community, }, }); }, [community, navigate]); const canManageLinks = threadHasPermission( community, threadPermissions.MANAGE_INVITE_LINKS, ); let publicLinkSection = null; if (inviteLink || canManageLinks) { let description; if (canManageLinks) { description = ( <> Public links allow unlimited uses and never expire. Edit public link ); } else { description = ( Share this invite link to help your friends join your community! ); } publicLinkSection = ( <> PUBLIC LINK {linkUrl} Copy {description} ); } return {publicLinkSection}; } const unboundStyles = { container: { flex: 1, paddingTop: 24, }, sectionTitle: { fontSize: 12, fontWeight: '400', lineHeight: 18, color: 'modalBackgroundLabel', paddingHorizontal: 16, paddingBottom: 4, }, section: { borderBottomColor: 'modalSeparator', borderBottomWidth: 1, borderTopColor: 'modalSeparator', borderTopWidth: 1, backgroundColor: 'modalForeground', padding: 16, }, link: { paddingHorizontal: 16, paddingVertical: 9, marginBottom: 16, backgroundColor: 'inviteLinkButtonBackground', borderRadius: 20, flexDirection: 'row', justifyContent: 'space-between', }, linkText: { fontSize: 14, fontWeight: '400', lineHeight: 22, color: 'inviteLinkLinkColor', flex: 1, }, button: { flexDirection: 'row', alignItems: 'center', }, copy: { fontSize: 12, fontWeight: '400', lineHeight: 18, color: 'modalForegroundLabel', paddingLeft: 8, }, details: { fontSize: 12, fontWeight: '400', lineHeight: 18, color: 'modalForegroundLabel', }, editLinkButton: { color: 'purpleLink', }, }; export default ViewInviteLinksScreen; diff --git a/native/keyboard/keyboard-state-container.react.js b/native/keyboard/keyboard-state-container.react.js index bbcb7649e..28d960f08 100644 --- a/native/keyboard/keyboard-state-container.react.js +++ b/native/keyboard/keyboard-state-container.react.js @@ -1,150 +1,147 @@ // @flow import * as React from 'react'; import { Platform } from 'react-native'; import { KeyboardUtils } from 'react-native-keyboard-input'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import sleep from 'lib/utils/sleep.js'; import KeyboardInputHost from './keyboard-input-host.react.js'; import { KeyboardContext } from './keyboard-state.js'; import { addKeyboardDismissListener, addKeyboardShowListener, removeKeyboardListener, } from './keyboard.js'; import { tabBarAnimationDuration } from '../navigation/tab-bar.react.js'; import { waitForInteractions } from '../utils/timers.js'; type Props = { +children: React.Node, }; type State = { +systemKeyboardShowing: boolean, +mediaGalleryOpen: boolean, - +mediaGalleryThread: ?LegacyThreadInfo | ?ThreadInfo, + +mediaGalleryThread: ?ThreadInfo, +renderKeyboardInputHost: boolean, }; class KeyboardStateContainer extends React.PureComponent { state: State = { systemKeyboardShowing: false, mediaGalleryOpen: false, mediaGalleryThread: null, renderKeyboardInputHost: false, }; keyboardShowListener: ?Object; keyboardDismissListener: ?Object; keyboardShow: () => void = () => { this.setState({ systemKeyboardShowing: true }); }; keyboardDismiss: () => void = () => { this.setState({ systemKeyboardShowing: false }); }; componentDidMount() { this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); this.keyboardDismissListener = addKeyboardDismissListener( this.keyboardDismiss, ); } componentWillUnmount() { if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardDismissListener) { removeKeyboardListener(this.keyboardDismissListener); this.keyboardDismissListener = null; } } componentDidUpdate(prevProps: Props, prevState: State) { if (Platform.OS !== 'android') { return; } if (this.state.mediaGalleryOpen && !prevState.mediaGalleryOpen) { void (async () => { await sleep(tabBarAnimationDuration); await waitForInteractions(); this.setState({ renderKeyboardInputHost: true }); })(); } } dismissKeyboard: () => void = () => { KeyboardUtils.dismiss(); this.hideMediaGallery(); }; dismissKeyboardIfShowing: () => boolean = () => { if (!this.keyboardShowing) { return false; } this.dismissKeyboard(); return true; }; get keyboardShowing(): boolean { const { systemKeyboardShowing, mediaGalleryOpen } = this.state; return systemKeyboardShowing || mediaGalleryOpen; } - showMediaGallery: (thread: LegacyThreadInfo | ThreadInfo) => void = ( - thread: LegacyThreadInfo | ThreadInfo, - ) => { + showMediaGallery: (thread: ThreadInfo) => void = (thread: ThreadInfo) => { this.setState({ mediaGalleryOpen: true, mediaGalleryThread: thread, }); }; hideMediaGallery: () => void = () => { this.setState({ mediaGalleryOpen: false, mediaGalleryThread: null, renderKeyboardInputHost: false, }); }; - getMediaGalleryThread: () => ?LegacyThreadInfo | ?ThreadInfo = () => + getMediaGalleryThread: () => ?ThreadInfo = () => this.state.mediaGalleryThread; render(): React.Node { const { systemKeyboardShowing, mediaGalleryOpen, renderKeyboardInputHost } = this.state; const { keyboardShowing, dismissKeyboard, dismissKeyboardIfShowing, showMediaGallery, hideMediaGallery, getMediaGalleryThread, } = this; const keyboardState = { keyboardShowing, dismissKeyboard, dismissKeyboardIfShowing, systemKeyboardShowing, mediaGalleryOpen, showMediaGallery, hideMediaGallery, getMediaGalleryThread, }; const keyboardInputHost = renderKeyboardInputHost ? ( ) : null; return ( {this.props.children} {keyboardInputHost} ); } } export default KeyboardStateContainer; diff --git a/native/keyboard/keyboard-state.js b/native/keyboard/keyboard-state.js index 8085b2f27..401788c3d 100644 --- a/native/keyboard/keyboard-state.js +++ b/native/keyboard/keyboard-state.js @@ -1,22 +1,21 @@ // @flow import * as React from 'react'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; export type KeyboardState = { +keyboardShowing: boolean, +dismissKeyboard: () => void, +dismissKeyboardIfShowing: () => boolean, +systemKeyboardShowing: boolean, +mediaGalleryOpen: boolean, - +showMediaGallery: (thread: LegacyThreadInfo | ThreadInfo) => void, + +showMediaGallery: (thread: ThreadInfo) => void, +hideMediaGallery: () => void, - +getMediaGalleryThread: () => ?LegacyThreadInfo | ?ThreadInfo, + +getMediaGalleryThread: () => ?ThreadInfo, }; const KeyboardContext: React.Context = React.createContext(null); export { KeyboardContext }; diff --git a/native/markdown/rules.react.js b/native/markdown/rules.react.js index c5277124a..02a48ac3a 100644 --- a/native/markdown/rules.react.js +++ b/native/markdown/rules.react.js @@ -1,442 +1,439 @@ // @flow import _memoize from 'lodash/memoize.js'; import * as React from 'react'; import { Platform, Text, View } from 'react-native'; import * as SimpleMarkdown from 'simple-markdown'; import * as SharedMarkdown from 'lib/shared/markdown.js'; import { chatMentionRegex } from 'lib/shared/mention-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { - ChatMentionCandidates, - LegacyThreadInfo, -} from 'lib/types/thread-types.js'; +import type { ChatMentionCandidates } from 'lib/types/thread-types.js'; import MarkdownChatMention from './markdown-chat-mention.react.js'; import MarkdownLink from './markdown-link.react.js'; import MarkdownParagraph from './markdown-paragraph.react.js'; import MarkdownSpoiler from './markdown-spoiler.react.js'; import MarkdownUserMention from './markdown-user-mention.react.js'; import { getMarkdownStyles } from './styles.js'; export type MarkdownRules = { +simpleMarkdownRules: SharedMarkdown.ParserRules, +emojiOnlyFactor: ?number, // We need to use a Text container for Entry because it needs to match up // exactly with TextInput. However, if we use a Text container, we can't // support styles for things like blockQuote, which rely on rendering as a // View, and Views can't be nested inside Texts without explicit height and // width +container: 'View' | 'Text', }; // Entry requires a seamless transition between Markdown and TextInput // components, so we can't do anything that would change the position of text const inlineMarkdownRules: boolean => MarkdownRules = _memoize(useDarkStyle => { const styles = getMarkdownStyles(useDarkStyle ? 'dark' : 'light'); const simpleMarkdownRules = { // Matches 'https://google.com' during parse phase and returns a 'link' node url: { ...SimpleMarkdown.defaultRules.url, // simple-markdown is case-sensitive, but we don't want to be match: SimpleMarkdown.inlineRegex(SharedMarkdown.urlRegex), }, // Matches '[Google](https://google.com)' during parse phase and handles // rendering all 'link' nodes, including for 'autolink' and 'url' link: { ...SimpleMarkdown.defaultRules.link, match: () => null, react( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) { return ( {output(node.content, state)} ); }, }, // Each line gets parsed into a 'paragraph' node. The AST returned by the // parser will be an array of one or more 'paragraph' nodes paragraph: { ...SimpleMarkdown.defaultRules.paragraph, // simple-markdown's default RegEx collapses multiple newlines into one. // We want to keep the newlines, but when rendering within a View, we // strip just one trailing newline off, since the View adds vertical // spacing between its children match: (source: string, state: SharedMarkdown.State) => { if (state.inline) { return null; } else if (state.container === 'View') { return SharedMarkdown.paragraphStripTrailingNewlineRegex.exec(source); } else { return SharedMarkdown.paragraphRegex.exec(source); } }, parse( capture: SharedMarkdown.Capture, parse: SharedMarkdown.Parser, state: SharedMarkdown.State, ) { let content = capture[1]; if (state.container === 'View') { // React Native renders empty lines with less height. We want to // preserve the newline characters, so we replace empty lines with a // single space content = content.replace(/^$/m, ' '); } return { content: SimpleMarkdown.parseInline(parse, content, state), }; }, react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, // This is the leaf node in the AST returned by the parse phase text: SimpleMarkdown.defaultRules.text, }; return { simpleMarkdownRules, emojiOnlyFactor: null, container: 'Text', }; }); // We allow the most markdown features for TextMessage, which doesn't have the // same requirements as Entry const fullMarkdownRules: boolean => MarkdownRules = _memoize(useDarkStyle => { const styles = getMarkdownStyles(useDarkStyle ? 'dark' : 'light'); const inlineRules = inlineMarkdownRules(useDarkStyle); const simpleMarkdownRules = { ...inlineRules.simpleMarkdownRules, // Matches '' during parse phase and returns a 'link' // node autolink: SimpleMarkdown.defaultRules.autolink, // Matches '[Google](https://google.com)' during parse phase and handles // rendering all 'link' nodes, including for 'autolink' and 'url' link: { ...inlineRules.simpleMarkdownRules.link, match: SimpleMarkdown.defaultRules.link.match, }, mailto: SimpleMarkdown.defaultRules.mailto, em: { ...SimpleMarkdown.defaultRules.em, react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, strong: { ...SimpleMarkdown.defaultRules.strong, react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, u: { ...SimpleMarkdown.defaultRules.u, react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, del: { ...SimpleMarkdown.defaultRules.del, react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, spoiler: { order: SimpleMarkdown.defaultRules.paragraph.order - 1, match: SimpleMarkdown.inlineRegex(SharedMarkdown.spoilerRegex), parse( capture: SharedMarkdown.Capture, parse: SharedMarkdown.Parser, state: SharedMarkdown.State, ) { const content = capture[1]; return { content: SimpleMarkdown.parseInline(parse, content, state), }; }, react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( ), }, inlineCode: { ...SimpleMarkdown.defaultRules.inlineCode, react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {node.content} ), }, heading: { ...SimpleMarkdown.defaultRules.heading, match: SimpleMarkdown.blockRegex( SharedMarkdown.headingStripFollowingNewlineRegex, ), react( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) { const headingStyle = styles['h' + node.level]; return ( {output(node.content, state)} ); }, }, blockQuote: { ...SimpleMarkdown.defaultRules.blockQuote, // match end of blockQuote by either \n\n or end of string match: SharedMarkdown.matchBlockQuote( SharedMarkdown.blockQuoteStripFollowingNewlineRegex, ), parse: SharedMarkdown.parseBlockQuote, react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => { const { isNestedQuote } = state; const backgroundColor = isNestedQuote ? '#00000000' : '#00000066'; const borderLeftColor = (Platform.select({ ios: '#00000066', default: isNestedQuote ? '#00000066' : '#000000A3', }): string); return ( {output(node.content, { ...state, isNestedQuote: true })} ); }, }, codeBlock: { ...SimpleMarkdown.defaultRules.codeBlock, match: SimpleMarkdown.blockRegex( SharedMarkdown.codeBlockStripTrailingNewlineRegex, ), parse(capture: SharedMarkdown.Capture) { return { content: capture[1].replace(/^ {4}/gm, ''), }; }, react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {node.content} ), }, fence: { ...SimpleMarkdown.defaultRules.fence, match: SimpleMarkdown.blockRegex( SharedMarkdown.fenceStripTrailingNewlineRegex, ), parse: (capture: SharedMarkdown.Capture) => ({ type: 'codeBlock', content: capture[2], }), }, json: { order: SimpleMarkdown.defaultRules.paragraph.order - 1, match: (source: string, state: SharedMarkdown.State) => { if (state.inline) { return null; } return SharedMarkdown.jsonMatch(source); }, parse: (capture: SharedMarkdown.Capture) => { const jsonCapture: SharedMarkdown.JSONCapture = (capture: any); return { type: 'codeBlock', content: SharedMarkdown.jsonPrint(jsonCapture), }; }, }, list: { ...SimpleMarkdown.defaultRules.list, match: SharedMarkdown.matchList, parse: SharedMarkdown.parseList, react( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) { const children = node.items.map((item, i) => { const content = output(item, state); const bulletValue = node.ordered ? node.start + i + '. ' : '\u2022 '; return ( {bulletValue} {content} ); }); return {children}; }, }, escape: SimpleMarkdown.defaultRules.escape, }; return { ...inlineRules, simpleMarkdownRules, emojiOnlyFactor: 2, container: 'View', }; }); function useTextMessageRulesFunc( - threadInfo: LegacyThreadInfo | ThreadInfo, + threadInfo: ThreadInfo, chatMentionCandidates: ChatMentionCandidates, ): (useDarkStyle: boolean) => MarkdownRules { const { members } = threadInfo; const membersMap = SharedMarkdown.useMemberMapForUserMentions(members); return React.useMemo( () => _memoize<[boolean], MarkdownRules>((useDarkStyle: boolean) => textMessageRules(chatMentionCandidates, useDarkStyle, membersMap), ), [chatMentionCandidates, membersMap], ); } function textMessageRules( chatMentionCandidates: ChatMentionCandidates, useDarkStyle: boolean, membersMap: $ReadOnlyMap, ): MarkdownRules { const baseRules = fullMarkdownRules(useDarkStyle); return { ...baseRules, simpleMarkdownRules: { ...baseRules.simpleMarkdownRules, userMention: { ...SimpleMarkdown.defaultRules.strong, match: SharedMarkdown.matchUserMentions(membersMap), parse: (capture: SharedMarkdown.Capture) => SharedMarkdown.parseUserMentions(membersMap, capture), react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {node.content} ), }, chatMention: { ...SimpleMarkdown.defaultRules.strong, match: SimpleMarkdown.inlineRegex(chatMentionRegex), parse: (capture: SharedMarkdown.Capture) => SharedMarkdown.parseChatMention(chatMentionCandidates, capture), react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => node.hasAccessToChat ? ( {node.content} ) : ( {node.content} ), }, }, }; } let defaultTextMessageRules = null; const defaultMembersMap = new Map(); function getDefaultTextMessageRules( overrideDefaultChatMentionCandidates: ChatMentionCandidates = {}, ): MarkdownRules { if (Object.keys(overrideDefaultChatMentionCandidates).length > 0) { return textMessageRules( overrideDefaultChatMentionCandidates, false, defaultMembersMap, ); } if (!defaultTextMessageRules) { defaultTextMessageRules = textMessageRules({}, false, defaultMembersMap); } return defaultTextMessageRules; } export { inlineMarkdownRules, useTextMessageRulesFunc, getDefaultTextMessageRules, }; diff --git a/native/media/media-gallery-keyboard.react.js b/native/media/media-gallery-keyboard.react.js index 4f0ac4fdb..95591eec5 100644 --- a/native/media/media-gallery-keyboard.react.js +++ b/native/media/media-gallery-keyboard.react.js @@ -1,685 +1,684 @@ // @flow import * as ImagePicker from 'expo-image-picker'; import * as MediaLibrary from 'expo-media-library'; import invariant from 'invariant'; import * as React from 'react'; import { ActivityIndicator, Animated, Easing, FlatList, Platform, Text, View, } from 'react-native'; import { KeyboardRegistry } from 'react-native-keyboard-input'; import { Provider } from 'react-redux'; import { extensionFromFilename, filenameFromPathOrURI, } from 'lib/media/file-utils.js'; import { useIsAppForegrounded } from 'lib/shared/lifecycle-utils.js'; import type { MediaLibrarySelection } from 'lib/types/media-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { getCompatibleMediaURI } from './identifier-utils.js'; import MediaGalleryMedia from './media-gallery-media.react.js'; import SendMediaButton from './send-media-button.react.js'; import Button from '../components/button.react.js'; import type { DimensionsInfo } from '../redux/dimensions-updater.react.js'; import { store } from '../redux/redux-setup.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../themes/colors.js'; import type { LayoutEvent, ViewableItemsChange, } from '../types/react-native.js'; import type { ViewStyle } from '../types/styles.js'; const animationSpec = { duration: 400, easing: Easing.inOut(Easing.ease), useNativeDriver: true, }; const unboundStyles = { container: { backgroundColor: 'listBackground', position: 'absolute', left: 0, right: 0, top: 0, }, galleryHeader: { height: 56, borderTopWidth: 1, borderColor: 'modalForegroundBorder', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, }, galleryHeaderTitle: { color: 'modalForegroundLabel', fontSize: 14, fontWeight: '500', }, nativePickerButton: { backgroundColor: 'rgba(255, 255, 255, 0.08)', borderRadius: 8, paddingHorizontal: 12, paddingVertical: 7, }, nativePickerButtonLabel: { color: 'modalButtonLabel', fontSize: 12, fontWeight: '500', }, galleryContainer: { flex: 1, alignItems: 'center', flexDirection: 'row', }, error: { color: 'listBackgroundLabel', flex: 1, fontSize: 28, textAlign: 'center', }, loadingIndicator: { flex: 1, }, sendButtonContainer: { bottom: 20, position: 'absolute', right: 30, }, separator: { width: 2, }, }; type BaseProps = { - +threadInfo: ?LegacyThreadInfo | ?ThreadInfo, + +threadInfo: ?ThreadInfo, }; type Props = { ...BaseProps, // Redux state +dimensions: DimensionsInfo, +foreground: boolean, +colors: Colors, +styles: $ReadOnly, }; type State = { +selections: ?$ReadOnlyArray, +error: ?string, +containerHeight: ?number, // null means end reached; undefined means no fetch yet +cursor: ?string, +queuedMediaURIs: ?Set, +focusedMediaURI: ?string, +dimensions: DimensionsInfo, }; class MediaGalleryKeyboard extends React.PureComponent { mounted = false; fetchingPhotos = false; flatList: ?FlatList; viewableIndices: number[] = []; queueModeProgress: Animated.Value = new Animated.Value(0); sendButtonStyle: ViewStyle; mediaSelected = false; constructor(props: Props) { super(props); const sendButtonScale = this.queueModeProgress.interpolate({ inputRange: [0, 1], outputRange: ([1.3, 1]: number[]), // Flow... }); this.sendButtonStyle = { opacity: this.queueModeProgress, transform: [{ scale: sendButtonScale }], }; this.state = { selections: null, error: null, containerHeight: null, cursor: undefined, queuedMediaURIs: null, focusedMediaURI: null, dimensions: props.dimensions, }; } static getDerivedStateFromProps(props: Props): Partial { // We keep this in state since we pass this.state as // FlatList's extraData prop return { dimensions: props.dimensions }; } componentDidMount() { this.mounted = true; void this.fetchPhotos(); } componentWillUnmount() { this.mounted = false; } componentDidUpdate(prevProps: Props, prevState: State) { const { queuedMediaURIs } = this.state; const prevQueuedMediaURIs = prevState.queuedMediaURIs; if (queuedMediaURIs && !prevQueuedMediaURIs) { Animated.timing(this.queueModeProgress, { ...animationSpec, toValue: 1, }).start(); } else if (!queuedMediaURIs && prevQueuedMediaURIs) { Animated.timing(this.queueModeProgress, { ...animationSpec, toValue: 0, }).start(); } const { flatList, viewableIndices } = this; const { selections, focusedMediaURI } = this.state; let scrollingSomewhere = false; if (flatList && selections) { let newURI; if (focusedMediaURI && focusedMediaURI !== prevState.focusedMediaURI) { newURI = focusedMediaURI; } else if ( queuedMediaURIs && (!prevQueuedMediaURIs || queuedMediaURIs.size > prevQueuedMediaURIs.size) ) { const flowMadeMeDoThis = queuedMediaURIs; for (const queuedMediaURI of flowMadeMeDoThis) { if (prevQueuedMediaURIs && prevQueuedMediaURIs.has(queuedMediaURI)) { continue; } newURI = queuedMediaURI; break; } } let index; if (newURI !== null && newURI !== undefined) { index = selections.findIndex(({ uri }) => uri === newURI); } if (index !== null && index !== undefined) { if (index === viewableIndices[0]) { scrollingSomewhere = true; flatList.scrollToIndex({ index }); } else if (index === viewableIndices[viewableIndices.length - 1]) { scrollingSomewhere = true; flatList.scrollToIndex({ index, viewPosition: 1 }); } } } if (this.props.foreground && !prevProps.foreground) { void this.fetchPhotos(); } if ( !scrollingSomewhere && this.flatList && this.state.selections && prevState.selections && this.state.selections.length > 0 && prevState.selections.length > 0 && this.state.selections[0].uri !== prevState.selections[0].uri ) { this.flatList.scrollToIndex({ index: 0 }); } } guardedSetState(change: Partial) { if (this.mounted) { this.setState(change); } } async fetchPhotos(after?: ?string) { if (this.fetchingPhotos) { return; } this.fetchingPhotos = true; try { const hasPermission = await this.getPermissions(); if (!hasPermission) { return; } const { assets, endCursor, hasNextPage } = await MediaLibrary.getAssetsAsync({ first: 20, after, mediaType: [ MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video, ], sortBy: [MediaLibrary.SortBy.modificationTime], }); let firstRemoved = false, lastRemoved = false; const mediaURIs = this.state.selections ? this.state.selections.map(({ uri }) => uri) : []; const existingURIs = new Set(mediaURIs); let first = true; const selections = assets .map(asset => { const { id, height, width, filename, mediaType, duration } = asset; const isVideo = mediaType === MediaLibrary.MediaType.video; const uri = getCompatibleMediaURI( asset.uri, extensionFromFilename(filename), ); if (existingURIs.has(uri)) { if (first) { firstRemoved = true; } lastRemoved = true; first = false; return null; } first = false; lastRemoved = false; existingURIs.add(uri); if (isVideo) { return { step: 'video_library', dimensions: { height, width }, uri, filename, mediaNativeID: id, duration, selectTime: 0, sendTime: 0, retries: 0, }; } else { return { step: 'photo_library', dimensions: { height, width }, uri, filename, mediaNativeID: id, selectTime: 0, sendTime: 0, retries: 0, }; } }) .filter(Boolean); let appendOrPrepend = after ? 'append' : 'prepend'; if (firstRemoved && !lastRemoved) { appendOrPrepend = 'append'; } else if (!firstRemoved && lastRemoved) { appendOrPrepend = 'prepend'; } let newSelections = selections; if (this.state.selections) { if (appendOrPrepend === 'prepend') { newSelections = [...newSelections, ...this.state.selections]; } else { newSelections = [...this.state.selections, ...newSelections]; } } this.guardedSetState({ selections: newSelections, error: null, cursor: hasNextPage ? endCursor : null, }); } catch (e) { this.guardedSetState({ selections: null, error: 'something went wrong :(', }); } this.fetchingPhotos = false; } openNativePicker = async () => { try { const { assets, canceled } = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.All, allowsEditing: false, allowsMultipleSelection: true, // maximum quality is 1 - it disables compression quality: 1, // we don't want to compress videos at this point videoExportPreset: ImagePicker.VideoExportPreset.Passthrough, }); if (canceled || assets.length === 0) { return; } const selections = assets.map(asset => { const { width, height, fileName, type, duration, assetId: mediaNativeID, } = asset; const isVideo = type === 'video'; const filename = fileName || filenameFromPathOrURI(asset.uri) || ''; const uri = getCompatibleMediaURI( asset.uri, extensionFromFilename(filename), ); if (isVideo) { return { step: 'video_library', dimensions: { height, width }, uri, filename, mediaNativeID, duration, selectTime: 0, sendTime: 0, retries: 0, }; } else { return { step: 'photo_library', dimensions: { height, width }, uri, filename, mediaNativeID, selectTime: 0, sendTime: 0, retries: 0, }; } }); this.sendMedia(selections); } catch (e) { if (__DEV__) { console.warn(e); } this.guardedSetState({ selections: null, error: 'something went wrong :(', }); } }; async getPermissions(): Promise { const { granted } = await MediaLibrary.requestPermissionsAsync(); if (!granted) { this.guardedSetState({ error: "don't have permission :(" }); } return granted; } get queueModeActive(): boolean { return !!this.state.queuedMediaURIs; } renderItem = (row: { +item: MediaLibrarySelection, ... }): React.Node => { const { containerHeight, queuedMediaURIs } = this.state; invariant(containerHeight, 'should be set'); const { uri } = row.item; const isQueued = !!(queuedMediaURIs && queuedMediaURIs.has(uri)); const { queueModeActive } = this; return ( ); }; ItemSeparator = (): React.Node => { return ; }; static keyExtractor = (item: MediaLibrarySelection): string => { return item.uri; }; GalleryHeader = (): React.Node => ( Photos ); render(): React.Node { let content; const { selections, error, containerHeight } = this.state; const bottomOffsetStyle: ViewStyle = { marginBottom: this.props.dimensions.bottomInset, }; if (selections && selections.length > 0 && containerHeight) { content = ( ); } else if (selections && containerHeight) { content = ( no media was found! ); } else if (error) { content = ( {error} ); } else { content = ( ); } const { queuedMediaURIs } = this.state; const queueCount = queuedMediaURIs ? queuedMediaURIs.size : 0; const bottomInset: number = Platform.select({ ios: -1 * this.props.dimensions.bottomInset, default: 0, }); const containerStyle = { bottom: bottomInset }; return ( {content} ); } flatListRef = (flatList: ?FlatList) => { this.flatList = flatList; }; onContainerLayout = (event: LayoutEvent) => { this.guardedSetState({ containerHeight: event.nativeEvent.layout.height }); }; onEndReached = () => { const { cursor } = this.state; if (cursor !== null) { void this.fetchPhotos(cursor); } }; onViewableItemsChanged = (info: ViewableItemsChange) => { const viewableIndices = []; for (const { index } of info.viewableItems) { if (index !== null && index !== undefined) { viewableIndices.push(index); } } this.viewableIndices = viewableIndices; }; setMediaQueued = (selection: MediaLibrarySelection, isQueued: boolean) => { this.setState((prevState: State) => { const prevQueuedMediaURIs = prevState.queuedMediaURIs ? [...prevState.queuedMediaURIs] : []; if (isQueued) { return { queuedMediaURIs: new Set([...prevQueuedMediaURIs, selection.uri]), focusedMediaURI: null, }; } const queuedMediaURIs = prevQueuedMediaURIs.filter( uri => uri !== selection.uri, ); if (queuedMediaURIs.length < prevQueuedMediaURIs.length) { return { queuedMediaURIs: new Set(queuedMediaURIs), focusedMediaURI: null, }; } return null; }); }; setFocus = (selection: MediaLibrarySelection, isFocused: boolean) => { const { uri } = selection; if (isFocused) { this.setState({ focusedMediaURI: uri }); } else if (this.state.focusedMediaURI === uri) { this.setState({ focusedMediaURI: null }); } }; sendSingleMedia = (selection: MediaLibrarySelection) => { this.sendMedia([selection]); }; sendQueuedMedia = () => { const { selections, queuedMediaURIs } = this.state; if (!selections || !queuedMediaURIs) { return; } const queuedSelections = []; for (const uri of queuedMediaURIs) { for (const selection of selections) { if (selection.uri === uri) { queuedSelections.push(selection); break; } } } this.sendMedia(queuedSelections); }; sendMedia(selections: $ReadOnlyArray) { if (this.mediaSelected) { return; } this.mediaSelected = true; const now = Date.now(); const timeProps = { selectTime: now, sendTime: now, }; const selectionsWithTime = selections.map(selection => ({ ...selection, ...timeProps, })); KeyboardRegistry.onItemSelected(mediaGalleryKeyboardName, { selections: selectionsWithTime, threadInfo: this.props.threadInfo, }); } } const mediaGalleryKeyboardName = 'MediaGalleryKeyboard'; function ConnectedMediaGalleryKeyboard(props: BaseProps) { const dimensions = useSelector(state => state.dimensions); const foreground = useIsAppForegrounded(); const colors = useColors(); const styles = useStyles(unboundStyles); return ( ); } function ReduxMediaGalleryKeyboard(props: BaseProps) { return ( ); } KeyboardRegistry.registerKeyboard( mediaGalleryKeyboardName, () => ReduxMediaGalleryKeyboard, ); export { mediaGalleryKeyboardName }; diff --git a/native/navigation/nav-selectors.js b/native/navigation/nav-selectors.js index 81a916a12..12478b3c4 100644 --- a/native/navigation/nav-selectors.js +++ b/native/navigation/nav-selectors.js @@ -1,446 +1,445 @@ // @flow import type { PossiblyStaleNavigationState } from '@react-navigation/core'; import { useRoute } from '@react-navigation/native'; import _memoize from 'lodash/memoize.js'; import * as React from 'react'; import { createSelector } from 'reselect'; import { nonThreadCalendarFiltersSelector } from 'lib/selectors/calendar-filter-selectors.js'; import { currentCalendarQuery } from 'lib/selectors/nav-selectors.js'; import { useCanEditMessage } from 'lib/shared/edit-messages-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { CalendarFilter } from 'lib/types/filter-types.js'; import type { ComposableMessageInfo, RobotextMessageInfo, } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { GlobalTheme } from 'lib/types/theme-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import type { NavContextType } from './navigation-context.js'; import { NavContext } from './navigation-context.js'; import { currentLeafRoute, getStateFromNavigatorRoute, getThreadIDFromRoute, } from './navigation-utils.js'; import { accountModals, ActionResultModalRouteName, AppRouteName, CalendarRouteName, chatRootModals, ChatRouteName, CommunityDrawerNavigatorRouteName, MessageListRouteName, MessageResultsScreenRouteName, MessageSearchRouteName, scrollBlockingModals, TabNavigatorRouteName, ThreadPickerModalRouteName, threadRoutes, } from './route-names.js'; import type { RemoveEditMode } from '../chat/message-list-types.js'; import { useSelector } from '../redux/redux-utils.js'; import type { NavPlusRedux } from '../types/selector-types.js'; const baseCreateIsForegroundSelector: ( routeName: string, ) => (context: ?NavContextType) => boolean = (routeName: string) => createSelector( (context: ?NavContextType) => context && context.state, (navigationState: ?PossiblyStaleNavigationState) => { if (!navigationState) { return false; } return navigationState.routes[navigationState.index].name === routeName; }, ); const createIsForegroundSelector: ( routeName: string, ) => (context: ?NavContextType) => boolean = _memoize( baseCreateIsForegroundSelector, ); function useIsAppLoggedIn(): boolean { const navContext = React.useContext(NavContext); return React.useMemo(() => { if (!navContext) { return false; } const { state } = navContext; return !accountModals.includes(state.routes[state.index].name); }, [navContext]); } const baseCreateActiveTabSelector: ( routeName: string, ) => (context: ?NavContextType) => boolean = (routeName: string) => createSelector( (context: ?NavContextType) => context && context.state, (navigationState: ?PossiblyStaleNavigationState) => { if (!navigationState) { return false; } const currentRootSubroute = navigationState.routes[navigationState.index]; if (currentRootSubroute.name !== AppRouteName) { return false; } const appState = getStateFromNavigatorRoute(currentRootSubroute); const [firstAppSubroute] = appState.routes; if (firstAppSubroute.name !== CommunityDrawerNavigatorRouteName) { return false; } const communityDrawerState = getStateFromNavigatorRoute(firstAppSubroute); const [firstCommunityDrawerSubroute] = communityDrawerState.routes; if (firstCommunityDrawerSubroute.name !== TabNavigatorRouteName) { return false; } const tabState = getStateFromNavigatorRoute(firstCommunityDrawerSubroute); return tabState.routes[tabState.index].name === routeName; }, ); const createActiveTabSelector: ( routeName: string, ) => (context: ?NavContextType) => boolean = _memoize( baseCreateActiveTabSelector, ); const scrollBlockingModalsClosedSelector: ( context: ?NavContextType, ) => boolean = createSelector( (context: ?NavContextType) => context && context.state, (navigationState: ?PossiblyStaleNavigationState) => { if (!navigationState) { return false; } const currentRootSubroute = navigationState.routes[navigationState.index]; if (currentRootSubroute.name !== AppRouteName) { return true; } const appState = getStateFromNavigatorRoute(currentRootSubroute); for (let i = appState.index; i >= 0; i--) { const route = appState.routes[i]; if (scrollBlockingModals.includes(route.name)) { return false; } } return true; }, ); function selectBackgroundIsDark( navigationState: ?PossiblyStaleNavigationState, theme: ?GlobalTheme, ): boolean { if (!navigationState) { return false; } const currentRootSubroute = navigationState.routes[navigationState.index]; if (currentRootSubroute.name !== AppRouteName) { // Very bright... we'll call it non-dark. Doesn't matter right now since // we only use this selector for determining ActionResultModal appearance return false; } const appState = getStateFromNavigatorRoute(currentRootSubroute); let appIndex = appState.index; let currentAppSubroute = appState.routes[appIndex]; while (currentAppSubroute.name === ActionResultModalRouteName) { currentAppSubroute = appState.routes[--appIndex]; } if (scrollBlockingModals.includes(currentAppSubroute.name)) { // All the scroll-blocking chat modals have a dark background return true; } return theme === 'dark'; } function activeThread( navigationState: ?PossiblyStaleNavigationState, validRouteNames: $ReadOnlyArray, ): ?string { if (!navigationState) { return null; } let rootIndex = navigationState.index; let currentRootSubroute = navigationState.routes[rootIndex]; while (currentRootSubroute.name !== AppRouteName) { if (!chatRootModals.includes(currentRootSubroute.name)) { return null; } if (rootIndex === 0) { return null; } currentRootSubroute = navigationState.routes[--rootIndex]; } const appState = getStateFromNavigatorRoute(currentRootSubroute); const [firstAppSubroute] = appState.routes; if (firstAppSubroute.name !== CommunityDrawerNavigatorRouteName) { return null; } const communityDrawerState = getStateFromNavigatorRoute(firstAppSubroute); const [firstCommunityDrawerSubroute] = communityDrawerState.routes; if (firstCommunityDrawerSubroute.name !== TabNavigatorRouteName) { return null; } const tabState = getStateFromNavigatorRoute(firstCommunityDrawerSubroute); const currentTabSubroute = tabState.routes[tabState.index]; if (currentTabSubroute.name !== ChatRouteName) { return null; } const chatState = getStateFromNavigatorRoute(currentTabSubroute); const currentChatSubroute = chatState.routes[chatState.index]; return getThreadIDFromRoute(currentChatSubroute, validRouteNames); } const activeThreadSelector: (context: ?NavContextType) => ?string = createSelector( (context: ?NavContextType) => context && context.state, (navigationState: ?PossiblyStaleNavigationState): ?string => activeThread(navigationState, threadRoutes), ); const messageListRouteNames = [MessageListRouteName]; const activeMessageListSelector: (context: ?NavContextType) => ?string = createSelector( (context: ?NavContextType) => context && context.state, (navigationState: ?PossiblyStaleNavigationState): ?string => activeThread(navigationState, messageListRouteNames), ); function useActiveThread(): ?string { const navContext = React.useContext(NavContext); return React.useMemo(() => { if (!navContext) { return null; } const { state } = navContext; return activeThread(state, threadRoutes); }, [navContext]); } function useActiveMessageList(): ?string { const navContext = React.useContext(NavContext); return React.useMemo(() => { if (!navContext) { return null; } const { state } = navContext; return activeThread(state, messageListRouteNames); }, [navContext]); } const calendarTabActiveSelector = createActiveTabSelector(CalendarRouteName); const threadPickerActiveSelector = createIsForegroundSelector( ThreadPickerModalRouteName, ); const calendarActiveSelector: (context: ?NavContextType) => boolean = createSelector( calendarTabActiveSelector, threadPickerActiveSelector, (calendarTabActive: boolean, threadPickerActive: boolean) => calendarTabActive || threadPickerActive, ); const nativeCalendarQuery: (input: NavPlusRedux) => () => CalendarQuery = createSelector( (input: NavPlusRedux) => currentCalendarQuery(input.redux), (input: NavPlusRedux) => calendarActiveSelector(input.navContext), ( calendarQuery: (calendarActive: boolean) => CalendarQuery, calendarActive: boolean, ) => () => calendarQuery(calendarActive), ); const nonThreadCalendarQuery: (input: NavPlusRedux) => () => CalendarQuery = createSelector( nativeCalendarQuery, (input: NavPlusRedux) => nonThreadCalendarFiltersSelector(input.redux), ( calendarQuery: () => CalendarQuery, filters: $ReadOnlyArray, ) => { return (): CalendarQuery => { const query = calendarQuery(); return { startDate: query.startDate, endDate: query.endDate, filters, }; }; }, ); function useCalendarQuery(): () => CalendarQuery { const navContext = React.useContext(NavContext); return useSelector(state => nonThreadCalendarQuery({ redux: state, navContext, }), ); } const drawerSwipeEnabledSelector: (context: ?NavContextType) => boolean = createSelector( (context: ?NavContextType) => context && context.state, (navigationState: ?PossiblyStaleNavigationState) => { if (!navigationState) { return true; } // First, we recurse into the navigation state until we find the tab route // The tab route should always be accessible by recursing through the // first routes of each subsequent nested navigation state const [firstRootSubroute] = navigationState.routes; if (firstRootSubroute.name !== AppRouteName) { return true; } const appState = getStateFromNavigatorRoute(firstRootSubroute); const [firstAppSubroute] = appState.routes; if (firstAppSubroute.name !== CommunityDrawerNavigatorRouteName) { return true; } const communityDrawerState = getStateFromNavigatorRoute(firstAppSubroute); const [firstCommunityDrawerSubroute] = communityDrawerState.routes; if (firstCommunityDrawerSubroute.name !== TabNavigatorRouteName) { return true; } const tabState = getStateFromNavigatorRoute(firstCommunityDrawerSubroute); // Once we have the tab state, we want to figure out if we currently have // an active StackNavigator const currentTabSubroute = tabState.routes[tabState.index]; if (!currentTabSubroute.state) { return true; } const currentTabSubrouteState = getStateFromNavigatorRoute(currentTabSubroute); if (currentTabSubrouteState.type !== 'stack') { return true; } // Finally, we want to disable the swipe gesture if there is a stack with // more than one subroute, since then the stack will have its own swipe // gesture that will conflict with the drawer's return currentTabSubrouteState.routes.length < 2; }, ); function getTabNavState( navigationState: ?PossiblyStaleNavigationState, ): ?PossiblyStaleNavigationState { if (!navigationState) { return null; } const [firstAppSubroute] = navigationState.routes; if (firstAppSubroute.name !== CommunityDrawerNavigatorRouteName) { return null; } const communityDrawerState = getStateFromNavigatorRoute(firstAppSubroute); const [firstCommunityDrawerSubroute] = communityDrawerState.routes; if (firstCommunityDrawerSubroute.name !== TabNavigatorRouteName) { return null; } const tabState = getStateFromNavigatorRoute(firstCommunityDrawerSubroute); return tabState; } function getChatNavStateFromTabNavState( tabState: ?PossiblyStaleNavigationState, ): ?PossiblyStaleNavigationState { if (!tabState) { return null; } let chatRoute; for (const route of tabState.routes) { if (route.name === ChatRouteName) { chatRoute = route; break; } } if (!chatRoute || !chatRoute.state) { return null; } const chatRouteState = getStateFromNavigatorRoute(chatRoute); if (chatRouteState.type !== 'stack') { return null; } return chatRouteState; } function getRemoveEditMode( chatRouteState: ?PossiblyStaleNavigationState, ): ?RemoveEditMode { if (!chatRouteState) { return null; } const messageListRoute = chatRouteState.routes[chatRouteState.routes.length - 1]; if (messageListRoute.name !== MessageListRouteName) { return null; } if (!messageListRoute || !messageListRoute.params) { return null; } const removeEditMode: Function = messageListRoute.params.removeEditMode; return removeEditMode; } function useCurrentLeafRouteName(): ?string { const navContext = React.useContext(NavContext); return React.useMemo(() => { if (!navContext) { return undefined; } return currentLeafRoute(navContext.state).name; }, [navContext]); } function useCanEditMessageNative( - threadInfo: LegacyThreadInfo | ThreadInfo, + threadInfo: ThreadInfo, targetMessageInfo: ComposableMessageInfo | RobotextMessageInfo, ): boolean { const route = useRoute(); const screenKey = route.key; const threadCreationTime = threadInfo.creationTime; const messageCreationTime = targetMessageInfo.time; const canEditInThisScreen = !screenKey.startsWith(MessageSearchRouteName) && !screenKey.startsWith(MessageResultsScreenRouteName) && messageCreationTime >= threadCreationTime; return ( useCanEditMessage(threadInfo, targetMessageInfo) && canEditInThisScreen ); } export { createIsForegroundSelector, useIsAppLoggedIn, createActiveTabSelector, scrollBlockingModalsClosedSelector, selectBackgroundIsDark, activeThreadSelector, activeMessageListSelector, useActiveThread, useActiveMessageList, calendarActiveSelector, nativeCalendarQuery, nonThreadCalendarQuery, useCalendarQuery, drawerSwipeEnabledSelector, useCurrentLeafRouteName, getRemoveEditMode, getTabNavState, getChatNavStateFromTabNavState, useCanEditMessageNative, }; diff --git a/native/navigation/subchannels-button.react.js b/native/navigation/subchannels-button.react.js index edadbc154..c97ac1e05 100644 --- a/native/navigation/subchannels-button.react.js +++ b/native/navigation/subchannels-button.react.js @@ -1,64 +1,63 @@ // @flow import Icon from '@expo/vector-icons/Feather.js'; import { useNavigation } from '@react-navigation/native'; import * as React from 'react'; import { Text, TouchableOpacity, View } from 'react-native'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { SubchannelsListModalRouteName } from './route-names.js'; import { useStyles } from '../themes/colors.js'; type Props = { - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, }; function SubchnnelsButton(props: Props): React.Node { const styles = useStyles(unboundStyles); const { threadInfo } = props; const { navigate } = useNavigation(); const onPress = React.useCallback( () => navigate<'SubchannelsListModal'>({ name: SubchannelsListModalRouteName, params: { threadInfo }, }), [navigate, threadInfo], ); return ( Subchannels ); } const unboundStyles = { view: { flexDirection: 'row', }, label: { color: 'drawerExpandButton', fontWeight: '500', fontSize: 12, lineHeight: 18, }, iconWrapper: { height: 16, width: 16, alignItems: 'center', }, icon: { color: 'drawerExpandButton', marginRight: 2, }, }; export default SubchnnelsButton; diff --git a/native/roles/change-roles-screen.react.js b/native/roles/change-roles-screen.react.js index b75b86e83..0f14107a7 100644 --- a/native/roles/change-roles-screen.react.js +++ b/native/roles/change-roles-screen.react.js @@ -1,306 +1,303 @@ // @flow import { useActionSheet } from '@expo/react-native-action-sheet'; import invariant from 'invariant'; import * as React from 'react'; import { ActivityIndicator, Platform, Text, View } from 'react-native'; import { TouchableOpacity } from 'react-native-gesture-handler'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { changeThreadMemberRolesActionTypes } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { otherUsersButNoOtherAdmins } from 'lib/selectors/thread-selectors.js'; import { roleIsAdminRole } from 'lib/shared/thread-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { - LegacyThreadInfo, - RelativeMemberInfo, -} from 'lib/types/thread-types.js'; +import type { RelativeMemberInfo } from 'lib/types/thread-types.js'; import { values } from 'lib/utils/objects.js'; import ChangeRolesHeaderRightButton from './change-roles-header-right-button.react.js'; import UserAvatar from '../avatars/user-avatar.react.js'; import type { ChatNavigationProp } from '../chat/chat.react'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; export type ChangeRolesScreenParams = { - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, +memberInfo: RelativeMemberInfo, +role: ?string, }; type Props = { +navigation: ChatNavigationProp<'ChangeRolesScreen'>, +route: NavigationRoute<'ChangeRolesScreen'>, }; const changeRolesLoadingStatusSelector = createLoadingStatusSelector( changeThreadMemberRolesActionTypes, ); function ChangeRolesScreen(props: Props): React.Node { const { navigation, route } = props; const { threadInfo, memberInfo, role } = props.route.params; invariant(role, 'Role must be defined'); const changeRolesLoadingStatus: LoadingStatus = useSelector( changeRolesLoadingStatusSelector, ); const styles = useStyles(unboundStyles); const [selectedRole, setSelectedRole] = React.useState(role); const roleOptions = React.useMemo( () => values(threadInfo.roles).map(threadRole => ({ id: threadRole.id, name: threadRole.name, })), [threadInfo.roles], ); const selectedRoleName = React.useMemo( () => roleOptions.find(roleOption => roleOption.id === selectedRole)?.name, [roleOptions, selectedRole], ); const onRoleChange = React.useCallback( (selectedIndex: ?number) => { if ( selectedIndex === undefined || selectedIndex === null || selectedIndex === roleOptions.length ) { return; } const newRole = roleOptions[selectedIndex].id; setSelectedRole(newRole); navigation.setParams({ threadInfo, memberInfo, role: newRole, }); }, [navigation, setSelectedRole, roleOptions, memberInfo, threadInfo], ); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const { showActionSheetWithOptions } = useActionSheet(); const insets = useSafeAreaInsets(); const showActionSheet = React.useCallback(() => { const options = Platform.OS === 'ios' ? [...roleOptions.map(roleOption => roleOption.name), 'Cancel'] : [...roleOptions.map(roleOption => roleOption.name)]; const cancelButtonIndex = Platform.OS === 'ios' ? options.length - 1 : -1; const containerStyle = { paddingBottom: insets.bottom, }; showActionSheetWithOptions( { options, cancelButtonIndex, containerStyle, userInterfaceStyle: activeTheme ?? 'dark', }, onRoleChange, ); }, [ roleOptions, onRoleChange, insets.bottom, activeTheme, showActionSheetWithOptions, ]); const otherUsersButNoOtherAdminsValue = useSelector( otherUsersButNoOtherAdmins(threadInfo.id), ); const memberIsAdmin = React.useMemo(() => { invariant(memberInfo.role, 'Expected member role to be defined'); return roleIsAdminRole(threadInfo.roles[memberInfo.role]); }, [threadInfo.roles, memberInfo.role]); const shouldRoleChangeBeDisabled = React.useMemo( () => otherUsersButNoOtherAdminsValue && memberIsAdmin, [otherUsersButNoOtherAdminsValue, memberIsAdmin], ); const roleSelector = React.useMemo(() => { if (shouldRoleChangeBeDisabled) { return ( {selectedRoleName} ); } return ( {selectedRoleName} ); }, [showActionSheet, styles, selectedRoleName, shouldRoleChangeBeDisabled]); const disabledRoleChangeMessage = React.useMemo(() => { if (!shouldRoleChangeBeDisabled) { return null; } return ( There must be at least one admin at any given time in a community. ); }, [ shouldRoleChangeBeDisabled, styles.disabledWarningBackground, styles.infoIcon, styles.disabledWarningText, ]); React.useEffect(() => { navigation.setOptions({ headerRight: () => { if (changeRolesLoadingStatus === 'loading') { return ( ); } return ( ); }, }); }, [ changeRolesLoadingStatus, navigation, styles.activityIndicator, route, shouldRoleChangeBeDisabled, ]); return ( Members can only be assigned one role at a time. Changing a member’s role will replace their previously assigned role. {memberInfo.username} {roleSelector} {disabledRoleChangeMessage} ); } const unboundStyles = { descriptionBackground: { backgroundColor: 'panelForeground', marginBottom: 20, }, descriptionText: { color: 'panelBackgroundLabel', padding: 16, fontSize: 14, }, memberInfo: { backgroundColor: 'panelForeground', padding: 16, marginBottom: 30, display: 'flex', flexDirection: 'column', alignItems: 'center', }, memberInfoUsername: { color: 'panelForegroundLabel', marginTop: 8, fontSize: 18, fontWeight: '500', }, roleSelectorLabel: { color: 'panelForegroundSecondaryLabel', marginLeft: 8, fontSize: 12, }, roleSelector: { backgroundColor: 'panelForeground', marginTop: 8, padding: 16, display: 'flex', alignItems: 'center', flexDirection: 'row', justifyContent: 'space-between', }, currentRole: { color: 'panelForegroundSecondaryLabel', fontSize: 16, }, disabledCurrentRole: { color: 'disabledButton', fontSize: 16, }, pencilIcon: { color: 'panelInputSecondaryForeground', }, disabledPencilIcon: { color: 'disabledButton', }, disabledWarningBackground: { backgroundColor: 'disabledButton', padding: 16, display: 'flex', marginTop: 20, flexDirection: 'row', justifyContent: 'center', width: '75%', alignSelf: 'center', }, disabledWarningText: { color: 'panelForegroundSecondaryLabel', fontSize: 14, marginRight: 8, display: 'flex', }, infoIcon: { color: 'panelForegroundSecondaryLabel', marginRight: 8, marginLeft: 8, marginBottom: 12, }, activityIndicator: { paddingRight: 15, }, }; export default ChangeRolesScreen; diff --git a/native/roles/community-roles-screen.react.js b/native/roles/community-roles-screen.react.js index 3ea29889c..bea0b1e3a 100644 --- a/native/roles/community-roles-screen.react.js +++ b/native/roles/community-roles-screen.react.js @@ -1,183 +1,182 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { useRoleMemberCountsForCommunity, useRoleUserSurfacedPermissions, } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import RolePanelEntry from './role-panel-entry.react.js'; import type { RolesNavigationProp } from './roles-navigator.react.js'; import Button from '../components/button.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { CreateRolesScreenRouteName } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; export type CommunityRolesScreenParams = { - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, }; type CommunityRolesScreenProps = { +navigation: RolesNavigationProp<'CommunityRolesScreen'>, +route: NavigationRoute<'CommunityRolesScreen'>, }; function CommunityRolesScreen(props: CommunityRolesScreenProps): React.Node { const { threadInfo } = props.route.params; // This route is either accessed from the CommunityDrawer via the // CommunityActionsButton, or from navigating back after a successful // role creation in CreateRolesScreen. In the second case, we want to // manually pull in the threadInfo from the redux store, since the threadInfo // passed into the route params will not be updated automatically. const threadID = threadInfo.id; - const reduxThreadInfo: ?LegacyThreadInfo | ?ThreadInfo = useSelector( + const reduxThreadInfo: ?ThreadInfo = useSelector( state => threadInfoSelector(state)[threadID], ); const { setParams } = props.navigation; React.useEffect(() => { if (reduxThreadInfo) { setParams({ threadInfo: reduxThreadInfo }); } }, [reduxThreadInfo, setParams]); const styles = useStyles(unboundStyles); const roleNamesToMembers = useRoleMemberCountsForCommunity(threadInfo); const roleNamesToUserSurfacedPermissions = useRoleUserSurfacedPermissions(threadInfo); const rolePanelList = React.useMemo(() => { const rolePanelEntries = []; Object.keys(roleNamesToMembers).forEach(roleName => { rolePanelEntries.push( , ); }); return rolePanelEntries; }, [ roleNamesToMembers, props.navigation, threadInfo, roleNamesToUserSurfacedPermissions, ]); const navigateToCreateRole = React.useCallback( () => props.navigation.navigate(CreateRolesScreenRouteName, { threadInfo, action: 'create_role', roleName: 'New role', rolePermissions: new Set(), }), [threadInfo, props.navigation], ); return ( Roles help you group community members together and assign them certain permissions. The Admins and Members roles are set by default and cannot be edited or deleted. When people join the community, they are automatically assigned the Members role. ROLES MEMBERS {rolePanelList} ); } const unboundStyles = { rolesInfoContainer: { backgroundColor: 'panelForeground', padding: 16, }, rolesInfoTextFirstLine: { color: 'panelBackgroundLabel', fontSize: 14, marginBottom: 14, }, rolesInfoTextSecondLine: { color: 'panelBackgroundLabel', fontSize: 14, }, rolesPanel: { marginTop: 30, }, rolePanelHeadersContainer: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 8, }, rolePanelHeaderLeft: { color: 'panelBackgroundLabel', fontSize: 14, }, rolePanelHeaderRight: { color: 'panelBackgroundLabel', fontSize: 14, marginRight: 72, }, rolePanelList: { backgroundColor: 'panelForeground', marginTop: 8, padding: 4, maxHeight: 325, }, buttonContainer: { backgroundColor: 'panelForeground', padding: 2, }, createRoleButton: { justifyContent: 'center', alignItems: 'center', margin: 10, backgroundColor: 'purpleButton', height: 48, borderRadius: 10, }, createRoleButtonText: { color: 'whiteText', fontSize: 16, fontWeight: '500', }, }; export default CommunityRolesScreen; diff --git a/native/roles/create-roles-screen.react.js b/native/roles/create-roles-screen.react.js index 5064a6e08..c6b551198 100644 --- a/native/roles/create-roles-screen.react.js +++ b/native/roles/create-roles-screen.react.js @@ -1,291 +1,290 @@ // @flow import * as React from 'react'; import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { modifyCommunityRoleActionTypes } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { type UserSurfacedPermission, type UserSurfacedPermissionOption, userSurfacedPermissionOptions, } from 'lib/types/thread-permission-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import CreateRolesHeaderRightButton from './create-roles-header-right-button.react.js'; import type { RolesNavigationProp } from './roles-navigator.react.js'; import EnumSettingsOption from '../components/enum-settings-option.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import TextInput from '../components/text-input.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; export type CreateRolesScreenParams = { - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, +action: 'create_role' | 'edit_role', +existingRoleID?: string, +roleName: string, +rolePermissions: $ReadOnlySet, }; type CreateRolesScreenProps = { +navigation: RolesNavigationProp<'CreateRolesScreen'>, +route: NavigationRoute<'CreateRolesScreen'>, }; const createRolesLoadingStatusSelector = createLoadingStatusSelector( modifyCommunityRoleActionTypes, ); function CreateRolesScreen(props: CreateRolesScreenProps): React.Node { const { threadInfo, action, existingRoleID, roleName: defaultRoleName, rolePermissions: defaultRolePermissions, } = props.route.params; const createRolesLoadingStatus: LoadingStatus = useSelector( createRolesLoadingStatusSelector, ); const [customRoleName, setCustomRoleName] = React.useState(defaultRoleName); const [selectedPermissions, setSelectedPermissions] = React.useState< $ReadOnlySet, >(defaultRolePermissions); const [roleCreationFailed, setRoleCreationFailed] = React.useState(false); const styles = useStyles(unboundStyles); const errorStyles = React.useMemo( () => roleCreationFailed ? [styles.errorContainer, styles.errorContainerVisible] : styles.errorContainer, [roleCreationFailed, styles.errorContainer, styles.errorContainerVisible], ); const onClearPermissions = React.useCallback(() => { setSelectedPermissions(new Set()); }, []); const isSelectedPermissionsEmpty = selectedPermissions.size === 0; const clearPermissionsText = React.useMemo(() => { const textStyle = isSelectedPermissionsEmpty ? styles.clearPermissionsTextDisabled : styles.clearPermissionsText; return ( Clear permissions ); }, [ isSelectedPermissionsEmpty, onClearPermissions, styles.clearPermissionsText, styles.clearPermissionsTextDisabled, ]); const isUserSurfacedPermissionSelected = React.useCallback( (option: UserSurfacedPermissionOption) => selectedPermissions.has(option.userSurfacedPermission), [selectedPermissions], ); const onEnumValuePress = React.useCallback( (option: UserSurfacedPermissionOption) => setSelectedPermissions(currentPermissions => { if (currentPermissions.has(option.userSurfacedPermission)) { const newPermissions = new Set(currentPermissions); newPermissions.delete(option.userSurfacedPermission); return newPermissions; } else { return new Set([ ...currentPermissions, option.userSurfacedPermission, ]); } }), [], ); React.useEffect( () => props.navigation.setParams({ threadInfo, action, existingRoleID, roleName: customRoleName, rolePermissions: selectedPermissions, }), [ props.navigation, threadInfo, action, existingRoleID, customRoleName, selectedPermissions, ], ); const permissionsList = React.useMemo( () => [...userSurfacedPermissionOptions].map(permission => ( onEnumValuePress(permission)} /> )), [isUserSurfacedPermissionSelected, onEnumValuePress], ); const onChangeRoleNameInput = React.useCallback((roleName: string) => { setRoleCreationFailed(false); setCustomRoleName(roleName); }, []); React.useEffect( () => props.navigation.setOptions({ headerRight: () => { if (createRolesLoadingStatus === 'loading') { return ( ); } return ( ); }, }), [ createRolesLoadingStatus, props.navigation, styles.activityIndicator, props.route, ], ); return ( ROLE NAME There is already a role with this name in the community PERMISSIONS {clearPermissionsText} {permissionsList} ); } const unboundStyles = { roleNameContainer: { marginTop: 30, }, roleNameText: { color: 'panelBackgroundLabel', fontSize: 12, marginBottom: 5, marginLeft: 10, }, roleInput: { backgroundColor: 'panelForeground', padding: 12, flexDirection: 'row', justifyContent: 'space-between', }, roleInputComponent: { color: 'panelForegroundLabel', fontSize: 16, }, pencilIcon: { color: 'panelInputSecondaryForeground', }, errorContainer: { marginTop: 10, alignItems: 'center', opacity: 0, }, errorContainerVisible: { opacity: 1, }, errorText: { color: 'redText', fontSize: 14, }, permissionsContainer: { marginTop: 20, paddingBottom: 220, }, permissionsHeader: { flexDirection: 'row', justifyContent: 'space-between', }, permissionsText: { color: 'panelBackgroundLabel', fontSize: 12, marginLeft: 10, }, clearPermissionsText: { color: 'purpleLink', fontSize: 12, marginRight: 15, }, clearPermissionsTextDisabled: { color: 'disabledButton', fontSize: 12, marginRight: 15, }, permissionsListContainer: { backgroundColor: 'panelForeground', marginTop: 10, }, activityIndicator: { paddingRight: 15, }, }; export default CreateRolesScreen; diff --git a/native/roles/role-panel-entry.react.js b/native/roles/role-panel-entry.react.js index c21363b57..5c01f5682 100644 --- a/native/roles/role-panel-entry.react.js +++ b/native/roles/role-panel-entry.react.js @@ -1,196 +1,195 @@ // @flow import { useActionSheet } from '@expo/react-native-action-sheet'; import invariant from 'invariant'; import * as React from 'react'; import { Platform, Text, TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { UserSurfacedPermission } from 'lib/types/thread-permission-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { useDisplayDeleteRoleAlert } from './role-utils.react.js'; import type { RolesNavigationProp } from './roles-navigator.react.js'; import CommIcon from '../components/comm-icon.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { CreateRolesScreenRouteName } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; type RolePanelEntryProps = { +navigation: RolesNavigationProp<'CommunityRolesScreen'>, - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, +roleName: string, +rolePermissions: $ReadOnlySet, +memberCount: number, }; function RolePanelEntry(props: RolePanelEntryProps): React.Node { const { navigation, threadInfo, roleName, rolePermissions, memberCount } = props; const styles = useStyles(unboundStyles); const existingRoleID = React.useMemo( () => Object.keys(threadInfo.roles).find( roleID => threadInfo.roles[roleID].name === roleName, ), [roleName, threadInfo.roles], ); invariant(existingRoleID, 'Role ID must exist for an existing role'); const defaultRoleID = Object.keys(threadInfo.roles).find( roleID => threadInfo.roles[roleID].isDefault, ); invariant(defaultRoleID, 'Default role ID must exist'); const displayDeleteRoleAlert = useDisplayDeleteRoleAlert( threadInfo, existingRoleID, defaultRoleID, memberCount, ); const options = React.useMemo(() => { const availableOptions = ['Edit role']; // Since the `Members` role is able to be renamed, we need to check if the // default role ID is the same as the existing role ID. if (defaultRoleID !== existingRoleID) { availableOptions.push('Delete role'); } if (Platform.OS === 'ios') { availableOptions.push('Cancel'); } return availableOptions; }, [defaultRoleID, existingRoleID]); const onOptionSelected = React.useCallback( (index: ?number) => { if (index === undefined || index === null || index === options.length) { return; } const selectedOption = options[index]; if (selectedOption === 'Edit role') { navigation.navigate(CreateRolesScreenRouteName, { threadInfo, action: 'edit_role', existingRoleID, roleName, rolePermissions, }); } else if (selectedOption === 'Delete role') { displayDeleteRoleAlert(); } }, [ navigation, options, existingRoleID, roleName, rolePermissions, threadInfo, displayDeleteRoleAlert, ], ); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const { showActionSheetWithOptions } = useActionSheet(); const insets = useSafeAreaInsets(); const showActionSheet = React.useCallback(() => { const cancelButtonIndex = Platform.OS === 'ios' ? options.length - 1 : -1; const containerStyle = { paddingBottom: insets.bottom, }; showActionSheetWithOptions( { options, cancelButtonIndex, containerStyle, userInterfaceStyle: activeTheme ?? 'dark', icons: [], }, onOptionSelected, ); }, [ options, onOptionSelected, insets.bottom, activeTheme, showActionSheetWithOptions, ]); const menuButton = React.useMemo(() => { if (roleName === 'Admins') { return ; } return ( ); }, [ roleName, styles.rolePanelEmptyMenuButton, styles.rolePanelMenuButton, showActionSheet, ]); return ( {roleName} {memberCount} {menuButton} ); } const unboundStyles = { rolePanelEntry: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 8, }, rolePanelNameEntry: { flex: 1, color: 'panelForegroundLabel', fontWeight: '600', fontSize: 14, }, rolePanelCountEntryContainer: { marginRight: 40, alignItmes: 'flex-end', }, rolePanelCountEntry: { color: 'panelForegroundLabel', fontWeight: '600', fontSize: 14, marginRight: 22, padding: 8, }, rolePanelEmptyMenuButton: { marginRight: 22, }, rolePanelMenuButton: { color: 'panelForegroundLabel', }, }; export default RolePanelEntry; diff --git a/native/roles/role-utils.react.js b/native/roles/role-utils.react.js index 6709e1f17..ca97337d6 100644 --- a/native/roles/role-utils.react.js +++ b/native/roles/role-utils.react.js @@ -1,63 +1,62 @@ // @flow import * as React from 'react'; import { deleteCommunityRoleActionTypes, useDeleteCommunityRole, } from 'lib/actions/thread-actions.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import { constructRoleDeletionMessagePrompt } from 'lib/utils/role-utils.js'; import Alert from '../utils/alert.js'; function useDisplayDeleteRoleAlert( - threadInfo: LegacyThreadInfo | ThreadInfo, + threadInfo: ThreadInfo, existingRoleID: string, defaultRoleID: string, memberCount: number, ): () => void { const defaultRoleName = threadInfo.roles[defaultRoleID].name; const callDeleteCommunityRole = useDeleteCommunityRole(); const dispatchActionPromise = useDispatchActionPromise(); const onDeleteRole = React.useCallback(() => { void dispatchActionPromise( deleteCommunityRoleActionTypes, callDeleteCommunityRole({ community: threadInfo.id, roleID: existingRoleID, }), ); }, [ callDeleteCommunityRole, dispatchActionPromise, existingRoleID, threadInfo.id, ]); const message = constructRoleDeletionMessagePrompt( defaultRoleName, memberCount, ); return React.useCallback( () => Alert.alert('Delete role', message, [ { text: 'Yes, delete role', style: 'destructive', onPress: onDeleteRole, }, { text: 'Cancel', style: 'cancel', }, ]), [message, onDeleteRole], ); } export { useDisplayDeleteRoleAlert }; diff --git a/native/user-profile/user-profile-menu-button.react.js b/native/user-profile/user-profile-menu-button.react.js index 33323bda1..9653fad66 100644 --- a/native/user-profile/user-profile-menu-button.react.js +++ b/native/user-profile/user-profile-menu-button.react.js @@ -1,148 +1,147 @@ // @flow import { useNavigation, useRoute } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { TouchableOpacity, View } from 'react-native'; import { useRelationshipPrompt } from 'lib/hooks/relationship-prompt.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from 'lib/types/relationship-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import type { UserInfo } from 'lib/types/user-types'; import { userProfileMenuButtonHeight } from './user-profile-constants.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { OverlayContext } from '../navigation/overlay-context.js'; import { UserRelationshipTooltipModalRouteName } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; // We need to set onMenuButtonLayout in order to allow .measure() // to be on the ref const onMenuButtonLayout = () => {}; type Props = { - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, +pendingPersonalThreadUserInfo: ?UserInfo, }; function UserProfileMenuButton(props: Props): React.Node { const { threadInfo, pendingPersonalThreadUserInfo } = props; const { otherUserInfo } = useRelationshipPrompt( threadInfo, undefined, pendingPersonalThreadUserInfo, ); const { navigate } = useNavigation(); const route = useRoute(); const styles = useStyles(unboundStyles); const overlayContext = React.useContext(OverlayContext); const menuButtonRef = React.useRef>(); const visibleTooltipActionEntryIDs = React.useMemo(() => { const result = []; if (otherUserInfo?.relationshipStatus === userRelationshipStatus.FRIEND) { result.push('unfriend'); result.push('block'); } else if ( otherUserInfo?.relationshipStatus === userRelationshipStatus.BOTH_BLOCKED || otherUserInfo?.relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ) { result.push('unblock'); } else { result.push('block'); } return result; }, [otherUserInfo?.relationshipStatus]); const onPressMenuButton = React.useCallback(() => { invariant( overlayContext, 'UserProfileMenuButton should have OverlayContext', ); overlayContext.setScrollBlockingModalStatus('open'); const currentMenuButtonRef = menuButtonRef.current; if (!currentMenuButtonRef || !otherUserInfo) { return; } currentMenuButtonRef.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height, }; const verticalBounds = { height: userProfileMenuButtonHeight, y: pageY, }; const { relationshipStatus, ...restUserInfo } = otherUserInfo; const relativeUserInfo = { ...restUserInfo, isViewer: false, }; navigate<'UserRelationshipTooltipModal'>({ name: UserRelationshipTooltipModalRouteName, params: { presentedFrom: route.key, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs: visibleTooltipActionEntryIDs, relativeUserInfo, tooltipButtonIcon: 'menu', }, }); }); }, [ navigate, otherUserInfo, overlayContext, route.key, visibleTooltipActionEntryIDs, ]); const userProfileMenuButton = React.useMemo( () => ( ), [onPressMenuButton, styles.iconContainer, styles.moreIcon], ); return userProfileMenuButton; } const unboundStyles = { iconContainer: { alignSelf: 'flex-end', }, moreIcon: { color: 'modalButtonLabel', alignSelf: 'flex-end', }, }; export default UserProfileMenuButton; diff --git a/native/user-profile/user-profile-relationship-button.react.js b/native/user-profile/user-profile-relationship-button.react.js index 506daa699..ae5fb5a03 100644 --- a/native/user-profile/user-profile-relationship-button.react.js +++ b/native/user-profile/user-profile-relationship-button.react.js @@ -1,154 +1,153 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import { useRelationshipPrompt } from 'lib/hooks/relationship-prompt.js'; import type { SetState } from 'lib/types/hook-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from 'lib/types/relationship-types.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import type { UserInfo } from 'lib/types/user-types'; import { userProfileActionButtonHeight } from './user-profile-constants.js'; import RelationshipButton from '../components/relationship-button.react.js'; import { useStyles } from '../themes/colors.js'; import Alert from '../utils/alert.js'; const onErrorCallback = () => { Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }]); }; type Props = { - +threadInfo: LegacyThreadInfo | ThreadInfo, + +threadInfo: ThreadInfo, +pendingPersonalThreadUserInfo?: UserInfo, +setUserProfileRelationshipButtonHeight: SetState, }; function UserProfileRelationshipButton(props: Props): React.Node { const { threadInfo, pendingPersonalThreadUserInfo, setUserProfileRelationshipButtonHeight, } = props; const { otherUserInfo, callbacks: { friendUser, unfriendUser }, } = useRelationshipPrompt( threadInfo, onErrorCallback, pendingPersonalThreadUserInfo, ); React.useLayoutEffect(() => { if ( !otherUserInfo || otherUserInfo.relationshipStatus === userRelationshipStatus.FRIEND ) { setUserProfileRelationshipButtonHeight(0); } else if ( otherUserInfo?.relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED ) { const incomingFriendRequestButtonsContainerHeight = 88; setUserProfileRelationshipButtonHeight( incomingFriendRequestButtonsContainerHeight, ); } else { setUserProfileRelationshipButtonHeight(userProfileActionButtonHeight); } }, [ otherUserInfo, otherUserInfo?.relationshipStatus, setUserProfileRelationshipButtonHeight, ]); const styles = useStyles(unboundStyles); const userProfileRelationshipButton = React.useMemo(() => { if ( !otherUserInfo || !otherUserInfo.username || otherUserInfo.relationshipStatus === userRelationshipStatus.FRIEND ) { return null; } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED ) { return ( Incoming friend request ); } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.REQUEST_SENT ) { return ( ); } return ( ); }, [ friendUser, otherUserInfo, styles.acceptFriendRequestButtonContainer, styles.incomingFriendRequestButtonsContainer, styles.incomingFriendRequestContainer, styles.incomingFriendRequestLabel, styles.rejectFriendRequestButtonContainer, styles.singleButtonContainer, unfriendUser, ]); return userProfileRelationshipButton; } const unboundStyles = { singleButtonContainer: { marginTop: 16, }, incomingFriendRequestContainer: { marginTop: 24, }, incomingFriendRequestLabel: { color: 'modalForegroundLabel', }, incomingFriendRequestButtonsContainer: { flexDirection: 'row', marginTop: 8, }, acceptFriendRequestButtonContainer: { flex: 1, marginRight: 4, }, rejectFriendRequestButtonContainer: { flex: 1, marginLeft: 4, }, }; export default UserProfileRelationshipButton;