// 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 { ReadonlyDeep } from 'type-fest'; 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'; import { TimelineWarning, TimelineWarningCustomInfo, TimelineWarningLink, } from './TimelineWarning.dom.js'; import { ContactSpoofingType } from '../../util/contactSpoofing.std.js'; import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions.std.js'; import { hasUnacknowledgedCollisions } from '../../util/groupMemberNameCollisions.std.js'; import type { I18nComponentParts } from '../I18n.dom.js'; import { I18n } from '../I18n.dom.js'; import type { SmartCollidingAvatarsProps } from '../../state/smart/CollidingAvatars.dom.js'; import type { ContactSpoofingWarning, MultipleGroupMembersWithSameTitleContactSpoofingWarning, } from '../../state/selectors/timeline.preload.js'; import { tw } from '../../axo/tw.dom.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 RenderCollidingAvatars = ( props: SmartCollidingAvatarsProps ) => React.JSX.Element; export type RenderMiniPlayer = (options: { shouldFlow: boolean; }) => React.JSX.Element; export type RenderPinnedMessagesBar = () => React.JSX.Element; export type AcknowledgeGroupMemberNameCollisions = ( conversationId: string, groupNameCollisions: ReadonlyDeep ) => void; export type ReviewConversationNameCollission = () => void; export type PropsDataType = { addedByName: ContactNameData | null; badge?: BadgeType; cannotLeaveBecauseYouAreLastAdmin: boolean; conversation: MinimalConversation; conversationName: ContactNameData; hasPanelShowing?: boolean; hasStories?: HasStories; hasActiveCall?: boolean; isMissingMandatoryProfileSharing?: boolean; isSelectMode: boolean; isSignalConversation?: boolean; isSmsOnlyOrUnregistered?: boolean; outgoingCallButtonStyle: OutgoingCallButtonStyle; theme: ThemeType; contactSpoofingWarning: ContactSpoofingWarning | null; renderCollidingAvatars: RenderCollidingAvatars; shouldShowMiniPlayer: boolean; renderMiniPlayer: RenderMiniPlayer; renderPinnedMessagesBar: RenderPinnedMessagesBar; }; export type PropsActionsType = { 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; acknowledgeGroupMemberNameCollisions: AcknowledgeGroupMemberNameCollisions; reviewConversationNameCollision: ReviewConversationNameCollission; }; 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, onConversationAccept, onConversationArchive, onConversationBlock, onConversationBlockAndReportSpam, onConversationDelete, onConversationDeleteMessages, onConversationDisappearingMessagesChange, onConversationLeaveGroup, onConversationMarkUnread, onConversationMuteExpirationChange, onConversationPin, onConversationReportSpam, onConversationUnarchive, onConversationUnpin, onOutgoingAudioCall, onOutgoingVideoCall, onSearchInConversation, onSelectModeEnter, onShowMembers, onViewAllMedia, onViewConversationDetails, onViewUserStories, outgoingCallButtonStyle, theme, contactSpoofingWarning, acknowledgeGroupMemberNameCollisions, reviewConversationNameCollision, renderCollidingAvatars, shouldShowMiniPlayer, renderMiniPlayer, renderPinnedMessagesBar, }: PropsType): React.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); }} /> )} {hasLeaveGroupConfirmation && ( { setHasLeaveGroupConfirmation(false); }} onLeaveGroup={() => { setHasLeaveGroupConfirmation(false); if (!cannotLeaveBecauseYouAreLastAdmin) { onConversationLeaveGroup(); } else { setHasLeaveGroupConfirmation(false); setHasCannotLeaveGroupBecauseYouAreLastAdminAlert(true); } }} /> )} {hasCannotLeaveGroupBecauseYouAreLastAdminAlert && ( { setHasCannotLeaveGroupBecauseYouAreLastAdminAlert(false); }} /> )} { if (size.hidden) { return; } setIsNarrow(size.width < 500); }} > {measureRef => (
{!isSmsOnlyOrUnregistered && !isSignalConversation && ( )}
)}
); }); function HeaderContent({ conversation, badge, hasStories, headerRef, i18n, theme, isSignalConversation, onViewUserStories, onViewConversationDetails, }: { conversation: MinimalConversation; badge: BadgeType | null; hasStories: HasStories | null; headerRef: RefObject; i18n: LocalizerType; 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' >): React.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 ( ); } function ConversationSubheader(props: { i18n: LocalizerType; conversationId: string; contactSpoofingWarning: ContactSpoofingWarning | null; reviewConversationNameCollision: ReviewConversationNameCollission; acknowledgeGroupMemberNameCollisions: AcknowledgeGroupMemberNameCollisions; renderCollidingAvatars: RenderCollidingAvatars; shouldShowMiniPlayer: boolean; renderMiniPlayer: RenderMiniPlayer; renderPinnedMessagesBar: RenderPinnedMessagesBar; }) { const { i18n } = props; const [ hasDismissedDirectContactSpoofingWarning, setHasDismissedDirectContactSpoofingWarning, ] = useState(false); const renderableContactSpoofingWarning = getRenderableContactSpoofingWarning( props.contactSpoofingWarning, hasDismissedDirectContactSpoofingWarning ); const handleDismissDirectContactSpoofingWarning = useCallback(() => { setHasDismissedDirectContactSpoofingWarning(true); }, []); return ( <> {renderableContactSpoofingWarning != null && ( <> {renderableContactSpoofingWarning.type === ContactSpoofingType.DirectConversationWithSameTitle && ( )} {renderableContactSpoofingWarning.type === ContactSpoofingType.MultipleGroupMembersWithSameTitle && ( )} )} {props.shouldShowMiniPlayer && props.renderMiniPlayer({ shouldFlow: true })} {!props.shouldShowMiniPlayer && props.renderPinnedMessagesBar()} ); } function getRenderableContactSpoofingWarning( contactSpoofingWarning: ContactSpoofingWarning | null, hasDismissedDirectContactSpoofingWarning: boolean ): ContactSpoofingWarning | null { if (contactSpoofingWarning == null) { return null; } if ( contactSpoofingWarning.type === ContactSpoofingType.DirectConversationWithSameTitle ) { const shouldRender = !hasDismissedDirectContactSpoofingWarning; return shouldRender ? contactSpoofingWarning : null; } if ( contactSpoofingWarning.type === ContactSpoofingType.MultipleGroupMembersWithSameTitle ) { const shouldRender = hasUnacknowledgedCollisions( contactSpoofingWarning.acknowledgedGroupNameCollisions, contactSpoofingWarning.groupNameCollisions ); return shouldRender ? contactSpoofingWarning : null; } throw missingCaseError(contactSpoofingWarning); } function DirectConversationWithSameTitleWarning(props: { i18n: LocalizerType; reviewConversationNameCollision: ReviewConversationNameCollission; onDismissDirectContactSpoofingWarning: () => void; }) { const { i18n } = props; return ( ( {parts} ), }} /> ); } function MultipleGroupMembersWithSameTitleWarning(props: { i18n: LocalizerType; conversationId: string; contactSpoofingWarning: MultipleGroupMembersWithSameTitleContactSpoofingWarning; acknowledgeGroupMemberNameCollisions: AcknowledgeGroupMemberNameCollisions; reviewConversationNameCollision: ReviewConversationNameCollission; renderCollidingAvatars: RenderCollidingAvatars; }) { const { i18n, conversationId, contactSpoofingWarning, acknowledgeGroupMemberNameCollisions, reviewConversationNameCollision, renderCollidingAvatars, } = props; const { groupNameCollisions } = contactSpoofingWarning; const numberOfSharedNames = Object.keys(groupNameCollisions).length; const conversationIds = Object.values(groupNameCollisions).flat(1); const handleClose = useCallback(() => { acknowledgeGroupMemberNameCollisions(conversationId, groupNameCollisions); }, [ acknowledgeGroupMemberNameCollisions, conversationId, groupNameCollisions, ]); const reviewRequestLink = useCallback( (parts: I18nComponentParts) => { return ( {parts} ); }, [reviewConversationNameCollision] ); if (numberOfSharedNames === 1) { return ( = 2 ? ( {renderCollidingAvatars({ conversationIds })} ) : null } > ); } return ( ); }