// Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import classNames from 'classnames'; import type { ReactNode, RefObject } from 'react'; import React, { memo, useRef, useState } from 'react'; import { ContextMenu, ContextMenuTrigger, MenuItem, SubMenu, } from 'react-contextmenu'; import { createPortal } from 'react-dom'; import type { BadgeType } from '../../badges/types'; import { useKeyboardShortcuts, useStartCallShortcuts, } from '../../hooks/useKeyboardShortcuts'; import { SizeObserver } from '../../hooks/useSizeObserver'; import type { ConversationTypeType } from '../../state/ducks/conversations'; import type { HasStories } from '../../types/Stories'; import type { LocalizerType, ThemeType } from '../../types/Util'; import { DurationInSeconds } from '../../util/durations'; import * as expirationTimer from '../../util/expirationTimer'; import { getMuteOptions } from '../../util/getMuteOptions'; import { isConversationMuted } from '../../util/isConversationMuted'; import { isInSystemContacts } from '../../util/isInSystemContacts'; import { missingCaseError } from '../../util/missingCaseError'; import { Alert } from '../Alert'; import { Avatar, AvatarSize } from '../Avatar'; import { ConfirmationDialog } from '../ConfirmationDialog'; import { DisappearingTimeDialog } from '../DisappearingTimeDialog'; import { InContactsIcon } from '../InContactsIcon'; import { UserText } from '../UserText'; import type { ContactNameData } from './ContactName'; import { MessageRequestActionsConfirmation, MessageRequestState, } from './MessageRequestActionsConfirmation'; import type { MinimalConversation } from '../../hooks/useMinimalConversation'; import { LocalDeleteWarningModal } from '../LocalDeleteWarningModal'; function HeaderInfoTitle({ name, title, type, i18n, isMe, headerRef, }: { name: string | null; title: string; type: ConversationTypeType; i18n: LocalizerType; isMe: boolean; headerRef: React.RefObject; }) { if (isMe) { return (
{i18n('icu:noteToSelf')}
); } return (
{isInSystemContacts({ name: name ?? undefined, type }) ? ( ) : null}
); } export enum OutgoingCallButtonStyle { None, JustVideo, Both, Join, } export type PropsDataType = { addedByName: ContactNameData | null; badge?: BadgeType; cannotLeaveBecauseYouAreLastAdmin: boolean; conversation: MinimalConversation; conversationName: ContactNameData; hasPanelShowing?: boolean; hasStories?: HasStories; localDeleteWarningShown: boolean; isDeleteSyncSendEnabled: boolean; isMissingMandatoryProfileSharing?: boolean; isSelectMode: boolean; isSignalConversation?: boolean; isSMSOnly?: boolean; outgoingCallButtonStyle: OutgoingCallButtonStyle; sharedGroupNames: ReadonlyArray; theme: ThemeType; }; export type PropsActionsType = { setLocalDeleteWarningShown: () => void; onConversationAccept: () => void; onConversationArchive: () => void; onConversationBlock: () => void; onConversationBlockAndReportSpam: () => void; onConversationDelete: () => void; onConversationDeleteMessages: () => void; onConversationDisappearingMessagesChange: ( seconds: DurationInSeconds ) => void; onConversationLeaveGroup: () => void; onConversationMarkUnread: () => void; onConversationMuteExpirationChange: (seconds: number) => void; onConversationPin: () => void; onConversationUnpin: () => void; onConversationReportSpam: () => void; onConversationUnarchive: () => void; onOutgoingAudioCall: () => void; onOutgoingVideoCall: () => void; onSearchInConversation: () => void; onSelectModeEnter: () => void; onShowMembers: () => void; onViewConversationDetails: () => void; onViewRecentMedia: () => void; onViewUserStories: () => void; }; export type PropsHousekeepingType = { i18n: LocalizerType; }; export type PropsType = PropsDataType & PropsActionsType & PropsHousekeepingType; const TIMER_ITEM_CLASS = 'module-ConversationHeader__disappearing-timer__item'; export const ConversationHeader = memo(function ConversationHeader({ addedByName, badge, cannotLeaveBecauseYouAreLastAdmin, conversation, conversationName, hasPanelShowing, hasStories, i18n, isDeleteSyncSendEnabled, isMissingMandatoryProfileSharing, isSelectMode, isSignalConversation, isSMSOnly, localDeleteWarningShown, onConversationAccept, onConversationArchive, onConversationBlock, onConversationBlockAndReportSpam, onConversationDelete, onConversationDeleteMessages, onConversationDisappearingMessagesChange, onConversationLeaveGroup, onConversationMarkUnread, onConversationMuteExpirationChange, onConversationPin, onConversationReportSpam, onConversationUnarchive, onConversationUnpin, onOutgoingAudioCall, onOutgoingVideoCall, onSearchInConversation, onSelectModeEnter, onShowMembers, onViewConversationDetails, onViewRecentMedia, onViewUserStories, outgoingCallButtonStyle, setLocalDeleteWarningShown, sharedGroupNames, theme, }: PropsType): JSX.Element | null { // Comes from a third-party dependency // eslint-disable-next-line @typescript-eslint/no-explicit-any const menuTriggerRef = useRef(null); const headerRef = useRef(null); const [ hasCustomDisappearingTimeoutModal, setHasCustomDisappearingTimeoutModal, ] = useState(false); const [hasDeleteMessagesConfirmation, setHasDeleteMessagesConfirmation] = useState(false); const [hasLeaveGroupConfirmation, setHasLeaveGroupConfirmation] = useState(false); const [ hasCannotLeaveGroupBecauseYouAreLastAdminAlert, setHasCannotLeaveGroupBecauseYouAreLastAdminAlert, ] = useState(false); const [isNarrow, setIsNarrow] = useState(false); const [messageRequestState, setMessageRequestState] = useState( MessageRequestState.default ); const triggerId = `conversation-${conversation.id}`; if (hasPanelShowing) { return null; } return ( <> {hasCustomDisappearingTimeoutModal && ( { setHasCustomDisappearingTimeoutModal(false); onConversationDisappearingMessagesChange(value); }} onClose={() => { setHasCustomDisappearingTimeoutModal(false); }} /> )} {hasDeleteMessagesConfirmation && ( { setHasDeleteMessagesConfirmation(false); onConversationDeleteMessages(); }} onClose={() => { setHasDeleteMessagesConfirmation(false); }} setLocalDeleteWarningShown={setLocalDeleteWarningShown} /> )} {hasLeaveGroupConfirmation && ( { setHasLeaveGroupConfirmation(false); }} onLeaveGroup={() => { setHasLeaveGroupConfirmation(false); if (!cannotLeaveBecauseYouAreLastAdmin) { onConversationLeaveGroup(); } else { setHasLeaveGroupConfirmation(false); setHasCannotLeaveGroupBecauseYouAreLastAdminAlert(true); } }} /> )} {hasCannotLeaveGroupBecauseYouAreLastAdminAlert && ( { setHasCannotLeaveGroupBecauseYouAreLastAdminAlert(false); }} /> )} { setIsNarrow(size.width < 500); }} > {measureRef => (
{!isSMSOnly && !isSignalConversation && ( )}
)}
); }); function HeaderContent({ conversation, badge, hasStories, headerRef, i18n, sharedGroupNames, theme, onViewUserStories, onViewConversationDetails, }: { conversation: MinimalConversation; badge: BadgeType | null; hasStories: HasStories | null; headerRef: RefObject; i18n: LocalizerType; sharedGroupNames: ReadonlyArray; theme: ThemeType; onViewUserStories: () => void; onViewConversationDetails: () => void; }) { let onClick: undefined | (() => void); const { type } = conversation; switch (type) { case 'direct': onClick = onViewConversationDetails; break; case 'group': { const hasGV2AdminEnabled = conversation.groupVersion === 2; onClick = hasGV2AdminEnabled ? onViewConversationDetails : undefined; break; } default: throw missingCaseError(type); } const avatar = ( ); const contents = (
{(conversation.expireTimer != null || conversation.isVerified) && (
{conversation.expireTimer != null && conversation.expireTimer !== 0 && (
{expirationTimer.format(i18n, conversation.expireTimer)}
)} {conversation.isVerified && (
{i18n('icu:verified')}
)}
)}
); if (onClick) { return (
{avatar}
); } return (
{avatar} {contents}
); } function HeaderMenu({ conversation, i18n, isMissingMandatoryProfileSharing, isSelectMode, isSignalConversation, onChangeDisappearingMessages, onChangeMuteExpiration, onConversationAccept, onConversationArchive, onConversationBlock, onConversationDelete, onConversationDeleteMessages, onConversationLeaveGroup, onConversationMarkUnread, onConversationPin, onConversationReportAndMaybeBlock, onConversationUnarchive, onConversationUnblock, onConversationUnpin, onSelectModeEnter, onSetupCustomDisappearingTimeout, onShowMembers, onViewRecentMedia, onViewConversationDetails, triggerId, }: { conversation: MinimalConversation; i18n: LocalizerType; isMissingMandatoryProfileSharing: boolean; isSelectMode: boolean; isSignalConversation: boolean; onChangeDisappearingMessages: (seconds: DurationInSeconds) => void; onChangeMuteExpiration: (seconds: number) => void; onConversationAccept: () => void; onConversationArchive: () => void; onConversationBlock: () => void; onConversationDelete: () => void; onConversationDeleteMessages: () => void; onConversationLeaveGroup: () => void; onConversationMarkUnread: () => void; onConversationPin: () => void; onConversationReportAndMaybeBlock: () => void; onConversationUnarchive: () => void; onConversationUnblock: () => void; onConversationUnpin: () => void; onSelectModeEnter: () => void; onSetupCustomDisappearingTimeout: () => void; onShowMembers: () => void; onViewRecentMedia: () => void; onViewConversationDetails: () => void; triggerId: string; }) { const isRTL = i18n.getLocaleDirection() === 'rtl'; const muteOptions = getMuteOptions(conversation.muteExpiresAt, i18n); const isGroup = conversation.type === 'group'; const disableTimerChanges = Boolean( !conversation.canChangeTimer || !conversation.acceptedMessageRequest || conversation.left || isMissingMandatoryProfileSharing ); const hasGV2AdminEnabled = isGroup && conversation.groupVersion === 2; const isActiveExpireTimer = (value: number): boolean => { if (!conversation.expireTimer) { return value === 0; } // Custom time... if (value === -1) { return !expirationTimer.DEFAULT_DURATIONS_SET.has( conversation.expireTimer ); } return value === conversation.expireTimer; }; if (isSelectMode) { return null; } const muteTitle = {i18n('icu:muteNotificationsTitle')}; const disappearingTitle = {i18n('icu:disappearingMessages')}; if (isSignalConversation) { const isMuted = conversation.muteExpiresAt && isConversationMuted(conversation); return ( {isMuted ? ( { onChangeMuteExpiration(0); }} > {i18n('icu:unmute')} ) : ( { onChangeMuteExpiration(Number.MAX_SAFE_INTEGER); }} > {i18n('icu:muteAlways')} )} ); } if (isGroup && conversation.groupVersion !== 2) { return ( {i18n('icu:showMembers')} {i18n('icu:viewRecentMedia')} {conversation.isArchived ? ( {i18n('icu:moveConversationToInbox')} ) : ( {i18n('icu:archiveConversation')} )} {i18n('icu:deleteMessagesInConversation')} ); } const expireDurations: ReadonlyArray = [ ...expirationTimer.DEFAULT_DURATIONS_IN_SECONDS, DurationInSeconds.fromSeconds(-1), ].map(seconds => { let text: string; if (seconds === -1) { text = i18n('icu:customDisappearingTimeOption'); } else { text = expirationTimer.format(i18n, seconds, { capitalizeOff: true, }); } const onDurationClick = () => { if (seconds === -1) { onSetupCustomDisappearingTimeout(); } else { onChangeDisappearingMessages(seconds); } }; return (
{text}
); }); return createPortal( {!conversation.acceptedMessageRequest && ( <> {!conversation.isBlocked && ( {i18n('icu:ConversationHeader__MenuItem--Block')} )} {conversation.isBlocked && ( {i18n('icu:ConversationHeader__MenuItem--Unblock')} )} {!conversation.isBlocked && ( {i18n('icu:ConversationHeader__MenuItem--Accept')} )} {i18n('icu:ConversationHeader__MenuItem--ReportSpam')} {i18n('icu:ConversationHeader__MenuItem--DeleteChat')} )} {conversation.acceptedMessageRequest && ( <> {disableTimerChanges ? null : ( {expireDurations} )} {muteOptions.map(item => ( { onChangeMuteExpiration(item.value); }} > {item.name} ))} {!isGroup || hasGV2AdminEnabled ? ( {isGroup ? i18n('icu:showConversationDetails') : i18n('icu:showConversationDetails--direct')} ) : null} {i18n('icu:viewRecentMedia')} {i18n('icu:ConversationHeader__menu__selectMessages')} {!conversation.markedUnread ? ( {i18n('icu:markUnread')} ) : null} {conversation.isPinned ? ( {i18n('icu:unpinConversation')} ) : ( {i18n('icu:pinConversation')} )} {conversation.isArchived ? ( {i18n('icu:moveConversationToInbox')} ) : ( {i18n('icu:archiveConversation')} )} {!conversation.isBlocked && ( {i18n('icu:ConversationHeader__MenuItem--Block')} )} {conversation.isBlocked && ( {i18n('icu:ConversationHeader__MenuItem--Unblock')} )} {i18n('icu:deleteMessagesInConversation')} {isGroup && ( {i18n( 'icu:ConversationHeader__ContextMenu__LeaveGroupAction__title' )} )} )} , document.body ); } function OutgoingCallButtons({ conversation, i18n, isNarrow, onOutgoingAudioCall, onOutgoingVideoCall, outgoingCallButtonStyle, }: { isNarrow: boolean } & Pick< PropsType, | 'i18n' | 'conversation' | 'onOutgoingAudioCall' | 'onOutgoingVideoCall' | 'outgoingCallButtonStyle' >): JSX.Element | null { const videoButton = ( ); default: throw missingCaseError(outgoingCallButtonStyle); } } function LeaveGroupConfirmationDialog({ cannotLeaveBecauseYouAreLastAdmin, i18n, onLeaveGroup, onClose, }: { cannotLeaveBecauseYouAreLastAdmin: boolean; i18n: LocalizerType; onLeaveGroup: () => void; onClose: () => void; }) { return ( {i18n('icu:ConversationHeader__LeaveGroupConfirmation__description')} ); } function CannotLeaveGroupBecauseYouAreLastAdminAlert({ i18n, onClose, }: { i18n: LocalizerType; onClose: () => void; }) { return ( ); } function DeleteMessagesConfirmationDialog({ isDeleteSyncSendEnabled, i18n, localDeleteWarningShown, onDestroyMessages, onClose, setLocalDeleteWarningShown, }: { isDeleteSyncSendEnabled: boolean; i18n: LocalizerType; localDeleteWarningShown: boolean; onDestroyMessages: () => void; onClose: () => void; setLocalDeleteWarningShown: () => void; }) { if (!localDeleteWarningShown && isDeleteSyncSendEnabled) { return ( ); } return ( {i18n( 'icu:ConversationHeader__DeleteMessagesInConversationConfirmation__description' )} ); }