// Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ReactNode } from 'react'; import React from 'react'; import classNames from 'classnames'; import { ContextMenu, ContextMenuTrigger, MenuItem, SubMenu, } from 'react-contextmenu'; import { createPortal } from 'react-dom'; import { DisappearingTimeDialog } from '../DisappearingTimeDialog'; import { Avatar, AvatarSize } from '../Avatar'; import { InContactsIcon } from '../InContactsIcon'; import type { LocalizerType, ThemeType } from '../../types/Util'; import type { ConversationType, PopPanelForConversationActionType, PushPanelForConversationActionType, } from '../../state/ducks/conversations'; import type { BadgeType } from '../../badges/types'; import type { HasStories } from '../../types/Stories'; import type { ViewUserStoriesActionCreatorType } from '../../state/ducks/stories'; import { StoryViewModeType } from '../../types/Stories'; import { getMuteOptions } from '../../util/getMuteOptions'; import * as expirationTimer from '../../util/expirationTimer'; import { missingCaseError } from '../../util/missingCaseError'; import { isInSystemContacts } from '../../util/isInSystemContacts'; import { isConversationMuted } from '../../util/isConversationMuted'; import { ConfirmationDialog } from '../ConfirmationDialog'; import { DurationInSeconds } from '../../util/durations'; import { useStartCallShortcuts, useKeyboardShortcuts, } from '../../hooks/useKeyboardShortcuts'; import { PanelType } from '../../types/Panels'; import { UserText } from '../UserText'; import { Alert } from '../Alert'; import { SizeObserver } from '../../hooks/useSizeObserver'; export enum OutgoingCallButtonStyle { None, JustVideo, Both, Join, } export type PropsDataType = { badge?: BadgeType; cannotLeaveBecauseYouAreLastAdmin: boolean; hasPanelShowing?: boolean; hasStories?: HasStories; isMissingMandatoryProfileSharing?: boolean; outgoingCallButtonStyle: OutgoingCallButtonStyle; isSMSOnly?: boolean; isSignalConversation?: boolean; theme: ThemeType; } & Pick< ConversationType, | 'acceptedMessageRequest' | 'announcementsOnly' | 'areWeAdmin' | 'avatarPath' | 'canChangeTimer' | 'color' | 'expireTimer' | 'groupVersion' | 'id' | 'isArchived' | 'isMe' | 'isPinned' | 'isVerified' | 'left' | 'markedUnread' | 'muteExpiresAt' | 'name' | 'phoneNumber' | 'profileName' | 'sharedGroupNames' | 'title' | 'type' | 'unblurredAvatarPath' >; export type PropsActionsType = { destroyMessages: (conversationId: string) => void; leaveGroup: (conversationId: string) => void; onArchive: (conversationId: string) => void; onMarkUnread: (conversationId: string) => void; toggleSelectMode: (on: boolean) => void; onMoveToInbox: (conversationId: string) => void; onOutgoingAudioCallInConversation: (conversationId: string) => void; onOutgoingVideoCallInConversation: (conversationId: string) => void; pushPanelForConversation: PushPanelForConversationActionType; popPanelForConversation: PopPanelForConversationActionType; searchInConversation: (conversationId: string) => void; setDisappearingMessages: ( conversationId: string, seconds: DurationInSeconds ) => void; setMuteExpiration: (conversationId: string, seconds: number) => void; setPinned: (conversationId: string, value: boolean) => void; viewUserStories: ViewUserStoriesActionCreatorType; }; export type PropsHousekeepingType = { i18n: LocalizerType; }; export type PropsType = PropsDataType & PropsActionsType & PropsHousekeepingType; enum ModalState { NothingOpen, CustomDisappearingTimeout, } type StateType = { hasDeleteMessagesConfirmation: boolean; hasLeaveGroupConfirmation: boolean; hasCannotLeaveGroupBecauseYouAreLastAdminAlert: boolean; isNarrow: boolean; modalState: ModalState; }; const TIMER_ITEM_CLASS = 'module-ConversationHeader__disappearing-timer__item'; export class ConversationHeader extends React.Component { private showMenuBound: (event: React.MouseEvent) => void; // Comes from a third-party dependency // eslint-disable-next-line @typescript-eslint/no-explicit-any private menuTriggerRef: React.RefObject; public headerRef: React.RefObject; public constructor(props: PropsType) { super(props); this.state = { hasDeleteMessagesConfirmation: false, hasLeaveGroupConfirmation: false, hasCannotLeaveGroupBecauseYouAreLastAdminAlert: false, isNarrow: false, modalState: ModalState.NothingOpen, }; this.menuTriggerRef = React.createRef(); this.headerRef = React.createRef(); this.showMenuBound = this.showMenu.bind(this); } private showMenu(event: React.MouseEvent): void { if (this.menuTriggerRef.current) { this.menuTriggerRef.current.handleContextClick(event); } } private renderHeaderInfoTitle(): ReactNode { const { name, title, type, i18n, isMe } = this.props; if (isMe) { return (
{i18n('icu:noteToSelf')}
); } return (
{isInSystemContacts({ name, type }) ? ( ) : null}
); } private renderHeaderInfoSubtitle(): ReactNode { const expirationNode = this.renderExpirationLength(); const verifiedNode = this.renderVerifiedIcon(); if (expirationNode || verifiedNode) { return (
{expirationNode} {verifiedNode}
); } return null; } private renderAvatar(onClickFallback: undefined | (() => void)): ReactNode { const { acceptedMessageRequest, avatarPath, badge, color, hasStories, id, i18n, type, isMe, phoneNumber, profileName, sharedGroupNames, theme, title, unblurredAvatarPath, viewUserStories, } = this.props; return ( { viewUserStories({ conversationId: id, storyViewMode: StoryViewModeType.User, }); } : onClickFallback } phoneNumber={phoneNumber} profileName={profileName} sharedGroupNames={sharedGroupNames} size={AvatarSize.THIRTY_TWO} // user may have stories, but we don't show that on Note to Self conversation storyRing={isMe ? undefined : hasStories} theme={theme} title={title} unblurredAvatarPath={unblurredAvatarPath} /> ); } private renderExpirationLength(): ReactNode { const { i18n, expireTimer } = this.props; if (!expireTimer) { return null; } return (
{expirationTimer.format(i18n, expireTimer)}
); } private renderVerifiedIcon(): ReactNode { const { i18n, isVerified } = this.props; if (!isVerified) { return null; } return (
{i18n('icu:verified')}
); } private renderMoreButton(triggerId: string): ReactNode { const { i18n } = this.props; return ( ); } return (
{avatar} {contents}
); } public override render(): ReactNode { const { announcementsOnly, areWeAdmin, expireTimer, hasPanelShowing, i18n, id, isSMSOnly, isSignalConversation, onOutgoingAudioCallInConversation, onOutgoingVideoCallInConversation, outgoingCallButtonStyle, setDisappearingMessages, } = this.props; if (hasPanelShowing) { return null; } const { isNarrow, modalState } = this.state; const triggerId = `conversation-${id}`; let modalNode: ReactNode; if (modalState === ModalState.NothingOpen) { modalNode = undefined; } else if (modalState === ModalState.CustomDisappearingTimeout) { modalNode = ( { this.setState({ modalState: ModalState.NothingOpen }); setDisappearingMessages(id, value); }} onClose={() => this.setState({ modalState: ModalState.NothingOpen })} /> ); } else { throw missingCaseError(modalState); } return ( <> {modalNode} {this.renderDeleteMessagesConfirmationDialog()} {this.renderLeaveGroupConfirmationDialog()} {this.renderCannotLeaveGroupBecauseYouAreLastAdminAlert()} { this.setState({ isNarrow: size.width < 500 }); }} > {measureRef => (
{this.renderHeader()} {!isSMSOnly && !isSignalConversation && ( )} {this.renderSearchButton()} {this.renderMoreButton(triggerId)} {this.renderMenu(triggerId)}
)}
); } } function OutgoingCallButtons({ announcementsOnly, areWeAdmin, i18n, id, isNarrow, onOutgoingAudioCallInConversation, onOutgoingVideoCallInConversation, outgoingCallButtonStyle, }: { isNarrow: boolean } & Pick< PropsType, | 'announcementsOnly' | 'areWeAdmin' | 'i18n' | 'id' | 'onOutgoingAudioCallInConversation' | 'onOutgoingVideoCallInConversation' | 'outgoingCallButtonStyle' >): JSX.Element | null { const videoButton = ( ); default: throw missingCaseError(outgoingCallButtonStyle); } }