// Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import classNames from 'classnames'; import type { RefObject } from 'react'; import React, { memo, useCallback, useMemo, useRef, useState } from 'react'; import type { BadgeType } from '../../badges/types.std.js'; import { useKeyboardShortcuts, useStartCallShortcuts, } from '../../hooks/useKeyboardShortcuts.dom.js'; import { SizeObserver } from '../../hooks/useSizeObserver.dom.js'; import type { ConversationTypeType } from '../../state/ducks/conversations.preload.js'; import type { HasStories } from '../../types/Stories.std.js'; import type { LocalizerType, ThemeType } from '../../types/Util.std.js'; import type { DurationInSeconds } from '../../util/durations/index.std.js'; import * as expirationTimer from '../../util/expirationTimer.std.js'; import { getMuteOptions } from '../../util/getMuteOptions.std.js'; import { isConversationMuted } from '../../util/isConversationMuted.std.js'; import { isInSystemContacts } from '../../util/isInSystemContacts.std.js'; import { missingCaseError } from '../../util/missingCaseError.std.js'; import { Alert } from '../Alert.dom.js'; import { Avatar, AvatarSize } from '../Avatar.dom.js'; import { ConfirmationDialog } from '../ConfirmationDialog.dom.js'; import { DisappearingTimeDialog } from '../DisappearingTimeDialog.dom.js'; import { InContactsIcon } from '../InContactsIcon.dom.js'; import { UserText } from '../UserText.dom.js'; import type { ContactNameData } from './ContactName.dom.js'; import { MessageRequestActionsConfirmation, MessageRequestState, } from './MessageRequestActionsConfirmation.dom.js'; import type { MinimalConversation } from '../../hooks/useMinimalConversation.std.js'; import { InAnotherCallTooltip } from './InAnotherCallTooltip.dom.js'; import { DeleteMessagesConfirmationDialog } from '../DeleteMessagesConfirmationDialog.dom.js'; import { AxoDropdownMenu } from '../../axo/AxoDropdownMenu.dom.js'; import { strictAssert } from '../../util/assert.std.js'; function HeaderInfoTitle({ name, title, type, i18n, isMe, isSignalConversation, headerRef, }: { name: string | null; title: string; type: ConversationTypeType; i18n: LocalizerType; isMe: boolean; isSignalConversation: boolean; headerRef: React.RefObject; }) { if (isSignalConversation) { return (
); } 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; hasActiveCall?: boolean; localDeleteWarningShown: boolean; isMissingMandatoryProfileSharing?: boolean; isSelectMode: boolean; isSignalConversation?: boolean; isSmsOnlyOrUnregistered?: 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; onViewAllMedia: () => void; onViewConversationDetails: () => void; onViewUserStories: () => void; }; export type PropsHousekeepingType = { i18n: LocalizerType; }; export type PropsType = PropsDataType & PropsActionsType & PropsHousekeepingType; export const ConversationHeader = memo(function ConversationHeader({ addedByName, badge, cannotLeaveBecauseYouAreLastAdmin, conversation, conversationName, hasActiveCall, hasPanelShowing, hasStories, i18n, isMissingMandatoryProfileSharing, isSelectMode, isSignalConversation, isSmsOnlyOrUnregistered, localDeleteWarningShown, onConversationAccept, onConversationArchive, onConversationBlock, onConversationBlockAndReportSpam, onConversationDelete, onConversationDeleteMessages, onConversationDisappearingMessagesChange, onConversationLeaveGroup, onConversationMarkUnread, onConversationMuteExpirationChange, onConversationPin, onConversationReportSpam, onConversationUnarchive, onConversationUnpin, onOutgoingAudioCall, onOutgoingVideoCall, onSearchInConversation, onSelectModeEnter, onShowMembers, onViewAllMedia, onViewConversationDetails, onViewUserStories, outgoingCallButtonStyle, setLocalDeleteWarningShown, sharedGroupNames, theme, }: PropsType): JSX.Element | null { // Comes from a third-party dependency 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 ); 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 => (
{!isSmsOnlyOrUnregistered && !isSignalConversation && ( )}
)}
); }); function HeaderContent({ conversation, badge, hasStories, headerRef, i18n, sharedGroupNames, theme, isSignalConversation, onViewUserStories, onViewConversationDetails, }: { conversation: MinimalConversation; badge: BadgeType | null; hasStories: HasStories | null; headerRef: RefObject; i18n: LocalizerType; sharedGroupNames: ReadonlyArray; theme: ThemeType; isSignalConversation: boolean; 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 HeaderDropdownMenuContent({ conversation, i18n, isMissingMandatoryProfileSharing, isSelectMode, isSignalConversation, onChangeDisappearingMessages, onChangeMuteExpiration, onConversationAccept, onConversationArchive, onConversationBlock, onConversationDelete, onConversationDeleteMessages, onConversationLeaveGroup, onConversationMarkUnread, onConversationPin, onConversationReportAndMaybeBlock, onConversationUnarchive, onConversationUnblock, onConversationUnpin, onSelectModeEnter, onSetupCustomDisappearingTimeout, onShowMembers, onViewAllMedia, onViewConversationDetails, }: { 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; onViewAllMedia: () => void; onViewConversationDetails: () => void; }) { 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 disappearingMessagesValue = useMemo(() => { const { expireTimer } = conversation; if (expireTimer == null) { return '0'; } if (expirationTimer.DEFAULT_DURATIONS_IN_SECONDS.includes(expireTimer)) { return `${expireTimer}`; } return 'custom'; }, [conversation]); const onDisappearingMessagesValueChange = useCallback( (value: string) => { if (value === 'custom') { return; } const seconds = Number(value); strictAssert(Number.isFinite(seconds), 'Invalid value in radio item'); onChangeDisappearingMessages(seconds as DurationInSeconds); }, [onChangeDisappearingMessages] ); if (isSelectMode) { return null; } const muteTitle = {i18n('icu:muteNotificationsTitle')}; const disappearingTitle = {i18n('icu:disappearingMessages')}; if (isSignalConversation) { const isMuted = conversation.muteExpiresAt && isConversationMuted(conversation); return ( {muteTitle} {isMuted ? ( { onChangeMuteExpiration(0); }} > {i18n('icu:unmute')} ) : ( { onChangeMuteExpiration(Number.MAX_SAFE_INTEGER); }} > {i18n('icu:muteAlways')} )} {conversation.isArchived ? ( {i18n('icu:moveConversationToInbox')} ) : ( {i18n('icu:archiveConversation')} )} {i18n('icu:deleteConversation')} ); } if (isGroup && conversation.groupVersion !== 2) { return ( {i18n('icu:showMembers')} {i18n('icu:allMediaMenuItem')} {conversation.isArchived ? ( {i18n('icu:moveConversationToInbox')} ) : ( {i18n('icu:archiveConversation')} )} {i18n('icu:deleteConversation')} ); } return ( {!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 : ( {disappearingTitle} {expirationTimer.DEFAULT_DURATIONS_IN_SECONDS.map(seconds => { return ( {expirationTimer.format(i18n, seconds, { capitalizeOff: true, })} ); })} {i18n('icu:customDisappearingTimeOption')} )} {muteTitle} {muteOptions.map(item => ( { onChangeMuteExpiration(item.value); }} > {item.name} ))} {!isGroup || hasGV2AdminEnabled ? ( {isGroup ? i18n('icu:showConversationDetails') : i18n('icu:showConversationDetails--direct')} ) : null} {i18n('icu:allMediaMenuItem')} {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:deleteConversation')} {isGroup && ( {i18n( 'icu:ConversationHeader__ContextMenu__LeaveGroupAction__title' )} )} )} ); } function OutgoingCallButtons({ conversation, hasActiveCall, i18n, isNarrow, onOutgoingAudioCall, onOutgoingVideoCall, outgoingCallButtonStyle, }: { isNarrow: boolean } & Pick< PropsType, | 'i18n' | 'conversation' | 'hasActiveCall' | 'onOutgoingAudioCall' | 'onOutgoingVideoCall' | 'outgoingCallButtonStyle' >): JSX.Element | null { const disabled = conversation.type === 'group' && conversation.announcementsOnly && !conversation.areWeAdmin; const inAnotherCall = !disabled && hasActiveCall; const videoButton = ( ); return inAnotherCall ? ( {joinButton} ) : ( joinButton ); 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 ( ); }