diff --git a/src/CONST/index.ts b/src/CONST/index.ts index cf0c6368090a..86f634120491 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1335,6 +1335,7 @@ const CONST = { REMOVE_FROM_ROOM: 'REMOVEFROMROOM', LEAVE_ROOM: 'LEAVEROOM', UPDATE_ROOM_DESCRIPTION: 'UPDATEROOMDESCRIPTION', + UPDATE_ROOM_AVATAR: 'UPDATEROOMAVATAR', }, REJECTEDTRANSACTION_THREAD: 'REJECTEDTRANSACTION_THREAD', REJECTED_TRANSACTION_MARKASRESOLVED: 'REJECTEDTRANSACTIONMARKASRESOLVED', diff --git a/src/components/AvatarButtonWithIcon.tsx b/src/components/AvatarButtonWithIcon.tsx index d2800733d6ca..c99161469915 100644 --- a/src/components/AvatarButtonWithIcon.tsx +++ b/src/components/AvatarButtonWithIcon.tsx @@ -61,6 +61,9 @@ type AvatarButtonWithIconProps = { /** Optionally override the default "Edit" icon */ editIcon?: IconAsset; + + /** The name associated with avatar */ + name?: string; }; /** @@ -82,6 +85,7 @@ function AvatarButtonWithIcon({ disabled = false, editIcon = Expensicons.Pencil, anchorRef, + name = '', }: AvatarButtonWithIconProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -110,6 +114,7 @@ function AvatarButtonWithIcon({ fallbackIcon={fallbackIcon} size={size} type={type} + name={name} /> ) : ( diff --git a/src/components/AvatarWithImagePicker.tsx b/src/components/AvatarWithImagePicker.tsx index 45bf6422ac7b..60914e47fdf1 100644 --- a/src/components/AvatarWithImagePicker.tsx +++ b/src/components/AvatarWithImagePicker.tsx @@ -69,6 +69,9 @@ type AvatarWithImagePickerProps = Omit ; + participants: number[]; }; -function RoomHeaderAvatars({icons, reportID}: RoomHeaderAvatarsProps) { +function RoomHeaderAvatars({icons, report, policy, participants}: RoomHeaderAvatarsProps) { const navigateToAvatarPage = (icon: Icon) => { if (icon.type === CONST.ICON_TYPE_WORKSPACE && icon.id) { - Navigation.navigate(ROUTES.REPORT_AVATAR.getRoute(reportID, icon.id.toString())); + Navigation.navigate(ROUTES.REPORT_AVATAR.getRoute(report?.reportID, icon.id.toString())); return; } @@ -30,6 +38,8 @@ function RoomHeaderAvatars({icons, reportID}: RoomHeaderAvatarsProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const currentUserAccountID = getCurrentUserAccountID(); + const canEditRoomAvatar = isUserCreatedPolicyRoom(report) && participants.includes(currentUserAccountID) && !!policy && policy.role !== CONST.POLICY.ROLE.AUDITOR; if (!icons.length) { return null; @@ -42,6 +52,31 @@ function RoomHeaderAvatars({icons, reportID}: RoomHeaderAvatarsProps) { return; } + if (canEditRoomAvatar) { + return ( + Navigation.navigate(ROUTES.REPORT_AVATAR.getRoute(report.reportID))} + onImageRemoved={() => updatePolicyRoomAvatar(report.reportID)} + onImageSelected={(file) => updatePolicyRoomAvatar(report.reportID, file)} + editIcon={Expensicons.Camera} + editIconStyle={styles.smallEditIconAccount} + pendingAction={report.pendingFields?.avatar} + errors={report.errorFields?.avatar ?? null} + errorRowStyles={styles.mt6} + onErrorClose={() => clearAvatarErrors(report.reportID)} + style={[styles.w100, styles.mb3, styles.alignItemsStart, styles.sectionMenuItemTopDescription]} + type={icon.type} + editorMaskImage={Expensicons.ImageCropSquareMask} + name={icon.name} + /> + ); + } + return ( ${message}`, type: 'COMMENT'}]; } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_AVATAR)) { + const message = getRoomAvatarUpdatedMessage(action); + return [{text: message, html: `${message}`, type: 'COMMENT'}]; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DESCRIPTION)) { const message = getWorkspaceDescriptionUpdatedMessage(action); return [{text: message, html: `${message}`, type: 'COMMENT'}]; @@ -2262,6 +2267,17 @@ function getUpdateRoomDescriptionMessage(reportAction: ReportAction): string { return translateLocal('roomChangeLog.clearRoomDescription'); } +function getRoomAvatarUpdatedMessage(reportAction: ReportAction): string { + const originalMessage = getOriginalMessage(reportAction) as OriginalMessageChangeLog; + if (originalMessage?.avatarURL) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('roomChangeLog.changedRoomAvatar'); + } + + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('roomChangeLog.removedRoomAvatar'); +} + function getRetractedMessage(): string { // eslint-disable-next-line @typescript-eslint/no-deprecated return translateLocal('iou.retracted'); @@ -3406,6 +3422,7 @@ export { getExportIntegrationLastMessageText, getExportIntegrationMessageHTML, getUpdateRoomDescriptionMessage, + getRoomAvatarUpdatedMessage, didMessageMentionCurrentUser, getPolicyChangeLogAddEmployeeMessage, getPolicyChangeLogUpdateEmployee, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index a6c1449c6b73..d5583e4ede62 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -570,6 +570,11 @@ type OptimisticRoomDescriptionUpdatedReportAction = Pick< 'actorAccountID' | 'created' | 'message' | 'person' | 'reportActionID' | 'pendingAction' | 'actionName' | 'originalMessage' >; +type OptimisticRoomAvatarUpdatedReportAction = Pick< + ReportAction, + 'actorAccountID' | 'created' | 'message' | 'person' | 'reportActionID' | 'pendingAction' | 'actionName' | 'originalMessage' +>; + type OptimisticChatReport = Pick< Report, | 'type' @@ -1004,6 +1009,7 @@ Onyx.connect({ return acc; } + // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { handlePreexistingReport(report); }); @@ -3530,6 +3536,26 @@ function getIconsForInvoiceReport( return icons; } +/** + * Helper function to get the icons for a user-created policy room. Only to be used in getIcons(). + */ +function getIconsForUserCreatedPolicyRoom(report: OnyxInputOrEntry, policy: OnyxInputOrEntry): Icon[] { + if (!report) { + return []; + } + if (report?.avatarUrl) { + return [ + { + source: report.avatarUrl, + type: CONST.ICON_TYPE_WORKSPACE, + name: getPolicyName({report, policy}), + id: report?.policyID, + }, + ]; + } + return [getWorkspaceIcon(report, policy)]; +} + /** * 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. @@ -3566,6 +3592,9 @@ function getIcons( if (isDomainRoom(report)) { return getIconsForDomainRoom(report); } + if (isUserCreatedPolicyRoom(report)) { + return getIconsForUserCreatedPolicyRoom(report, policy); + } if (isAdminRoom(report) || isAnnounceRoom(report) || isChatRoom(report) || (isArchivedNonExpenseReport(report, isReportArchived) && !chatIncludesConcierge(report))) { return getIconsForPolicyRoom(report, personalDetails, policy, invoiceReceiverPolicy); } @@ -7685,6 +7714,38 @@ function buildOptimisticRoomDescriptionUpdatedReportAction(description: string): }; } +/** + * Returns the necessary reportAction onyx data to indicate that the room avatar has been updated + */ +function buildOptimisticRoomAvatarUpdatedReportAction(avatarURL: string): OptimisticRoomAvatarUpdatedReportAction { + const now = DateUtils.getDBTime(); + return { + reportActionID: rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_AVATAR, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + actorAccountID: currentUserAccountID, + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + text: avatarURL ? `changed the room avatar` : 'removed the room avatar', + html: avatarURL ? `changed the room avatar` : 'removed the room avatar', + }, + ], + person: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'strong', + text: getCurrentUserDisplayNameOrEmail(), + }, + ], + originalMessage: { + avatarURL, + lastModified: now, + }, + created: now, + }; +} + /** * Returns the necessary reportAction onyx data to indicate that the transaction has been put on hold optimistically * @param [created] - Action created time @@ -12223,6 +12284,7 @@ export { buildOptimisticChangePolicyReportAction, buildOptimisticRenamedRoomReportAction, buildOptimisticRoomDescriptionUpdatedReportAction, + buildOptimisticRoomAvatarUpdatedReportAction, buildOptimisticReportPreview, buildOptimisticActionableTrackExpenseWhisper, buildOptimisticSubmittedReportAction, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 2ffe5bf060cd..44198e6fecc6 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -52,6 +52,7 @@ import { getReportAction, getReportActionMessageText, getRetractedMessage, + getRoomAvatarUpdatedMessage, getTagListNameUpdatedMessage, getTravelUpdateMessage, getUpdatedApprovalRuleMessage, @@ -896,6 +897,8 @@ function getOptionData({ result.alternateText = getMessageOfOldDotReportAction(lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_DESCRIPTION) { result.alternateText = getUpdateRoomDescriptionMessage(lastAction); + } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_AVATAR) { + result.alternateText = getRoomAvatarUpdatedMessage(lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EMPLOYEE) { result.alternateText = getPolicyChangeLogAddEmployeeMessage(lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE) { diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 4608f1257d18..0414953ff417 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -54,6 +54,7 @@ import type { UpdateReportNotificationPreferenceParams, UpdateReportPrivateNoteParams, UpdateReportWriteCapabilityParams, + UpdateRoomAvatarParams, UpdateRoomDescriptionParams, } from '@libs/API/parameters'; import type ExportReportCSVParams from '@libs/API/parameters/ExportReportCSVParams'; @@ -111,6 +112,7 @@ import { buildOptimisticMovedReportAction, buildOptimisticRenamedRoomReportAction, buildOptimisticReportPreview, + buildOptimisticRoomAvatarUpdatedReportAction, buildOptimisticRoomDescriptionUpdatedReportAction, buildOptimisticSelfDMReport, buildOptimisticUnHoldReportAction, @@ -924,7 +926,15 @@ function updateChatName(reportID: string, reportName: string, type: typeof CONST API.write(command, parameters, {optimisticData, successData, failureData}); } -function updateGroupChatAvatar(reportID: string, file?: File | CustomRNImageManipulatorResult) { +/** + * Helper function to build optimistic, success, and failure Onyx updates + * when updating or removing a report's avatar. + * + * @param reportID - The report ID of the policy room. + * @param [file] - (Optional) The selected image file to update the avatar with. + * If not provided, the existing avatar will be removed. + */ +function buildUpdateReportAvatarOnyxData(reportID: string, file?: File | CustomRNImageManipulatorResult) { // If we have no file that means we are removing the avatar. const optimisticData: OnyxUpdate[] = [ { @@ -967,10 +977,67 @@ function updateGroupChatAvatar(reportID: string, file?: File | CustomRNImageMani }, }, ]; + + return {optimisticData, successData, failureData}; +} + +/** + * Updates the avatar for a group chat. + */ +function updateGroupChatAvatar(reportID: string, file?: File | CustomRNImageManipulatorResult) { + const {optimisticData, successData, failureData} = buildUpdateReportAvatarOnyxData(reportID, file); const parameters: UpdateGroupChatAvatarParams = {file, reportID}; API.write(WRITE_COMMANDS.UPDATE_GROUP_CHAT_AVATAR, parameters, {optimisticData, failureData, successData}); } +/** + * Updates the avatar for a policy room. + */ +function updatePolicyRoomAvatar(reportID: string, file?: File | CustomRNImageManipulatorResult) { + const avatarURL = file?.uri ?? ''; + const {optimisticData, successData, failureData} = buildUpdateReportAvatarOnyxData(reportID, file); + + const optimisticAction = buildOptimisticRoomAvatarUpdatedReportAction(avatarURL); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: {[optimisticAction.reportActionID]: optimisticAction}, + }); + + // Update the report with last action details + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + lastActorAccountID: currentUserAccountID, + lastVisibleActionCreated: optimisticAction.created, + lastMessageText: (optimisticAction.message as Message[]).at(0)?.text, + }, + }); + + // Add success data to clear pending action + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [optimisticAction.reportActionID]: {pendingAction: null}, + }, + }); + + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [optimisticAction.reportActionID]: { + pendingAction: null, + }, + }, + }); + + const parameters: UpdateRoomAvatarParams = {reportID, file, reportActionID: optimisticAction.reportActionID}; + API.write(WRITE_COMMANDS.UPDATE_ROOM_AVATAR, parameters, {optimisticData, failureData, successData}); +} + /** * Clear error and pending fields for the report avatar */ @@ -6222,6 +6289,7 @@ export { unsubscribeFromReportChannel, updateDescription, updateGroupChatAvatar, + updatePolicyRoomAvatar, updateGroupChatMemberRoles, updateChatName, updateLastVisitTime, diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index d84ff052ee21..f9e30c166e6d 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -19,6 +19,7 @@ import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeed import type {PromotedAction} from '@components/PromotedActionsBar'; import PromotedActionsBar, {PromotedActions} from '@components/PromotedActionsBar'; import ReportActionAvatars from '@components/ReportActionAvatars'; +import RoomHeaderAvatars from '@components/RoomHeaderAvatars'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import {useSearchContext} from '@components/Search/SearchContext'; @@ -591,6 +592,18 @@ function ReportDetailsPage({policy, report, route, reportMetadata}: ReportDetail ) : null; const renderedAvatar = useMemo(() => { + if (isChatRoom && !isThread) { + return ( + + + + ); + } if (!isGroupChat || isThread) { return ( @@ -627,21 +640,7 @@ function ReportDetailsPage({policy, report, route, reportMetadata}: ReportDetail style={[styles.w100, styles.mb3]} /> ); - }, [ - isGroupChat, - isThread, - icons, - report.avatarUrl, - report.pendingFields?.avatar, - report.errorFields?.avatar, - report.reportID, - styles.avatarXLarge, - styles.smallEditIconAccount, - styles.mt6, - styles.w100, - styles.mb3, - moneyRequestReport, - ]); + }, [isGroupChat, isThread, isChatRoom, icons, report, styles.avatarXLarge, styles.smallEditIconAccount, styles.mt6, styles.w100, styles.mb3, moneyRequestReport?.reportID]); const canJoin = canJoinChat(report, parentReportAction, policy, !!reportNameValuePairs?.private_isArchived); diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index 9c82262e6aba..df4ee4f59f6c 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -50,6 +50,7 @@ import { getRenamedAction, getReopenedMessage, getReportActionMessageText, + getRoomAvatarUpdatedMessage, getTagListNameUpdatedMessage, getTravelUpdateMessage, getUpdatedApprovalRuleMessage, @@ -683,6 +684,8 @@ const ContextMenuActions: ContextMenuAction[] = [ setClipboardMessage(getExportIntegrationMessageHTML(reportAction)); } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_DESCRIPTION) { setClipboardMessage(getUpdateRoomDescriptionMessage(reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_AVATAR) { + setClipboardMessage(getRoomAvatarUpdatedMessage(reportAction)); } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EMPLOYEE) { setClipboardMessage(getPolicyChangeLogAddEmployeeMessage(reportAction)); } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE) { diff --git a/src/pages/media/AttachmentModalScreen/routes/report/ReportAvatarModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/report/ReportAvatarModalContent.tsx index dd8e21db0aeb..ae5ed59d621c 100644 --- a/src/pages/media/AttachmentModalScreen/routes/report/ReportAvatarModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/report/ReportAvatarModalContent.tsx @@ -1,6 +1,6 @@ import React, {useMemo} from 'react'; import useOnyx from '@hooks/useOnyx'; -import {getDefaultGroupAvatar, getPolicyName, getReportName, getWorkspaceIcon, isGroupChat, isThread} from '@libs/ReportUtils'; +import {getDefaultGroupAvatar, getPolicyName, getReportName, getWorkspaceIcon, isGroupChat, isThread, isUserCreatedPolicyRoom} from '@libs/ReportUtils'; import {getFullSizeAvatar} from '@libs/UserUtils'; import type {AttachmentModalBaseContentProps} from '@pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types'; import AttachmentModalContainer from '@pages/media/AttachmentModalScreen/AttachmentModalContainer'; @@ -24,6 +24,13 @@ function ReportAvatarModalContent({navigation, route}: AttachmentModalScreenProp isWorkspaceAvatar: false, }; } + if (isUserCreatedPolicyRoom(report)) { + return { + source: report?.avatarUrl ? getFullSizeAvatar(report.avatarUrl, 0) : undefined, + headerTitle: getReportName(report), + isWorkspaceAvatar: false, + }; + } return { source: getFullSizeAvatar(getWorkspaceIcon(report, policy).source, 0), diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index 161b486a7179..3296438a3465 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -386,6 +386,7 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa source={policy?.avatarURL ?? ''} avatarID={policy?.id} size={CONST.AVATAR_SIZE.X_LARGE} + name={policyName} avatarStyle={styles.avatarXLarge} enablePreview DefaultAvatar={DefaultAvatar} diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 8751b4c420d5..e86af9b86791 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -367,6 +367,9 @@ type OriginalMessageChangeLog = { /** Name of the added category */ categoryName?: string; + + /** Avatar URL of workspace room */ + avatarURL?: string; }; /** Model of change log */ diff --git a/tests/actions/EnforceActionExportRestrictions.ts b/tests/actions/EnforceActionExportRestrictions.ts index be76cb9f5b02..5c467834715d 100644 --- a/tests/actions/EnforceActionExportRestrictions.ts +++ b/tests/actions/EnforceActionExportRestrictions.ts @@ -88,6 +88,11 @@ describe('ReportUtils', () => { expect(ReportUtils.getIconsForGroupChat).toBeUndefined(); }); + it('does not export getIconsForUserCreatedPolicyRoom', () => { + // @ts-expect-error the test is asserting that it's undefined, so the TS error is normal + expect(ReportUtils.getIconsForUserCreatedPolicyRoom).toBeUndefined(); + }); + it('does not export getAllReportActions', () => { // @ts-expect-error the test is asserting that it's undefined, so the TS error is normal expect(ReportUtils.getAllReportActions).toBeUndefined(); diff --git a/tests/unit/ReportUtilsGetIconsTest.ts b/tests/unit/ReportUtilsGetIconsTest.ts index 2b30796a0384..7441f8d04f80 100644 --- a/tests/unit/ReportUtilsGetIconsTest.ts +++ b/tests/unit/ReportUtilsGetIconsTest.ts @@ -21,6 +21,7 @@ import { isSelfDM, isTaskReport, isThread, + isUserCreatedPolicyRoom, isWorkspaceTaskReport, isWorkspaceThread, } from '@libs/ReportUtils'; @@ -215,6 +216,25 @@ describe('getIcons', () => { expect(icons.at(0)?.name).toBe('Workspace-Test-001'); }); + it('should return the correct icons for a user created policy room', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([], 0, true), + chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM, + policyID: '1', + avatarUrl: 'https://example.com/avatar.png', + }; + const policy = LHNTestUtils.getFakePolicy('1'); + + // Verify report type conditions + expect(isUserCreatedPolicyRoom(report)).toBe(true); + expect(isChatReport(report)).toBe(true); + + const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy); + expect(icons).toHaveLength(1); + expect(icons.at(0)?.type).toBe(CONST.ICON_TYPE_WORKSPACE); + expect(icons.at(0)?.source).toBe('https://example.com/avatar.png'); + }); + it('should return the correct icons for a policy expense chat', () => { const report: Report = { ...LHNTestUtils.getFakeReport([1], 0, true),