// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ReactNode } from 'react'; import React, { useEffect, useState, useCallback } from 'react'; import { Button, ButtonIconType, ButtonVariant } from '../../Button'; import { Tooltip } from '../../Tooltip'; import type { ConversationType, PushPanelForConversationActionType, ShowConversationType, } from '../../../state/ducks/conversations'; import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges'; import type { SmartChooseGroupMembersModalPropsType } from '../../../state/smart/ChooseGroupMembersModal'; import type { SmartConfirmAdditionsModalPropsType } from '../../../state/smart/ConfirmAdditionsModal'; import { assertDev } from '../../../util/assert'; import { getMutedUntilText } from '../../../util/getMutedUntilText'; import type { LocalizerType, ThemeType } from '../../../types/Util'; import type { BadgeType } from '../../../badges/types'; import { missingCaseError } from '../../../util/missingCaseError'; import { DurationInSeconds } from '../../../util/durations'; import { DisappearingTimerSelect } from '../../DisappearingTimerSelect'; import { PanelRow } from './PanelRow'; import { PanelSection } from './PanelSection'; import { AddGroupMembersModal } from './AddGroupMembersModal'; import { ConversationDetailsActions } from './ConversationDetailsActions'; import { ConversationDetailsHeader } from './ConversationDetailsHeader'; import { ConversationDetailsIcon, IconType } from './ConversationDetailsIcon'; import type { Props as ConversationDetailsMediaListPropsType } from './ConversationDetailsMediaList'; import { ConversationDetailsMediaList } from './ConversationDetailsMediaList'; import type { GroupV2Membership } from './ConversationDetailsMembershipList'; import { ConversationDetailsMembershipList } from './ConversationDetailsMembershipList'; import type { GroupV2PendingMembership, GroupV2RequestingMembership, } from './PendingInvites'; import { EditConversationAttributesModal } from './EditConversationAttributesModal'; import { RequestState } from './util'; import { getCustomColorStyle } from '../../../util/getCustomColorStyle'; import { ConfirmationDialog } from '../../ConfirmationDialog'; import { ConversationNotificationsModal } from './ConversationNotificationsModal'; import type { AvatarDataType, DeleteAvatarFromDiskActionType, ReplaceAvatarActionType, SaveAvatarToDiskActionType, } from '../../../types/Avatar'; import { isConversationMuted } from '../../../util/isConversationMuted'; import { ConversationDetailsGroups } from './ConversationDetailsGroups'; import { PanelType } from '../../../types/Panels'; enum ModalState { NothingOpen, EditingGroupDescription, EditingGroupTitle, AddingGroupMembers, MuteNotifications, UnmuteNotifications, } export type StateProps = { areWeASubscriber: boolean; badges?: ReadonlyArray; canEditGroupInfo: boolean; canAddNewMembers: boolean; conversation?: ConversationType; hasGroupLink: boolean; getPreferredBadge: PreferredBadgeSelectorType; hasActiveCall: boolean; i18n: LocalizerType; isAdmin: boolean; isGroup: boolean; groupsInCommon: ReadonlyArray; maxGroupSize: number; maxRecommendedGroupSize: number; memberships: ReadonlyArray; pendingApprovalMemberships: ReadonlyArray; pendingMemberships: ReadonlyArray; theme: ThemeType; userAvatarData: ReadonlyArray; renderChooseGroupMembersModal: ( props: SmartChooseGroupMembersModalPropsType ) => JSX.Element; renderConfirmAdditionsModal: ( props: SmartConfirmAdditionsModalPropsType ) => JSX.Element; }; type ActionProps = { acceptConversation: (id: string) => void; addMembersToGroup: ( conversationId: string, conversationIds: ReadonlyArray, opts: { onSuccess?: () => unknown; onFailure?: () => unknown; } ) => unknown; blockConversation: (id: string) => void; deleteAvatarFromDisk: DeleteAvatarFromDiskActionType; getProfilesForConversation: (id: string) => unknown; leaveGroup: (conversationId: string) => void; loadRecentMediaItems: (id: string, limit: number) => void; onOutgoingAudioCallInConversation: (conversationId: string) => unknown; onOutgoingVideoCallInConversation: (conversationId: string) => unknown; pushPanelForConversation: PushPanelForConversationActionType; replaceAvatar: ReplaceAvatarActionType; saveAvatarToDisk: SaveAvatarToDiskActionType; searchInConversation: (id: string) => unknown; setDisappearingMessages: (id: string, seconds: DurationInSeconds) => void; setMuteExpiration: (id: string, muteExpiresAt: undefined | number) => unknown; showContactModal: (contactId: string, conversationId?: string) => void; showConversation: ShowConversationType; toggleAddUserToAnotherGroupModal: (contactId?: string) => void; toggleSafetyNumberModal: (conversationId: string) => unknown; updateGroupAttributes: ( conversationId: string, _: Readonly<{ avatar?: undefined | Uint8Array; description?: string; title?: string; }>, opts: { onSuccess?: () => unknown; onFailure?: () => unknown; } ) => unknown; } & Pick; export type Props = StateProps & ActionProps; export function ConversationDetails({ acceptConversation, addMembersToGroup, areWeASubscriber, badges, blockConversation, canEditGroupInfo, canAddNewMembers, conversation, deleteAvatarFromDisk, hasGroupLink, getPreferredBadge, getProfilesForConversation, groupsInCommon, hasActiveCall, i18n, isAdmin, isGroup, leaveGroup, loadRecentMediaItems, memberships, maxGroupSize, maxRecommendedGroupSize, onOutgoingAudioCallInConversation, onOutgoingVideoCallInConversation, pendingApprovalMemberships, pendingMemberships, pushPanelForConversation, renderChooseGroupMembersModal, renderConfirmAdditionsModal, replaceAvatar, saveAvatarToDisk, searchInConversation, setDisappearingMessages, setMuteExpiration, showContactModal, showConversation, showLightboxWithMedia, theme, toggleSafetyNumberModal, toggleAddUserToAnotherGroupModal, updateGroupAttributes, userAvatarData, }: Props): JSX.Element { const [modalState, setModalState] = useState( ModalState.NothingOpen ); const [editGroupAttributesRequestState, setEditGroupAttributesRequestState] = useState(RequestState.Inactive); const [addGroupMembersRequestState, setAddGroupMembersRequestState] = useState(RequestState.Inactive); if (conversation === undefined) { throw new Error('ConversationDetails rendered without a conversation'); } useEffect(() => { getProfilesForConversation(conversation.id); }, [conversation.id, getProfilesForConversation]); const invitesCount = pendingMemberships.length + pendingApprovalMemberships.length; const otherMemberships = memberships.filter(({ member }) => !member.isMe); const isJustMe = otherMemberships.length === 0; const isAnyoneElseAnAdmin = otherMemberships.some( membership => membership.isAdmin ); const cannotLeaveBecauseYouAreLastAdmin = isAdmin && !isJustMe && !isAnyoneElseAnAdmin; const onCloseModal = useCallback(() => { setModalState(ModalState.NothingOpen); setEditGroupAttributesRequestState(RequestState.Inactive); }, []); let modalNode: ReactNode; switch (modalState) { case ModalState.NothingOpen: modalNode = undefined; break; case ModalState.EditingGroupDescription: case ModalState.EditingGroupTitle: modalNode = ( ) => { setEditGroupAttributesRequestState(RequestState.Active); updateGroupAttributes(conversation.id, options, { onSuccess: () => { setModalState(ModalState.NothingOpen); setEditGroupAttributesRequestState(RequestState.Inactive); }, onFailure: () => { setEditGroupAttributesRequestState( RequestState.InactiveWithError ); }, }); }} onClose={onCloseModal} requestState={editGroupAttributesRequestState} title={conversation.title} deleteAvatarFromDisk={deleteAvatarFromDisk} replaceAvatar={replaceAvatar} saveAvatarToDisk={saveAvatarToDisk} userAvatarData={userAvatarData} /> ); break; case ModalState.AddingGroupMembers: modalNode = ( { setAddGroupMembersRequestState(oldRequestState => { assertDev( oldRequestState !== RequestState.Active, 'Should not be clearing an active request state' ); return RequestState.Inactive; }); }} conversationIdsAlreadyInGroup={ new Set(memberships.map(membership => membership.member.id)) } groupTitle={conversation.title} i18n={i18n} makeRequest={async conversationIds => { setAddGroupMembersRequestState(RequestState.Active); addMembersToGroup(conversation.id, conversationIds, { onSuccess: () => { setModalState(ModalState.NothingOpen); setAddGroupMembersRequestState(RequestState.Inactive); }, onFailure: () => { setAddGroupMembersRequestState(RequestState.InactiveWithError); }, }); }} maxGroupSize={maxGroupSize} maxRecommendedGroupSize={maxRecommendedGroupSize} onClose={onCloseModal} requestState={addGroupMembersRequestState} /> ); break; case ModalState.MuteNotifications: modalNode = ( ); break; case ModalState.UnmuteNotifications: modalNode = ( setMuteExpiration(conversation.id, 0), style: 'affirmative', text: i18n('unmute'), }, ]} hasXButton i18n={i18n} title={i18n('ConversationDetails__unmute--title')} onClose={onCloseModal} > {getMutedUntilText(Number(conversation.muteExpiresAt), i18n)} ); break; default: throw missingCaseError(modalState); } const isMuted = isConversationMuted(conversation); return (
{ setModalState( isGroupTitle ? ModalState.EditingGroupTitle : ModalState.EditingGroupDescription ); }} theme={theme} />
{!conversation.isMe && ( <> onOutgoingVideoCallInConversation(conversation.id)} type="video" /> {!isGroup && ( onOutgoingAudioCallInConversation(conversation.id) } type="audio" /> )} )}
{!isGroup || canEditGroupInfo ? ( } info={ isGroup ? i18n('ConversationDetails--disappearing-messages-info--group') : i18n( 'ConversationDetails--disappearing-messages-info--direct' ) } label={i18n('ConversationDetails--disappearing-messages-label')} right={ setDisappearingMessages(conversation.id, value) } /> } /> ) : null} } label={i18n('showChatColorEditor')} onClick={() => { pushPanelForConversation({ type: PanelType.ChatColorEditor, }); }} right={
} /> {isGroup && ( } label={i18n('ConversationDetails--notifications')} onClick={() => pushPanelForConversation({ type: PanelType.NotificationSettings, }) } right={ conversation.muteExpiresAt ? getMutedUntilText(conversation.muteExpiresAt, i18n) : undefined } /> )} {!isGroup && !conversation.isMe && ( toggleSafetyNumberModal(conversation.id)} icon={ } label={
{i18n('icu:ConversationDetails__viewSafetyNumber')}
} /> )} {isGroup && ( { setModalState(ModalState.AddingGroupMembers); }} theme={theme} /> )} {isGroup && ( {isAdmin || hasGroupLink ? ( } label={i18n('ConversationDetails--group-link')} onClick={() => pushPanelForConversation({ type: PanelType.GroupLinkManagement, }) } right={hasGroupLink ? i18n('on') : i18n('off')} /> ) : null} } label={i18n('ConversationDetails--requests-and-invites')} onClick={() => pushPanelForConversation({ type: PanelType.GroupInvites, }) } right={invitesCount} /> {isAdmin ? ( } label={i18n('permissions')} onClick={() => pushPanelForConversation({ type: PanelType.GroupPermissions, }) } /> ) : null} )} pushPanelForConversation({ type: PanelType.AllMedia, }) } showLightboxWithMedia={showLightboxWithMedia} /> {!isGroup && !conversation.isMe && ( )} {!conversation.isMe && ( leaveGroup(conversation.id)} /> )} {modalNode}
); } function ConversationDetailsCallButton({ disabled, i18n, onClick, type, }: Readonly<{ disabled: boolean; i18n: LocalizerType; onClick: () => unknown; type: 'audio' | 'video'; }>) { const button = ( ); if (disabled) { return ( {button} ); } return button; }