diff --git a/assets/images/avatars/group/default-avatar_1.svg b/assets/images/avatars/group/default-avatar_1.svg new file mode 100644 index 000000000000..5d97c5bf855b --- /dev/null +++ b/assets/images/avatars/group/default-avatar_1.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/images/avatars/group/default-avatar_10.svg b/assets/images/avatars/group/default-avatar_10.svg new file mode 100644 index 000000000000..12c9dd76ae31 --- /dev/null +++ b/assets/images/avatars/group/default-avatar_10.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/images/avatars/group/default-avatar_11.svg b/assets/images/avatars/group/default-avatar_11.svg new file mode 100644 index 000000000000..97f17f30f3a7 --- /dev/null +++ b/assets/images/avatars/group/default-avatar_11.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/images/avatars/group/default-avatar_12.svg b/assets/images/avatars/group/default-avatar_12.svg new file mode 100644 index 000000000000..f917fb136582 --- /dev/null +++ b/assets/images/avatars/group/default-avatar_12.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/images/avatars/group/default-avatar_13.svg b/assets/images/avatars/group/default-avatar_13.svg new file mode 100644 index 000000000000..9e59fb9123a5 --- /dev/null +++ b/assets/images/avatars/group/default-avatar_13.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/images/avatars/group/default-avatar_14.svg b/assets/images/avatars/group/default-avatar_14.svg new file mode 100644 index 000000000000..ca071e488416 --- /dev/null +++ b/assets/images/avatars/group/default-avatar_14.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/images/avatars/group/default-avatar_15.svg b/assets/images/avatars/group/default-avatar_15.svg new file mode 100644 index 000000000000..f227cc0717be --- /dev/null +++ b/assets/images/avatars/group/default-avatar_15.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/images/avatars/group/default-avatar_16.svg b/assets/images/avatars/group/default-avatar_16.svg new file mode 100644 index 000000000000..efbb85f0b13d --- /dev/null +++ b/assets/images/avatars/group/default-avatar_16.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/images/avatars/group/default-avatar_17.svg b/assets/images/avatars/group/default-avatar_17.svg new file mode 100644 index 000000000000..25c015c595ca --- /dev/null +++ b/assets/images/avatars/group/default-avatar_17.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/images/avatars/group/default-avatar_18.svg b/assets/images/avatars/group/default-avatar_18.svg new file mode 100644 index 000000000000..a58ee6e66eff --- /dev/null +++ b/assets/images/avatars/group/default-avatar_18.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/images/avatars/group/default-avatar_2.svg b/assets/images/avatars/group/default-avatar_2.svg new file mode 100644 index 000000000000..ff1cc3e6dd2d --- /dev/null +++ b/assets/images/avatars/group/default-avatar_2.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/images/avatars/group/default-avatar_3.svg b/assets/images/avatars/group/default-avatar_3.svg new file mode 100644 index 000000000000..dde31b5d02a0 --- /dev/null +++ b/assets/images/avatars/group/default-avatar_3.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/images/avatars/group/default-avatar_4.svg b/assets/images/avatars/group/default-avatar_4.svg new file mode 100644 index 000000000000..f6d02801bc6b --- /dev/null +++ b/assets/images/avatars/group/default-avatar_4.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/images/avatars/group/default-avatar_5.svg b/assets/images/avatars/group/default-avatar_5.svg new file mode 100644 index 000000000000..fdabd36e2058 --- /dev/null +++ b/assets/images/avatars/group/default-avatar_5.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/images/avatars/group/default-avatar_6.svg b/assets/images/avatars/group/default-avatar_6.svg new file mode 100644 index 000000000000..6f1c6b80eda6 --- /dev/null +++ b/assets/images/avatars/group/default-avatar_6.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/images/avatars/group/default-avatar_7.svg b/assets/images/avatars/group/default-avatar_7.svg new file mode 100644 index 000000000000..62d9a8b76bb8 --- /dev/null +++ b/assets/images/avatars/group/default-avatar_7.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/images/avatars/group/default-avatar_8.svg b/assets/images/avatars/group/default-avatar_8.svg new file mode 100644 index 000000000000..206b10c2322b --- /dev/null +++ b/assets/images/avatars/group/default-avatar_8.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/images/avatars/group/default-avatar_9.svg b/assets/images/avatars/group/default-avatar_9.svg new file mode 100644 index 000000000000..ffbe02ce57e8 --- /dev/null +++ b/assets/images/avatars/group/default-avatar_9.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/CONST.ts b/src/CONST.ts index c57ac575f7e6..0fa1c64be44d 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -46,6 +46,7 @@ const KEYBOARD_SHORTCUT_NAVIGATION_TYPE = 'NAVIGATION_SHORTCUT'; const chatTypes = { POLICY_ANNOUNCE: 'policyAnnounce', POLICY_ADMINS: 'policyAdmins', + GROUP: 'group', DOMAIN_ALL: 'domainAll', POLICY_ROOM: 'policyRoom', POLICY_EXPENSE_CHAT: 'policyExpenseChat', @@ -117,6 +118,7 @@ const CONST = { NORMAL: 'normal', }, + DEFAULT_GROUP_AVATAR_COUNT: 18, DEFAULT_AVATAR_COUNT: 24, OLD_DEFAULT_AVATAR_COUNT: 8, @@ -606,6 +608,10 @@ const CONST = { MAX_REPORT_PREVIEW_RECEIPTS: 3, }, REPORT: { + ROLE: { + ADMIN: 'admin', + MEMBER: 'member', + }, MAX_COUNT_BEFORE_FOCUS_UPDATE: 30, MAXIMUM_PARTICIPANTS: 8, SPLIT_REPORTID: '-2', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index b38e191a2a98..066350e5cbb4 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -287,6 +287,9 @@ const ONYXKEYS = { /** Indicates whether we should store logs or not */ SHOULD_STORE_LOGS: 'shouldStoreLogs', + /** Stores new group chat draft */ + NEW_GROUP_CHAT_DRAFT: 'newGroupChatDraft', + // Paths of PDF file that has been cached during one session CACHED_PDF_PATHS: 'cachedPDFPaths', @@ -545,6 +548,7 @@ type OnyxValuesMapping = { [ONYXKEYS.IOU]: OnyxTypes.IOU; [ONYXKEYS.MODAL]: OnyxTypes.Modal; [ONYXKEYS.NETWORK]: OnyxTypes.Network; + [ONYXKEYS.NEW_GROUP_CHAT_DRAFT]: OnyxTypes.NewGroupChatDraft; [ONYXKEYS.CUSTOM_STATUS_DRAFT]: OnyxTypes.CustomStatusDraft; [ONYXKEYS.INPUT_FOCUSED]: boolean; [ONYXKEYS.PERSONAL_DETAILS_LIST]: OnyxTypes.PersonalDetailsList; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 23bb2ee845ad..7b28a2672ba6 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -174,6 +174,7 @@ const ROUTES = { NEW: 'new', NEW_CHAT: 'new/chat', + NEW_CHAT_CONFIRM: 'new/chat/confirm', NEW_ROOM: 'new/room', REPORT: 'r', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index ffb18391c980..f5891b042e49 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -261,6 +261,7 @@ const SCREENS = { NEW_CHAT: { ROOT: 'NewChat_Root', NEW_CHAT: 'chat', + NEW_CHAT_CONFIRM: 'NewChat_Confirm', NEW_ROOM: 'room', }, diff --git a/src/components/Icon/GroupDefaultAvatars.ts b/src/components/Icon/GroupDefaultAvatars.ts new file mode 100644 index 000000000000..7b4afb7c7309 --- /dev/null +++ b/src/components/Icon/GroupDefaultAvatars.ts @@ -0,0 +1,20 @@ +import Avatar1 from '@assets/images/avatars/group/default-avatar_1.svg'; +import Avatar2 from '@assets/images/avatars/group/default-avatar_2.svg'; +import Avatar3 from '@assets/images/avatars/group/default-avatar_3.svg'; +import Avatar4 from '@assets/images/avatars/group/default-avatar_4.svg'; +import Avatar5 from '@assets/images/avatars/group/default-avatar_5.svg'; +import Avatar6 from '@assets/images/avatars/group/default-avatar_6.svg'; +import Avatar7 from '@assets/images/avatars/group/default-avatar_7.svg'; +import Avatar8 from '@assets/images/avatars/group/default-avatar_8.svg'; +import Avatar9 from '@assets/images/avatars/group/default-avatar_9.svg'; +import Avatar10 from '@assets/images/avatars/group/default-avatar_10.svg'; +import Avatar11 from '@assets/images/avatars/group/default-avatar_11.svg'; +import Avatar12 from '@assets/images/avatars/group/default-avatar_12.svg'; +import Avatar13 from '@assets/images/avatars/group/default-avatar_13.svg'; +import Avatar14 from '@assets/images/avatars/group/default-avatar_14.svg'; +import Avatar15 from '@assets/images/avatars/group/default-avatar_15.svg'; +import Avatar16 from '@assets/images/avatars/group/default-avatar_16.svg'; +import Avatar17 from '@assets/images/avatars/group/default-avatar_17.svg'; +import Avatar18 from '@assets/images/avatars/group/default-avatar_18.svg'; + +export {Avatar1, Avatar2, Avatar3, Avatar4, Avatar5, Avatar6, Avatar7, Avatar8, Avatar9, Avatar10, Avatar11, Avatar12, Avatar13, Avatar14, Avatar15, Avatar16, Avatar17, Avatar18}; diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index 5065d1cc7c13..b1abaf3f0b5b 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -19,7 +19,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import DateUtils from '@libs/DateUtils'; import DomUtils from '@libs/DomUtils'; -import {getGroupChatName} from '@libs/GroupChatUtils'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import * as ReportUtils from '@libs/ReportUtils'; @@ -107,8 +106,9 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti const report = ReportUtils.getReport(optionItem.reportID ?? ''); const isStatusVisible = !!emojiCode && ReportUtils.isOneOnOneChat(!isEmptyObject(report) ? report : null); - const isGroupChat = optionItem.type === CONST.REPORT.TYPE.CHAT && !optionItem.chatType && !optionItem.isThread && (optionItem.displayNamesWithTooltips?.length ?? 0) > 2; - const fullTitle = isGroupChat ? getGroupChatName(!isEmptyObject(report) ? report : null) : optionItem.text; + const isGroupChat = ReportUtils.isGroupChat(optionItem) || ReportUtils.isDeprecatedGroupDM(optionItem); + + const fullTitle = isGroupChat ? ReportUtils.getGroupChatName(report?.participantAccountIDs ?? []) : optionItem.text; const subscriptAvatarBorderColor = isFocused ? focusedBackgroundColor : theme.sidebar; return ( diff --git a/src/languages/en.ts b/src/languages/en.ts index 4badcddbc03d..eb26dfaf6b59 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -144,6 +144,7 @@ export default { twoFactorCode: 'Two-factor code', workspaces: 'Workspaces', chats: 'Chats', + group: 'Group', profile: 'Profile', referral: 'Referral', payments: 'Payments', @@ -1201,6 +1202,9 @@ export default { roomDescriptionOptional: 'Room description (optional)', explainerText: 'Set a custom decription for the room.', }, + groupConfirmPage: { + groupName: 'Group name', + }, languagePage: { language: 'Language', languages: { @@ -1339,7 +1343,7 @@ export default { }, newChatPage: { createChat: 'Create chat', - createGroup: 'Create group', + startGroup: 'Start group', addToGroup: 'Add to group', }, yearPickerPage: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 8167633c2d64..a3304eaf34e9 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -134,6 +134,7 @@ export default { twoFactorCode: 'Autenticación de dos factores', workspaces: 'Espacios de trabajo', chats: 'Chats', + group: 'Grupo', profile: 'Perfil', referral: 'Remisión', payments: 'Pagos', @@ -1203,6 +1204,9 @@ export default { roomDescriptionOptional: 'Descripción de la sala de chat (opcional)', explainerText: 'Establece una descripción personalizada para la sala de chat.', }, + groupConfirmPage: { + groupName: 'Nombre del grupo', + }, languagePage: { language: 'Idioma', languages: { @@ -1343,7 +1347,7 @@ export default { }, newChatPage: { createChat: 'Crear chat', - createGroup: 'Crear grupo', + startGroup: 'Grupo de inicio', addToGroup: 'Añadir al grupo', }, yearPickerPage: { diff --git a/src/libs/API/parameters/OpenReportParams.ts b/src/libs/API/parameters/OpenReportParams.ts index 8eaed6bc0fde..8b9d46a035d1 100644 --- a/src/libs/API/parameters/OpenReportParams.ts +++ b/src/libs/API/parameters/OpenReportParams.ts @@ -8,6 +8,10 @@ type OpenReportParams = { createdReportActionID?: string; clientLastReadTime?: string; idempotencyKey?: string; + groupChatAdminLogins?: string; + reportName?: string; + chatType?: string; + optimisticAccountIDList?: string; }; export default OpenReportParams; diff --git a/src/libs/API/parameters/SplitBillParams.ts b/src/libs/API/parameters/SplitBillParams.ts index 0ed7d252a2c6..310923093d5e 100644 --- a/src/libs/API/parameters/SplitBillParams.ts +++ b/src/libs/API/parameters/SplitBillParams.ts @@ -12,7 +12,8 @@ type SplitBillParams = { transactionID: string; reportActionID: string; createdReportActionID?: string; - policyID?: string; + policyID: string | undefined; + chatType: string | undefined; }; export default SplitBillParams; diff --git a/src/libs/GroupChatUtils.ts b/src/libs/GroupChatUtils.ts deleted file mode 100644 index a18de0fdcbbf..000000000000 --- a/src/libs/GroupChatUtils.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type {OnyxEntry} from 'react-native-onyx'; -import type {Report} from '@src/types/onyx'; -import localeCompare from './LocaleCompare'; -import * as ReportUtils from './ReportUtils'; - -/** - * Returns the report name if the report is a group chat - */ -function getGroupChatName(report: OnyxEntry, shouldApplyLimit = false): string | undefined { - let participants = report?.participantAccountIDs ?? []; - if (shouldApplyLimit) { - participants = participants.slice(0, 5); - } - const isMultipleParticipantReport = participants.length > 1; - - return participants - .map((participant) => ReportUtils.getDisplayNameForParticipant(participant, isMultipleParticipantReport)) - .sort((first, second) => localeCompare(first ?? '', second ?? '')) - .filter(Boolean) - .join(', '); -} - -export { - // eslint-disable-next-line import/prefer-default-export - getGroupChatName, -}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index ef1fc3c2dfb0..6583097ac9b8 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -152,6 +152,7 @@ const SearchModalStackNavigator = createModalStackNavigator({ [SCREENS.NEW_CHAT.ROOT]: () => require('../../../../pages/NewChatSelectorPage').default as React.ComponentType, + [SCREENS.NEW_CHAT.NEW_CHAT_CONFIRM]: () => require('../../../../pages/NewChatConfirmPage').default as React.ComponentType, }); const NewTaskModalStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index c9c5d47a2df3..6106f2aad419 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -432,6 +432,10 @@ const config: LinkingOptions['config'] = { }, }, }, + [SCREENS.NEW_CHAT.NEW_CHAT_CONFIRM]: { + path: ROUTES.NEW_CHAT_CONFIRM, + exact: true, + }, }, }, [SCREENS.RIGHT_MODAL.NEW_TASK]: { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index ce4264b32141..dad7c1dd9ce8 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -11,6 +11,7 @@ import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type {FileObject} from '@components/AttachmentModal'; import * as Expensicons from '@components/Icon/Expensicons'; +import * as defaultGroupAvatars from '@components/Icon/GroupDefaultAvatars'; import * as defaultWorkspaceAvatars from '@components/Icon/WorkspaceDefaultAvatars'; import CONST from '@src/CONST'; import type {ParentNavigationSummaryParams, TranslationPaths} from '@src/languages/types'; @@ -45,7 +46,7 @@ import type { ReimbursementDeQueuedMessage, } from '@src/types/onyx/OriginalMessage'; import type {Status} from '@src/types/onyx/PersonalDetails'; -import type {NotificationPreference, PendingChatMember} from '@src/types/onyx/Report'; +import type {NotificationPreference, Participants, PendingChatMember, Participant as ReportParticipant} from '@src/types/onyx/Report'; import type {Message, ReportActionBase, ReportActions} from '@src/types/onyx/ReportAction'; import type {Receipt, TransactionChanges, WaypointCollection} from '@src/types/onyx/Transaction'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; @@ -76,6 +77,8 @@ import * as TransactionUtils from './TransactionUtils'; import * as Url from './Url'; import * as UserUtils from './UserUtils'; +type AvatarRange = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18; + type WelcomeMessage = {showReportName: boolean; phrase1?: string; phrase2?: string}; type ExpenseOriginalMessage = { @@ -247,6 +250,7 @@ type OptimisticChatReport = Pick< | 'pendingFields' | 'parentReportActionID' | 'parentReportID' + | 'participants' | 'participantAccountIDs' | 'visibleChatMemberAccountIDs' | 'policyID' @@ -924,6 +928,10 @@ function isSelfDM(report: OnyxEntry): boolean { return getChatType(report) === CONST.REPORT.CHAT_TYPE.SELF_DM; } +function isGroupChat(report: OnyxEntry | Partial): boolean { + return getChatType(report) === CONST.REPORT.CHAT_TYPE.GROUP; +} + /** * Only returns true if this is our main 1:1 DM report with Concierge */ @@ -1529,6 +1537,17 @@ function getWorkspaceAvatar(report: OnyxEntry): UserUtils.AvatarSource { return !isEmpty(avatar) ? avatar : getDefaultWorkspaceAvatar(workspaceName); } +/** + * Helper method to return the default avatar associated with the given reportID + */ +function getDefaultGroupAvatar(reportID?: string): IconAsset { + if (!reportID) { + return defaultGroupAvatars.Avatar1; + } + const reportIDHashBucket: AvatarRange = ((Number(reportID) % CONST.DEFAULT_GROUP_AVATAR_COUNT) + 1) as AvatarRange; + return defaultGroupAvatars[`Avatar${reportIDHashBucket}`]; +} + /** * Returns the appropriate icons for the given chat report using the stored personalDetails. * The Avatar sources can be URLs or Icon components according to the chat type. @@ -1591,6 +1610,71 @@ function getWorkspaceIcon(report: OnyxEntry, policy: OnyxEntry = return workspaceIcon; } +/** + * Gets the personal details for a login by looking in the ONYXKEYS.PERSONAL_DETAILS_LIST Onyx key (stored in the local variable, allPersonalDetails). If it doesn't exist in Onyx, + * then a default object is constructed. + */ +function getPersonalDetailsForAccountID(accountID: number): Partial { + if (!accountID) { + return {}; + } + return ( + allPersonalDetails?.[accountID] ?? { + avatar: UserUtils.getDefaultAvatar(accountID), + isOptimisticPersonalDetail: true, + } + ); +} + +/** + * Get the displayName for a single report participant. + */ +function getDisplayNameForParticipant(accountID?: number, shouldUseShortForm = false, shouldFallbackToHidden = true, shouldAddCurrentUserPostfix = false): string { + if (!accountID) { + return ''; + } + + const personalDetails = getPersonalDetailsForAccountID(accountID); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const formattedLogin = LocalePhoneNumber.formatPhoneNumber(personalDetails.login || ''); + // This is to check if account is an invite/optimistically created one + // and prevent from falling back to 'Hidden', so a correct value is shown + // when searching for a new user + if (personalDetails.isOptimisticPersonalDetail === true) { + return formattedLogin; + } + + // For selfDM, we display the user's displayName followed by '(you)' as a postfix + const shouldAddPostfix = shouldAddCurrentUserPostfix && accountID === currentUserAccountID; + + const longName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, formattedLogin, shouldFallbackToHidden, shouldAddPostfix); + + // If the user's personal details (first name) should be hidden, make sure we return "hidden" instead of the short name + if (shouldFallbackToHidden && longName === Localize.translateLocal('common.hidden')) { + return longName; + } + + const shortName = personalDetails.firstName ? personalDetails.firstName : longName; + return shouldUseShortForm ? shortName : longName; +} + +/** + * Returns the report name if the report is a group chat + */ +function getGroupChatName(participantAccountIDs: number[], shouldApplyLimit = false): string | undefined { + let participants = participantAccountIDs; + if (shouldApplyLimit) { + participants = participants.slice(0, 5); + } + const isMultipleParticipantReport = participants.length > 1; + + return participants + .map((participant) => getDisplayNameForParticipant(participant, isMultipleParticipantReport)) + .sort((first, second) => localeCompare(first ?? '', second ?? '')) + .filter(Boolean) + .join(', '); +} + /** * Returns the appropriate icons for the given chat report using the stored personalDetails. * The Avatar sources can be URLs or Icon components according to the chat type. @@ -1711,55 +1795,17 @@ function getIcons( return getIconsForParticipants([currentUserAccountID ?? 0], personalDetails); } - return getIconsForParticipants(report?.participantAccountIDs ?? [], personalDetails); -} - -/** - * Gets the personal details for a login by looking in the ONYXKEYS.PERSONAL_DETAILS_LIST Onyx key (stored in the local variable, allPersonalDetails). If it doesn't exist in Onyx, - * then a default object is constructed. - */ -function getPersonalDetailsForAccountID(accountID: number): Partial { - if (!accountID) { - return {}; - } - return ( - allPersonalDetails?.[accountID] ?? { - avatar: UserUtils.getDefaultAvatar(accountID), - isOptimisticPersonalDetail: true, - } - ); -} - -/** - * Get the displayName for a single report participant. - */ -function getDisplayNameForParticipant(accountID?: number, shouldUseShortForm = false, shouldFallbackToHidden = true, shouldAddCurrentUserPostfix = false): string { - if (!accountID) { - return ''; - } - - const personalDetails = getPersonalDetailsForAccountID(accountID); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const formattedLogin = LocalePhoneNumber.formatPhoneNumber(personalDetails.login || ''); - // This is to check if account is an invite/optimistically created one - // and prevent from falling back to 'Hidden', so a correct value is shown - // when searching for a new user - if (personalDetails.isOptimisticPersonalDetail === true) { - return formattedLogin; - } - - // For selfDM, we display the user's displayName followed by '(you)' as a postfix - const shouldAddPostfix = shouldAddCurrentUserPostfix && accountID === currentUserAccountID; - - const longName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, formattedLogin, shouldFallbackToHidden, shouldAddPostfix); - - // If the user's personal details (first name) should be hidden, make sure we return "hidden" instead of the short name - if (shouldFallbackToHidden && longName === Localize.translateLocal('common.hidden')) { - return longName; + if (isGroupChat(report)) { + const groupChatIcon = { + source: getDefaultGroupAvatar(report.reportID), + id: -1, + type: CONST.ICON_TYPE_AVATAR, + name: getGroupChatName(report.participantAccountIDs ?? []), + }; + return [groupChatIcon]; } - const shortName = personalDetails.firstName ? personalDetails.firstName : longName; - return shouldUseShortForm ? shortName : longName; + return getIconsForParticipants(report?.participantAccountIDs ?? [], personalDetails); } function getDisplayNamesWithTooltips( @@ -1829,8 +1875,6 @@ function getDeletedParentActionMessageForChatReport(reportAction: OnyxEntry, report: OnyxEntry, shouldUseShortDisplayName = true): string { const submitterDisplayName = getDisplayNameForParticipant(report?.ownerAccountID, shouldUseShortDisplayName) ?? ''; @@ -3649,6 +3693,15 @@ function buildOptimisticChatReport( parentReportID = '', description = '', ): OptimisticChatReport { + const participants = participantList.reduce((reportParticipants: Participants, accountID: number) => { + const participant: ReportParticipant = { + hidden: false, + role: accountID === currentUserAccountID ? CONST.REPORT.ROLE.ADMIN : CONST.REPORT.ROLE.MEMBER, + }; + // eslint-disable-next-line no-param-reassign + reportParticipants[accountID] = participant; + return reportParticipants; + }, {} as Participants); const currentTime = DateUtils.getDBTime(); const isNewlyCreatedWorkspaceChat = chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT && isOwnPolicyExpenseChat; return { @@ -3671,6 +3724,8 @@ function buildOptimisticChatReport( // When creating a report the participantsAccountIDs and visibleChatMemberAccountIDs are the same participantAccountIDs: participantList, visibleChatMemberAccountIDs: participantList, + // For group chats we need to have participants object as we are migrating away from `participantAccountIDs` and `visibleChatMemberAccountIDs`. See https://github.com/Expensify/App/issues/34692 + participants, policyID, reportID: generateReportID(), reportName, @@ -4326,7 +4381,8 @@ function shouldReportBeInOptionList({ !isArchivedRoom(report) && !isMoneyRequestReport(report) && !isTaskReport(report) && - !isSelfDM(report)) + !isSelfDM(report) && + !isGroupChat(report)) ) { return false; } @@ -4410,7 +4466,8 @@ function getChatByParticipants(newParticipantList: number[], reports: OnyxCollec isTaskReport(report) || isMoneyRequestReport(report) || isChatRoom(report) || - isPolicyExpenseChat(report) + isPolicyExpenseChat(report) || + isGroupChat(report) ) { return false; } @@ -4625,7 +4682,7 @@ function hasIOUWaitingOnCurrentUserBankAccount(chatReport: OnyxEntry): b */ function canRequestMoney(report: OnyxEntry, policy: OnyxEntry, otherParticipants: number[]): boolean { // User cannot request money in chat thread or in task report or in chat room - if (isChatThread(report) || isTaskReport(report) || isChatRoom(report) || isSelfDM(report)) { + if (isChatThread(report) || isTaskReport(report) || isChatRoom(report) || isSelfDM(report) || isGroupChat(report)) { return false; } @@ -4707,7 +4764,12 @@ function getMoneyRequestOptions(report: OnyxEntry, policy: OnyxEntry 0) || (isDM(report) && hasMultipleOtherParticipants) || (isPolicyExpenseChat(report) && report?.isOwnPolicyExpenseChat)) { + if ( + (isChatRoom(report) && otherParticipants.length > 0) || + (isDM(report) && hasMultipleOtherParticipants) || + (isGroupChat(report) && otherParticipants.length > 0) || + (isPolicyExpenseChat(report) && report?.isOwnPolicyExpenseChat) + ) { options = [CONST.IOU.TYPE.SPLIT]; } @@ -5164,7 +5226,7 @@ function getIOUReportActionDisplayMessage(reportAction: OnyxEntry) * - More than 2 participants. * */ -function isGroupChat(report: OnyxEntry): boolean { +function isDeprecatedGroupDM(report: OnyxEntry): boolean { return Boolean( report && !isChatThread(report) && @@ -5695,7 +5757,7 @@ export { hasMissingSmartscanFields, getIOUReportActionDisplayMessage, isWaitingForAssigneeToCompleteTask, - isGroupChat, + isDeprecatedGroupDM, isOpenExpenseReport, shouldUseFullTitleToDisplay, parseReportRouteParams, @@ -5737,6 +5799,7 @@ export { canEditRoomVisibility, canEditPolicyDescription, getPolicyDescriptionText, + getDefaultGroupAvatar, isAllowedToSubmitDraftExpenseReport, isAllowedToApproveExpenseReport, findSelfDMReportID, @@ -5744,8 +5807,10 @@ export { isJoinRequestInAdminRoom, canAddOrDeleteTransactions, shouldCreateNewMoneyRequestReport, + isGroupChat, isTrackExpenseReport, hasActionsWithErrors, + getGroupChatName, }; export type { diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 63b907a42e25..4803067267d0 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -388,7 +388,10 @@ function getOptionData({ .join(' '); } - result.alternateText = ReportUtils.isGroupChat(report) && lastActorDisplayName ? `${lastActorDisplayName}: ${lastMessageText}` : lastMessageText || formattedLogin; + result.alternateText = + (ReportUtils.isGroupChat(report) || ReportUtils.isDeprecatedGroupDM(report)) && lastActorDisplayName + ? `${lastActorDisplayName}: ${lastMessageText}` + : lastMessageText || formattedLogin; } result.isIOUReportOwner = ReportUtils.isIOUOwnedByCurrentUser(result as Report); diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index b67e44fcf5ad..24a2605cca93 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -103,6 +103,7 @@ type SplitData = { reportActionID: string; policyID?: string; createdReportActionID?: string; + chatType?: string; }; type SplitsAndOnyxData = { @@ -2278,11 +2279,32 @@ function createSplitsAndOnyxData( ): SplitsAndOnyxData { const currentUserEmailForIOUSplit = PhoneNumber.addSMSDomainIfPhoneNumber(currentUserLogin); const participantAccountIDs = participants.map((participant) => Number(participant.accountID)); - const existingSplitChatReport = - existingSplitChatReportID || participants[0].reportID - ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${existingSplitChatReportID || participants[0].reportID}`] - : ReportUtils.getChatByParticipants(participantAccountIDs); - const splitChatReport = existingSplitChatReport ?? ReportUtils.buildOptimisticChatReport(participantAccountIDs); + + const existingChatReportID = existingSplitChatReportID || participants[0].reportID; + let existingSplitChatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${existingChatReportID}`]; + if (!existingSplitChatReport) { + existingSplitChatReport = participants.length < 2 ? ReportUtils.getChatByParticipants(participantAccountIDs) : null; + } + let newChat: ReportUtils.OptimisticChatReport | EmptyObject = {}; + const allParticipantsAccountIDs = [...participantAccountIDs, currentUserAccountID]; + if (!existingSplitChatReport && participants.length > 1) { + newChat = ReportUtils.buildOptimisticChatReport( + allParticipantsAccountIDs, + '', + CONST.REPORT.CHAT_TYPE.GROUP, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + ); + } + if (isEmptyObject(newChat)) { + newChat = ReportUtils.buildOptimisticChatReport(allParticipantsAccountIDs); + } + const splitChatReport = existingSplitChatReport ?? newChat; const isOwnPolicyExpenseChat = !!splitChatReport.isOwnPolicyExpenseChat; const splitTransaction = TransactionUtils.buildOptimisticTransaction( @@ -2611,6 +2633,7 @@ function createSplitsAndOnyxData( transactionID: splitTransaction.transactionID, reportActionID: splitIOUReportAction.reportActionID, policyID: splitChatReport.policyID, + chatType: splitChatReport.chatType, }; if (!existingSplitChatReport) { @@ -2675,6 +2698,7 @@ function splitBill( reportActionID: splitData.reportActionID, createdReportActionID: splitData.createdReportActionID, policyID: splitData.policyID, + chatType: splitData.chatType, }; API.write(WRITE_COMMANDS.SPLIT_BILL, parameters, onyxData); @@ -2733,6 +2757,7 @@ function splitBillAndOpenReport( reportActionID: splitData.reportActionID, createdReportActionID: splitData.createdReportActionID, policyID: splitData.policyID, + chatType: splitData.chatType, }; API.write(WRITE_COMMANDS.SPLIT_BILL_AND_OPEN_REPORT, parameters, onyxData); diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 225022665ddc..0c49845490ab 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -70,7 +70,16 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/NewRoomForm'; -import type {PersonalDetails, PersonalDetailsList, PolicyReportField, RecentlyUsedReportFields, ReportActionReactions, ReportMetadata, ReportUserIsTyping} from '@src/types/onyx'; +import type { + NewGroupChatDraft, + PersonalDetails, + PersonalDetailsList, + PolicyReportField, + RecentlyUsedReportFields, + ReportActionReactions, + ReportMetadata, + ReportUserIsTyping, +} from '@src/types/onyx'; import type {Decision, OriginalMessageIOU} from '@src/types/onyx/OriginalMessage'; import type {NotificationPreference, RoomVisibility, WriteCapability} from '@src/types/onyx/Report'; import type Report from '@src/types/onyx/Report'; @@ -92,6 +101,7 @@ type ActionSubscriber = { let conciergeChatReportID: string | undefined; let currentUserAccountID = -1; +let currentUserEmail: string | undefined; Onyx.connect({ key: ONYXKEYS.SESSION, callback: (value) => { @@ -100,7 +110,7 @@ Onyx.connect({ conciergeChatReportID = undefined; return; } - + currentUserEmail = value.email; currentUserAccountID = value.accountID; }, }); @@ -202,6 +212,21 @@ Onyx.connect({ callback: (val) => (allRecentlyUsedReportFields = val), }); +let newGroupDraft: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.NEW_GROUP_CHAT_DRAFT, + callback: (value) => (newGroupDraft = value), +}); + +function clearGroupChat() { + Onyx.set(ONYXKEYS.NEW_GROUP_CHAT_DRAFT, null); +} + +function startNewChat() { + clearGroupChat(); + Navigation.navigate(ROUTES.NEW); +} + /** Get the private pusher channel name for a Report. */ function getReportChannelName(reportID: string): string { return `${CONST.PUSHER.PRIVATE_REPORT_CHANNEL_PREFIX}${reportID}${CONFIG.PUSHER.SUFFIX}`; @@ -620,6 +645,13 @@ function openReport( idempotencyKey: `${SIDE_EFFECT_REQUEST_COMMANDS.OPEN_REPORT}_${reportID}`, }; + if (ReportUtils.isGroupChat(newReportObject)) { + parameters.chatType = CONST.REPORT.CHAT_TYPE.GROUP; + parameters.groupChatAdminLogins = currentUserEmail; + parameters.optimisticAccountIDList = participantAccountIDList.join(','); + parameters.reportName = newReportObject.reportName ?? ''; + } + if (isFromDeepLink) { parameters.shouldRetry = false; } @@ -752,16 +784,35 @@ function openReport( * @param userLogins list of user logins to start a chat report with. * @param shouldDismissModal a flag to determine if we should dismiss modal before navigate to report or navigate to report directly. */ -function navigateToAndOpenReport(userLogins: string[], shouldDismissModal = true) { +function navigateToAndOpenReport(userLogins: string[], shouldDismissModal = true, reportName?: string) { let newChat: ReportUtils.OptimisticChatReport | EmptyObject = {}; - + let chat: OnyxEntry | EmptyObject = {}; const participantAccountIDs = PersonalDetailsUtils.getAccountIDsByLogins(userLogins); - const chat = ReportUtils.getChatByParticipants(participantAccountIDs); - if (!chat) { - newChat = ReportUtils.buildOptimisticChatReport(participantAccountIDs); + // If we are not creating a new Group Chat then we are creating a 1:1 DM and will look for an existing chat + if (!newGroupDraft) { + chat = ReportUtils.getChatByParticipants(participantAccountIDs); + } + + if (isEmptyObject(chat)) { + if (newGroupDraft) { + newChat = ReportUtils.buildOptimisticChatReport( + participantAccountIDs, + reportName, + CONST.REPORT.CHAT_TYPE.GROUP, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + ); + } else { + newChat = ReportUtils.buildOptimisticChatReport(participantAccountIDs); + } } - const report = chat ?? newChat; + const report = isEmptyObject(chat) ? newChat : chat; // We want to pass newChat here because if anything is passed in that param (even an existing chat), we will try to create a chat on the server openReport(report.reportID, '', userLogins, newChat); @@ -2942,6 +2993,10 @@ function resolveActionableMentionWhisper(reportId: string, reportAction: OnyxEnt API.write(WRITE_COMMANDS.RESOLVE_ACTIONABLE_MENTION_WHISPER, parameters, {optimisticData, failureData}); } +function setGroupDraft(participants: Array<{login: string; accountID: number}>, reportName = '') { + Onyx.merge(ONYXKEYS.NEW_GROUP_CHAT_DRAFT, {participants, reportName}); +} + export { getReportDraftStatus, searchInServer, @@ -3013,4 +3068,7 @@ export { updateReportName, resolveActionableMentionWhisper, updateRoomVisibility, + setGroupDraft, + clearGroupChat, + startNewChat, }; diff --git a/src/pages/NewChatConfirmPage.tsx b/src/pages/NewChatConfirmPage.tsx new file mode 100644 index 000000000000..8570c061ebce --- /dev/null +++ b/src/pages/NewChatConfirmPage.tsx @@ -0,0 +1,152 @@ +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import Avatar from '@components/Avatar'; +import Badge from '@components/Badge'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import TableListItem from '@components/SelectionList/TableListItem'; +import type {ListItem} from '@components/SelectionList/types'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import * as Report from '@userActions/Report'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {Participant} from '@src/types/onyx/IOU'; + +type NewChatConfirmPageOnyxProps = { + /** New group chat draft data */ + newGroupDraft: OnyxEntry; + + /** All of the personal details for everyone */ + allPersonalDetails: OnyxEntry; +}; + +type NewChatConfirmPageProps = NewChatConfirmPageOnyxProps; + +function NewChatConfirmPage({newGroupDraft, allPersonalDetails}: NewChatConfirmPageProps) { + const {translate} = useLocalize(); + const StyleUtils = useStyleUtils(); + const styles = useThemeStyles(); + const personalData = useCurrentUserPersonalDetails(); + const participantAccountIDs = newGroupDraft?.participants.map((participant) => participant.accountID); + const selectedOptions = useMemo((): Participant[] => { + if (!newGroupDraft?.participants) { + return []; + } + const options: Participant[] = newGroupDraft.participants.map((participant) => + OptionsListUtils.getParticipantsOption({accountID: participant.accountID, login: participant.login, reportID: ''}, allPersonalDetails), + ); + return options; + }, [allPersonalDetails, newGroupDraft?.participants]); + + const groupName = ReportUtils.getGroupChatName(participantAccountIDs ?? []); + + const sections: ListItem[] = useMemo( + () => + selectedOptions + .map((selectedOption: Participant) => { + const accountID = selectedOption.accountID; + const isAdmin = personalData.accountID === accountID; + let roleBadge = null; + if (isAdmin) { + roleBadge = ( + + ); + } + + const section: ListItem = { + login: selectedOption?.login ?? '', + text: selectedOption?.text ?? '', + keyForList: selectedOption?.keyForList ?? '', + isSelected: !isAdmin, + isDisabled: isAdmin, + rightElement: roleBadge, + accountID, + icons: selectedOption?.icons, + }; + return section; + }) + .sort((a, b) => a.text?.toLowerCase().localeCompare(b.text?.toLowerCase() ?? '') ?? -1), + [selectedOptions, personalData.accountID, translate, styles.textStrong, styles.justifyContentCenter, styles.badgeBordered, styles.activeItemBadge, StyleUtils], + ); + + /** + * Removes a selected option from list if already selected. + */ + const unselectOption = (option: ListItem) => { + if (!newGroupDraft) { + return; + } + const newSelectedParticipants = newGroupDraft.participants.filter((participant) => participant.login !== option.login); + Report.setGroupDraft(newSelectedParticipants); + }; + + const createGroup = () => { + if (!newGroupDraft) { + return; + } + const logins: string[] = newGroupDraft.participants.map((participant) => participant.login); + Report.navigateToAndOpenReport(logins, true, groupName); + }; + + const navigateBack = () => { + Navigation.goBack(ROUTES.NEW_CHAT); + }; + + return ( + + + + + + + 1} + confirmButtonText={translate('newChatPage.startGroup')} + onConfirm={createGroup} + /> + + ); +} + +NewChatConfirmPage.displayName = 'NewChatConfirmPage'; + +export default withOnyx({ + newGroupDraft: { + key: ONYXKEYS.NEW_GROUP_CHAT_DRAFT, + }, + allPersonalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, +})(NewChatConfirmPage); diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index f4eccd52c78e..9c2d47f684ab 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -7,6 +7,7 @@ import OfflineIndicator from '@components/OfflineIndicator'; import OptionsSelector from '@components/OptionsSelector'; import ScreenWrapper from '@components/ScreenWrapper'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useSearchTermAndSearch from '@hooks/useSearchTermAndSearch'; @@ -14,6 +15,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import doInteractionTask from '@libs/DoInteractionTask'; +import Log from '@libs/Log'; +import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -21,12 +24,17 @@ import variables from '@styles/variables'; import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; +import type {SelectedParticipant} from '@src/types/onyx/NewGroupChatDraft'; type NewChatPageWithOnyxProps = { /** All reports shared with the user */ reports: OnyxCollection; + /** New group chat draft data */ + newGroupDraft: OnyxEntry; + /** All of the personal details for everyone */ personalDetails: OnyxEntry; @@ -45,7 +53,7 @@ type NewChatPageProps = NewChatPageWithOnyxProps & { const excludedGroupEmails = CONST.EXPENSIFY_EMAILS.filter((value) => value !== CONST.EMAIL.CONCIERGE); -function NewChatPage({betas, isGroupChat, personalDetails, reports, isSearchingForReports, dismissedReferralBanners}: NewChatPageProps) { +function NewChatPage({betas, isGroupChat, personalDetails, reports, isSearchingForReports, dismissedReferralBanners, newGroupDraft}: NewChatPageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const [searchTerm, setSearchTerm] = useState(''); @@ -57,6 +65,8 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, isSearchingF const {isSmallScreenWidth} = useWindowDimensions(); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); + const personalData = useCurrentUserPersonalDetails(); + const maxParticipantsReached = selectedOptions.length === CONST.REPORT.MAXIMUM_PARTICIPANTS; const setSearchTermAndSearchInServer = useSearchTermAndSearch(setSearchTerm, maxParticipantsReached); @@ -146,7 +156,6 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, isSearchingF [], true, ); - setSelectedOptions(newSelectedOptions); setFilteredRecentReports(recentReports); setFilteredPersonalDetails(newChatPersonalDetails); @@ -158,27 +167,45 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, isSearchingF * or navigates to the existing chat if one with those participants already exists. */ const createChat = (option: OptionData) => { - if (!option.login) { + let login = ''; + + if (option.login) { + login = option.login; + } else if (selectedOptions.length === 1) { + login = selectedOptions[0].login ?? ''; + } + + if (!login) { + Log.warn('Tried to create chat with empty login'); return; } - Report.navigateToAndOpenReport([option.login]); - }; + Report.navigateToAndOpenReport([login]); + }; /** - * Creates a new group chat with all the selected options and the current user, - * or navigates to the existing chat if one with those participants already exists. + * Navigates to create group confirm page */ - const createGroup = () => { - const logins = selectedOptions.map((option) => option.login).filter((login): login is string => typeof login === 'string'); - - if (logins.length < 1) { + const navigateToConfirmPage = () => { + if (!personalData || !personalData.login || !personalData.accountID) { return; } - - Report.navigateToAndOpenReport(logins); + const selectedParticipants: SelectedParticipant[] = selectedOptions.map((option: OptionData) => ({login: option.login ?? '', accountID: option.accountID ?? -1})); + const logins = [...selectedParticipants, {login: personalData.login, accountID: personalData.accountID}]; + Report.setGroupDraft(logins); + Navigation.navigate(ROUTES.NEW_CHAT_CONFIRM); }; const updateOptions = useCallback(() => { + let newSelectedOptions; + if (newGroupDraft?.participants) { + const selectedParticipants = newGroupDraft.participants.filter((participant) => participant.accountID !== personalData.accountID); + newSelectedOptions = selectedParticipants.map((participant): OptionData => { + const baseOption = OptionsListUtils.getParticipantsOption({accountID: participant.accountID, login: participant.login, reportID: ''}, personalDetails); + return {...baseOption, reportID: baseOption.reportID ?? ''}; + }); + setSelectedOptions(newSelectedOptions); + } + const { recentReports, personalDetails: newChatPersonalDetails, @@ -188,7 +215,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, isSearchingF personalDetails, betas ?? [], searchTerm, - selectedOptions, + newSelectedOptions ?? selectedOptions, isGroupChat ? excludedGroupEmails : [], false, true, @@ -200,12 +227,13 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, isSearchingF [], true, ); + setFilteredRecentReports(recentReports); setFilteredPersonalDetails(newChatPersonalDetails); setFilteredUserToInvite(userToInvite); // props.betas is not added as dependency since it doesn't change during the component lifecycle // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reports, personalDetails, searchTerm]); + }, [reports, personalDetails, searchTerm, newGroupDraft]); useEffect(() => { const interactionTask = doInteractionTask(() => { @@ -266,9 +294,9 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, isSearchingF shouldShowConfirmButton shouldShowReferralCTA={!dismissedReferralBanners?.[CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT]} referralContentType={CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT} - confirmButtonText={selectedOptions.length > 1 ? translate('newChatPage.createGroup') : translate('newChatPage.createChat')} + confirmButtonText={selectedOptions.length > 1 ? translate('common.next') : translate('newChatPage.createChat')} textInputAlert={isOffline ? [`${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}`, {isTranslated: true}] : ''} - onConfirmSelection={createGroup} + onConfirmSelection={selectedOptions.length > 1 ? navigateToConfirmPage : createChat} textInputLabel={translate('optionsSelector.nameEmailOrPhoneNumber')} safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} isLoadingNewOptions={isSearchingForReports} @@ -288,6 +316,9 @@ export default withOnyx({ dismissedReferralBanners: { key: ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS, }, + newGroupDraft: { + key: ONYXKEYS.NEW_GROUP_CHAT_DRAFT, + }, reports: { key: ONYXKEYS.COLLECTION.REPORT, }, diff --git a/src/pages/home/HeaderView.tsx b/src/pages/home/HeaderView.tsx index 9d620472bf3a..8aa2b855176c 100644 --- a/src/pages/home/HeaderView.tsx +++ b/src/pages/home/HeaderView.tsx @@ -21,7 +21,6 @@ import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import {getGroupChatName} from '@libs/GroupChatUtils'; import * as HeaderUtils from '@libs/HeaderUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {ReportWithoutHasDraft} from '@libs/OnyxSelectors/reportWithoutHasDraftSelector'; @@ -77,6 +76,7 @@ function HeaderView({report, personalDetails, parentReport, parentReportAction, const theme = useTheme(); const styles = useThemeStyles(); const isSelfDM = ReportUtils.isSelfDM(report); + const isGroupChat = ReportUtils.isGroupChat(report) || ReportUtils.isDeprecatedGroupDM(report); // Currently, currentUser is not included in participantAccountIDs, so for selfDM, we need to add the currentUser as participants. const participants = isSelfDM ? [session?.accountID ?? -1] : (report?.participantAccountIDs ?? []).slice(0, 5); const participantPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs(participants, personalDetails); @@ -89,7 +89,7 @@ function HeaderView({report, personalDetails, parentReport, parentReportAction, const isTaskReport = ReportUtils.isTaskReport(report); const reportHeaderData = !isTaskReport && !isChatThread && report.parentReportID ? parentReport : report; // Use sorted display names for the title for group chats on native small screen widths - const title = ReportUtils.isGroupChat(report) ? getGroupChatName(report, true) : ReportUtils.getReportName(reportHeaderData); + const title = isGroupChat ? ReportUtils.getGroupChatName(report.participantAccountIDs ?? [], true) : ReportUtils.getReportName(reportHeaderData); const subtitle = ReportUtils.getChatRoomSubtitle(reportHeaderData); const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(reportHeaderData); const isConcierge = ReportUtils.hasSingleParticipant(report) && participants.includes(CONST.ACCOUNT_ID.CONCIERGE); @@ -144,7 +144,7 @@ function HeaderView({report, personalDetails, parentReport, parentReportAction, Report.updateNotificationPreference(reportID, report.notificationPreference, CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, false, report.parentReportID, report.parentReportActionID), ); - const canJoinOrLeave = !isSelfDM && (isChatThread || isUserCreatedPolicyRoom || canLeaveRoom); + const canJoinOrLeave = !isSelfDM && !isGroupChat && (isChatThread || isUserCreatedPolicyRoom || canLeaveRoom); const canJoin = canJoinOrLeave && !isWhisperAction && report.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; const canLeave = canJoinOrLeave && ((isChatThread && !!report.notificationPreference?.length) || isUserCreatedPolicyRoom || canLeaveRoom); if (canJoin) { diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js index 24603de5679c..c154e39e0124 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js @@ -21,6 +21,7 @@ import personalDetailsPropType from '@pages/personalDetailsPropType'; import * as App from '@userActions/App'; import * as IOU from '@userActions/IOU'; import * as Policy from '@userActions/Policy'; +import * as Report from '@userActions/Report'; import * as Task from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -266,7 +267,7 @@ function FloatingActionButtonAndPopover(props) { { icon: Expensicons.ChatBubble, text: translate('sidebarScreen.fabNewChat'), - onSelected: () => interceptAnonymousUser(() => Navigation.navigate(ROUTES.NEW)), + onSelected: () => interceptAnonymousUser(Report.startNewChat), }, { icon: Expensicons.MoneyCircle, diff --git a/src/pages/settings/Report/ReportSettingsPage.tsx b/src/pages/settings/Report/ReportSettingsPage.tsx index 383cbbcb0833..f1c4047ae33e 100644 --- a/src/pages/settings/Report/ReportSettingsPage.tsx +++ b/src/pages/settings/Report/ReportSettingsPage.tsx @@ -11,7 +11,6 @@ import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getGroupChatName} from '@libs/GroupChatUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; import type {ReportSettingsNavigatorParamList} from '@navigation/types'; @@ -48,7 +47,8 @@ function ReportSettingsPage({report, policies}: ReportSettingsPageProps) { const shouldShowNotificationPref = !isMoneyRequestReport && report?.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; const roomNameLabel = translate(isMoneyRequestReport ? 'workspace.editor.nameInputLabel' : 'newRoomPage.roomName'); - const reportName = ReportUtils.isGroupChat(report) ? getGroupChatName(report) : ReportUtils.getReportName(report); + const reportName = + ReportUtils.isDeprecatedGroupDM(report) || ReportUtils.isGroupChat(report) ? ReportUtils.getGroupChatName(report.participantAccountIDs ?? []) : ReportUtils.getReportName(report); const shouldShowWriteCapability = !isMoneyRequestReport; diff --git a/src/types/onyx/NewGroupChatDraft.ts b/src/types/onyx/NewGroupChatDraft.ts new file mode 100644 index 000000000000..97dd63aa5f68 --- /dev/null +++ b/src/types/onyx/NewGroupChatDraft.ts @@ -0,0 +1,11 @@ +type SelectedParticipant = { + accountID: number; + login: string; +}; + +type NewGroupChatDraft = { + participants: SelectedParticipant[]; + reportName: string; +}; +export type {SelectedParticipant}; +export default NewGroupChatDraft; diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index 02dfcbbbfc5f..722102e3ab6e 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -192,4 +192,4 @@ type ReportCollectionDataSet = CollectionDataSet { groupChat = Object.values(allReports ?? {}).find( (report) => - report?.type === CONST.REPORT.TYPE.CHAT && isEqual(report.participantAccountIDs, [CARLOS_ACCOUNT_ID, JULES_ACCOUNT_ID, VIT_ACCOUNT_ID]), + report?.type === CONST.REPORT.TYPE.CHAT && + isEqual(report.participantAccountIDs, [CARLOS_ACCOUNT_ID, JULES_ACCOUNT_ID, VIT_ACCOUNT_ID, RORY_ACCOUNT_ID]), ) ?? null; expect(isEmptyObject(groupChat)).toBe(false); expect(groupChat?.pendingFields).toStrictEqual({createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD});