diff --git a/src/components/MultipleAvatars.js b/src/components/MultipleAvatars.js index 9b4d10c5c8e..daeb7a1c780 100644 --- a/src/components/MultipleAvatars.js +++ b/src/components/MultipleAvatars.js @@ -182,7 +182,12 @@ const MultipleAvatars = (props) => { absolute > - {`+${props.icons.length - 1}`} + + {`+${props.icons.length - 1}`} + )} diff --git a/src/languages/en.js b/src/languages/en.js index 9cb699f5f83..22ac07e014f 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -1310,4 +1310,9 @@ export default { parentReportAction: { deletedMessage: '[Deleted message]', }, + threads: { + lastReply: 'Last Reply', + replies: 'Replies', + reply: 'Reply', + }, }; diff --git a/src/languages/es.js b/src/languages/es.js index 7eab68f8cf0..003192a3c8c 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -1775,4 +1775,9 @@ export default { parentReportAction: { deletedMessage: '[Mensaje eliminado]', }, + threads: { + lastReply: 'Ăšltima respuesta', + replies: 'Respuestas', + reply: 'Respuesta', + }, }; diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index bf536690f36..7b0fd560ffe 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -447,6 +447,17 @@ function isThreadParent(reportAction) { return reportAction && reportAction.childReportID && reportAction.childReportID !== 0; } +/** + * Returns true if reportAction is the first chat preview of a Thread + * + * @param {Object} reportAction + * @param {String} reportID + * @returns {Boolean} + */ +function isThreadFirstChat(reportAction, reportID) { + return !_.isUndefined(reportAction.childReportID) && reportAction.childReportID.toString() === reportID; +} + /** * Get either the policyName or domainName the chat is tied to * @param {Object} report @@ -721,6 +732,41 @@ function getSmallSizeAvatar(avatarURL, login) { return `${source.substring(0, lastPeriodIndex)}_128${source.substring(lastPeriodIndex)}`; } +/** + * 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. + * + * @param {Array} participants + * @param {Object} personalDetails + * @returns {Array<*>} + */ +function getIconsForParticipants(participants, personalDetails) { + const participantDetails = []; + const participantsList = participants || []; + + for (let i = 0; i < participantsList.length; i++) { + const login = participantsList[i]; + const avatarSource = getAvatar(lodashGet(personalDetails, [login, 'avatar'], ''), login); + participantDetails.push([login, lodashGet(personalDetails, [login, 'firstName'], ''), avatarSource]); + } + + // Sort all logins by first name (which is the second element in the array) + const sortedParticipantDetails = participantDetails.sort((a, b) => a[1] - b[1]); + + // Now that things are sorted, gather only the avatars (third element in the array) and return those + const avatars = []; + for (let i = 0; i < sortedParticipantDetails.length; i++) { + const userIcon = { + source: sortedParticipantDetails[i][2], + type: CONST.ICON_TYPE_AVATAR, + name: sortedParticipantDetails[i][0], + }; + avatars.push(userIcon); + } + + return avatars; +} + /** * 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. @@ -825,30 +871,7 @@ function getIcons(report, personalDetails, defaultIcon = null) { ]; } - const participantDetails = []; - const participants = report.participants || []; - - for (let i = 0; i < participants.length; i++) { - const login = participants[i]; - const avatarSource = getAvatar(lodashGet(personalDetails, [login, 'avatar'], ''), login); - participantDetails.push([login, lodashGet(personalDetails, [login, 'firstName'], ''), avatarSource]); - } - - // Sort all logins by first name (which is the second element in the array) - const sortedParticipantDetails = participantDetails.sort((a, b) => a[1] - b[1]); - - // Now that things are sorted, gather only the avatars (third element in the array) and return those - const avatars = []; - for (let i = 0; i < sortedParticipantDetails.length; i++) { - const userIcon = { - source: sortedParticipantDetails[i][2], - type: CONST.ICON_TYPE_AVATAR, - name: sortedParticipantDetails[i][0], - }; - avatars.push(userIcon); - } - - return avatars; + return getIconsForParticipants(report.participants, personalDetails); } /** @@ -2020,6 +2043,7 @@ export { chatIncludesConcierge, isPolicyExpenseChat, getDefaultAvatar, + getIconsForParticipants, getIcons, getRoomWelcomeMessage, getDisplayNamesWithTooltips, @@ -2069,6 +2093,7 @@ export { getWorkspaceAvatar, isThread, isThreadParent, + isThreadFirstChat, shouldReportShowSubscript, isSettled, }; diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js index 9f95c0a8bf9..2b56094afc2 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.js +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js @@ -114,7 +114,7 @@ export default [ Permissions.canUseThreads(betas) && type === CONTEXT_MENU_TYPES.REPORT_ACTION && reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && - (_.isUndefined(reportAction.childReportID) || reportAction.childReportID.toString() !== reportID), + !ReportUtils.isThreadFirstChat(reportAction, reportID), onPress: (closePopover, {reportAction, reportID}) => { Report.navigateToAndOpenChildReport(lodashGet(reportAction, 'childReportID', '0'), reportAction, reportID); if (closePopover) { diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 0090396e61c..38b5bda6f71 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -17,6 +17,7 @@ import ReportActionItemMessage from './ReportActionItemMessage'; import UnreadActionIndicator from '../../../components/UnreadActionIndicator'; import ReportActionItemMessageEdit from './ReportActionItemMessageEdit'; import ReportActionItemCreated from './ReportActionItemCreated'; +import ReportActionItemThread from './ReportActionItemThread'; import compose from '../../../libs/compose'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions'; import ControlSelection from '../../../libs/ControlSelection'; @@ -48,6 +49,7 @@ import personalDetailsPropType from '../../personalDetailsPropType'; import ReportActionItemDraft from './ReportActionItemDraft'; import TaskPreview from '../../../components/ReportActionItem/TaskPreview'; import * as ReportActionUtils from '../../../libs/ReportActionsUtils'; +import Permissions from '../../../libs/Permissions'; const propTypes = { /** Report for this action */ @@ -83,6 +85,9 @@ const propTypes = { /** All of the personalDetails */ personalDetails: PropTypes.objectOf(personalDetailsPropType), + /** List of betas available to current user */ + betas: PropTypes.arrayOf(PropTypes.string), + ...windowDimensionsPropTypes, }; @@ -92,6 +97,7 @@ const defaultProps = { preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, personalDetails: {}, shouldShowSubscriptAvatar: false, + betas: [], }; class ReportActionItem extends Component { @@ -243,6 +249,10 @@ class ReportActionItem extends Component { const reactions = _.get(this.props, ['action', 'message', 0, 'reactions'], []); const hasReactions = reactions.length > 0; + const shouldDisplayThreadReplies = + this.props.action.childCommenterCount && Permissions.canUseThreads(this.props.betas) && !ReportUtils.isThreadFirstChat(this.props.action, this.props.report.reportID); + const oldestFourEmails = lodashGet(this.props.action, 'childOldestFourEmails', '').split(','); + return ( <> {children} @@ -254,6 +264,14 @@ class ReportActionItem extends Component { /> )} + {shouldDisplayThreadReplies && ( + + )} ); } @@ -371,6 +389,7 @@ class ReportActionItem extends Component { isVisible={hovered && !this.props.draftMessage && !hasErrors} draftMessage={this.props.draftMessage} isChronosReport={ReportUtils.chatIncludesChronos(this.props.report)} + childReportActionID={this.props.action.childReportActionID} /> )} @@ -402,5 +421,8 @@ export default compose( preferredSkinTone: { key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, }, + betas: { + key: ONYXKEYS.BETAS, + }, }), )(ReportActionItem); diff --git a/src/pages/home/report/ReportActionItemThread.js b/src/pages/home/report/ReportActionItemThread.js new file mode 100644 index 00000000000..9292c4b01c3 --- /dev/null +++ b/src/pages/home/report/ReportActionItemThread.js @@ -0,0 +1,66 @@ +import React from 'react'; +import {View, Pressable, Text} from 'react-native'; +import PropTypes from 'prop-types'; +import _ from 'underscore'; +import styles from '../../../styles/styles'; +import * as Report from '../../../libs/actions/Report'; +import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; +import CONST from '../../../CONST'; +import avatarPropTypes from '../../../components/avatarPropTypes'; +import MultipleAvatars from '../../../components/MultipleAvatars'; +import Navigation from '../../../libs/Navigation/Navigation'; +import ROUTES from '../../../ROUTES'; + +const propTypes = { + /** List of participant icons for the thread */ + icons: PropTypes.arrayOf(avatarPropTypes).isRequired, + + /** Number of comments under the thread */ + numberOfReplies: PropTypes.number.isRequired, + + /** Time of the most recent reply */ + mostRecentReply: PropTypes.string.isRequired, + + /** ID of child thread report */ + childReportID: PropTypes.string.isRequired, + + /** localization props */ + ...withLocalizePropTypes, +}; + +const ReportActionItemThread = (props) => ( + + { + Report.openReport(props.childReportID); + Navigation.navigate(ROUTES.getReportRoute(props.childReportID)); + }} + > + + icon.name)} + /> + + + {`${props.numberOfReplies} ${props.numberOfReplies === 1 ? props.translate('threads.reply') : props.translate('threads.replies')}`} + + {`${props.translate('threads.lastReply')} ${props.datetimeToCalendarTime(props.mostRecentReply)}`} + + + + +); + +ReportActionItemThread.propTypes = propTypes; +ReportActionItemThread.displayName = 'ReportActionItemThread'; + +export default withLocalize(ReportActionItemThread); diff --git a/src/styles/styles.js b/src/styles/styles.js index b09ea373140..bdb1ebd7469 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -1018,6 +1018,10 @@ const styles = { lineHeight: 16, }, + lh140Percent: { + lineHeight: '140%', + }, + formHelp: { color: themeColors.textSupporting, fontSize: variables.fontSizeLabel,