// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ReactNode } from 'react'; import React, { useState } from 'react'; import { Button, ButtonIconType, ButtonVariant } from '../../Button'; import { Tooltip } from '../../Tooltip'; import type { ConversationType } from '../../../state/ducks/conversations'; import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges'; import { assert } from '../../../util/assert'; import { getMutedUntilText } from '../../../util/getMutedUntilText'; import type { LocalizerType, ThemeType } from '../../../types/Util'; import type { MediaItemType } from '../../../types/MediaItem'; import type { BadgeType } from '../../../badges/types'; import { CapabilityError } from '../../../types/errors'; import { missingCaseError } from '../../../util/missingCaseError'; 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 { 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 { isMuted } from '../../../util/isMuted'; enum ModalState { NothingOpen, EditingGroupDescription, EditingGroupTitle, AddingGroupMembers, MuteNotifications, UnmuteNotifications, } export type StateProps = { addMembers: (conversationIds: ReadonlyArray) => Promise; areWeASubscriber: boolean; badges?: ReadonlyArray; canEditGroupInfo: boolean; candidateContactsToAdd: Array; conversation?: ConversationType; hasGroupLink: boolean; getPreferredBadge: PreferredBadgeSelectorType; hasActiveCall: boolean; i18n: LocalizerType; isAdmin: boolean; isGroup: boolean; loadRecentMediaItems: (limit: number) => void; memberships: Array; pendingApprovalMemberships: ReadonlyArray; pendingMemberships: ReadonlyArray; setDisappearingMessages: (seconds: number) => void; showAllMedia: () => void; showChatColorEditor: () => void; showGroupLinkManagement: () => void; showGroupV2Permissions: () => void; showPendingInvites: () => void; showLightboxForMedia: ( selectedMediaItem: MediaItemType, media: Array ) => void; showConversationNotificationsSettings: () => void; updateGroupAttributes: ( _: Readonly<{ avatar?: undefined | Uint8Array; description?: string; title?: string; }> ) => Promise; onBlock: () => void; onLeave: () => void; onUnblock: () => void; theme: ThemeType; userAvatarData: Array; setMuteExpiration: (muteExpiresAt: undefined | number) => unknown; onOutgoingAudioCallInConversation: () => unknown; onOutgoingVideoCallInConversation: () => unknown; }; type ActionProps = { deleteAvatarFromDisk: DeleteAvatarFromDiskActionType; replaceAvatar: ReplaceAvatarActionType; saveAvatarToDisk: SaveAvatarToDiskActionType; showContactModal: (contactId: string, conversationId: string) => void; toggleSafetyNumberModal: (conversationId: string) => unknown; searchInConversation: (id: string) => unknown; }; export type Props = StateProps & ActionProps; export const ConversationDetails: React.ComponentType = ({ addMembers, areWeASubscriber, badges, canEditGroupInfo, candidateContactsToAdd, conversation, deleteAvatarFromDisk, hasGroupLink, getPreferredBadge, hasActiveCall, i18n, isAdmin, isGroup, loadRecentMediaItems, memberships, onBlock, onLeave, onOutgoingAudioCallInConversation, onOutgoingVideoCallInConversation, onUnblock, pendingApprovalMemberships, pendingMemberships, replaceAvatar, saveAvatarToDisk, searchInConversation, setDisappearingMessages, setMuteExpiration, showAllMedia, showChatColorEditor, showContactModal, showConversationNotificationsSettings, showGroupLinkManagement, showGroupV2Permissions, showLightboxForMedia, showPendingInvites, theme, toggleSafetyNumberModal, updateGroupAttributes, userAvatarData, }) => { const [modalState, setModalState] = useState( ModalState.NothingOpen ); const [editGroupAttributesRequestState, setEditGroupAttributesRequestState] = useState(RequestState.Inactive); const [addGroupMembersRequestState, setAddGroupMembersRequestState] = useState(RequestState.Inactive); const [membersMissingCapability, setMembersMissingCapability] = useState(false); if (conversation === undefined) { throw new Error('ConversationDetails rendered without a conversation'); } 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; let modalNode: ReactNode; switch (modalState) { case ModalState.NothingOpen: modalNode = undefined; break; case ModalState.EditingGroupDescription: case ModalState.EditingGroupTitle: modalNode = ( ) => { setEditGroupAttributesRequestState(RequestState.Active); try { await updateGroupAttributes(options); setModalState(ModalState.NothingOpen); setEditGroupAttributesRequestState(RequestState.Inactive); } catch (err) { setEditGroupAttributesRequestState( RequestState.InactiveWithError ); } }} onClose={() => { setModalState(ModalState.NothingOpen); setEditGroupAttributesRequestState(RequestState.Inactive); }} requestState={editGroupAttributesRequestState} title={conversation.title} deleteAvatarFromDisk={deleteAvatarFromDisk} replaceAvatar={replaceAvatar} saveAvatarToDisk={saveAvatarToDisk} userAvatarData={userAvatarData} /> ); break; case ModalState.AddingGroupMembers: modalNode = ( { setAddGroupMembersRequestState(oldRequestState => { assert( oldRequestState !== RequestState.Active, 'Should not be clearing an active request state' ); return RequestState.Inactive; }); }} conversationIdsAlreadyInGroup={ new Set(memberships.map(membership => membership.member.id)) } getPreferredBadge={getPreferredBadge} groupTitle={conversation.title} i18n={i18n} makeRequest={async conversationIds => { setAddGroupMembersRequestState(RequestState.Active); try { await addMembers(conversationIds); setModalState(ModalState.NothingOpen); setAddGroupMembersRequestState(RequestState.Inactive); } catch (err) { if (err instanceof CapabilityError) { setMembersMissingCapability(true); setAddGroupMembersRequestState(RequestState.InactiveWithError); } else { setAddGroupMembersRequestState(RequestState.InactiveWithError); } } }} onClose={() => { setModalState(ModalState.NothingOpen); setEditGroupAttributesRequestState(RequestState.Inactive); }} requestState={addGroupMembersRequestState} theme={theme} /> ); break; case ModalState.MuteNotifications: modalNode = ( { setModalState(ModalState.NothingOpen); }} setMuteExpiration={setMuteExpiration} /> ); break; case ModalState.UnmuteNotifications: modalNode = ( setMuteExpiration(0), style: 'affirmative', text: i18n('unmute'), }, ]} hasXButton i18n={i18n} title={i18n('ConversationDetails__unmute--title')} onClose={() => { setModalState(ModalState.NothingOpen); }} > {getMutedUntilText(Number(conversation.muteExpiresAt), i18n)} ); break; default: throw missingCaseError(modalState); } const isConversationMuted = isMuted(conversation.muteExpiresAt); return (
{membersMissingCapability && ( setMembersMissingCapability(false)} > {i18n('GroupV2--add--missing-capability')} )} { setModalState( isGroupTitle ? ModalState.EditingGroupTitle : ModalState.EditingGroupDescription ); }} theme={theme} />
{!conversation.isMe && ( <> {!isGroup && ( )} )}
{!isGroup || canEditGroupInfo ? ( } info={i18n( isGroup ? 'ConversationDetails--disappearing-messages-info--group' : 'ConversationDetails--disappearing-messages-info--direct' )} label={i18n('ConversationDetails--disappearing-messages-label')} right={ } /> ) : null} } label={i18n('showChatColorEditor')} onClick={showChatColorEditor} right={
} /> {isGroup && ( } label={i18n('ConversationDetails--notifications')} onClick={showConversationNotificationsSettings} right={ conversation.muteExpiresAt ? getMutedUntilText(conversation.muteExpiresAt, i18n) : undefined } /> )} {!isGroup && !conversation.isMe && ( <> toggleSafetyNumberModal(conversation.id)} icon={ } label={
{i18n('verifyNewNumber')}
} /> )} {isGroup && ( { setModalState(ModalState.AddingGroupMembers); }} theme={theme} /> )} {isGroup && ( {isAdmin || hasGroupLink ? ( } label={i18n('ConversationDetails--group-link')} onClick={showGroupLinkManagement} right={hasGroupLink ? i18n('on') : i18n('off')} /> ) : null} } label={i18n('ConversationDetails--requests-and-invites')} onClick={showPendingInvites} right={invitesCount} /> {isAdmin ? ( } label={i18n('permissions')} onClick={showGroupV2Permissions} /> ) : null} )} {!conversation.isMe && ( )} {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; }