// Copyright 2018-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; import classNames from 'classnames'; import { debounce, get } from 'lodash'; import { Manager, Popper, Reference } from 'react-popper'; import { createPortal } from 'react-dom'; import { showSettings } from '../shims/Whisper'; import { Avatar } from './Avatar'; import { AvatarPopup } from './AvatarPopup'; import { LocalizerType } from '../types/Util'; import { ColorType } from '../types/Colors'; import { ConversationType } from '../state/ducks/conversations'; export type PropsType = { searchTerm: string; searchConversationName?: string; searchConversationId?: string; startSearchCounter: number; selectedConversation: undefined | ConversationType; // To be used as an ID ourConversationId: string; ourUuid: string; ourNumber: string; regionCode: string; // For display phoneNumber?: string; isMe?: boolean; name?: string; color?: ColorType; isVerified?: boolean; profileName?: string; title: string; avatarPath?: string; i18n: LocalizerType; updateSearchTerm: (searchTerm: string) => void; startSearch: () => void; searchInConversation: (id: string, name: string) => void; searchMessages: ( query: string, options: { searchConversationId?: string; regionCode: string; } ) => void; searchDiscussions: ( query: string, options: { ourConversationId: string; ourNumber: string; ourUuid: string; noteToSelf: string; } ) => void; clearConversationSearch: () => void; clearSearch: () => void; showArchivedConversations: () => void; startComposing: () => void; }; type StateType = { showingAvatarPopup: boolean; popperRoot: HTMLDivElement | null; }; export class MainHeader extends React.Component { private readonly inputRef: React.RefObject; constructor(props: PropsType) { super(props); this.inputRef = React.createRef(); this.state = { showingAvatarPopup: false, popperRoot: null, }; } public componentDidUpdate(prevProps: PropsType): void { const { searchConversationId, startSearchCounter } = this.props; // When user chooses to search in a given conversation we focus the field for them if ( searchConversationId && searchConversationId !== prevProps.searchConversationId ) { this.setFocus(); } // When user chooses to start a new search, we focus the field if (startSearchCounter !== prevProps.startSearchCounter) { this.setSelected(); } } public handleOutsideClick = ({ target }: MouseEvent): void => { const { popperRoot, showingAvatarPopup } = this.state; if ( showingAvatarPopup && popperRoot && !popperRoot.contains(target as Node) ) { this.hideAvatarPopup(); } }; public showAvatarPopup = (): void => { const popperRoot = document.createElement('div'); document.body.appendChild(popperRoot); this.setState({ showingAvatarPopup: true, popperRoot, }); document.addEventListener('click', this.handleOutsideClick); }; public hideAvatarPopup = (): void => { const { popperRoot } = this.state; document.removeEventListener('click', this.handleOutsideClick); this.setState({ showingAvatarPopup: false, popperRoot: null, }); if (popperRoot && document.body.contains(popperRoot)) { document.body.removeChild(popperRoot); } }; public componentDidMount(): void { document.addEventListener('keydown', this.handleGlobalKeyDown); } public componentWillUnmount(): void { const { popperRoot } = this.state; document.removeEventListener('click', this.handleOutsideClick); document.removeEventListener('keydown', this.handleGlobalKeyDown); if (popperRoot && document.body.contains(popperRoot)) { document.body.removeChild(popperRoot); } } public search = debounce((searchTerm: string): void => { const { i18n, ourConversationId, ourNumber, ourUuid, regionCode, searchDiscussions, searchMessages, searchConversationId, } = this.props; if (searchDiscussions && !searchConversationId) { searchDiscussions(searchTerm, { noteToSelf: i18n('noteToSelf').toLowerCase(), ourConversationId, ourNumber, ourUuid, }); } if (searchMessages) { searchMessages(searchTerm, { searchConversationId, regionCode, }); } }, 200); public updateSearch = (event: React.FormEvent): void => { const { updateSearchTerm, clearConversationSearch, clearSearch, searchConversationId, } = this.props; const searchTerm = event.currentTarget.value; if (!searchTerm) { if (searchConversationId) { clearConversationSearch(); } else { clearSearch(); } return; } if (updateSearchTerm) { updateSearchTerm(searchTerm); } if (searchTerm.length < 1) { return; } this.search(searchTerm); }; public clearSearch = (): void => { const { clearSearch } = this.props; clearSearch(); this.setFocus(); }; public clearConversationSearch = (): void => { const { clearConversationSearch } = this.props; clearConversationSearch(); this.setFocus(); }; public handleInputKeyDown = ( event: React.KeyboardEvent ): void => { const { clearConversationSearch, clearSearch, searchConversationId, searchTerm, } = this.props; const { ctrlKey, metaKey, key } = event; const commandKey = get(window, 'platform') === 'darwin' && metaKey; const controlKey = get(window, 'platform') !== 'darwin' && ctrlKey; const commandOrCtrl = commandKey || controlKey; // On linux, this keyboard combination selects all text if (commandOrCtrl && key === '/') { event.preventDefault(); event.stopPropagation(); return; } if (key !== 'Escape') { return; } if (searchConversationId && searchTerm) { clearConversationSearch(); } else { clearSearch(); } event.preventDefault(); event.stopPropagation(); }; public handleGlobalKeyDown = (event: KeyboardEvent): void => { const { showingAvatarPopup } = this.state; const { i18n, selectedConversation, startSearch, searchInConversation, } = this.props; const { ctrlKey, metaKey, shiftKey, key } = event; const commandKey = get(window, 'platform') === 'darwin' && metaKey; const controlKey = get(window, 'platform') !== 'darwin' && ctrlKey; const commandOrCtrl = commandKey || controlKey; const commandAndCtrl = commandKey && ctrlKey; if (showingAvatarPopup && key === 'Escape') { this.hideAvatarPopup(); } else if ( commandOrCtrl && !commandAndCtrl && !shiftKey && (key === 'f' || key === 'F') ) { startSearch(); event.preventDefault(); event.stopPropagation(); } else if ( selectedConversation && commandOrCtrl && !commandAndCtrl && shiftKey && (key === 'f' || key === 'F') ) { const name = selectedConversation.isMe ? i18n('noteToSelf') : selectedConversation.title; searchInConversation(selectedConversation.id, name); event.preventDefault(); event.stopPropagation(); } }; public handleXButton = (): void => { const { searchConversationId, clearConversationSearch, clearSearch, } = this.props; if (searchConversationId) { clearConversationSearch(); } else { clearSearch(); } this.setFocus(); }; public setFocus = (): void => { if (this.inputRef.current) { this.inputRef.current.focus(); } }; public setSelected = (): void => { if (this.inputRef.current) { this.inputRef.current.select(); } }; public render(): JSX.Element { const { avatarPath, color, i18n, name, startComposing, phoneNumber, profileName, title, searchConversationId, searchConversationName, searchTerm, showArchivedConversations, } = this.props; const { showingAvatarPopup, popperRoot } = this.state; const placeholder = searchConversationName ? i18n('searchIn', [searchConversationName]) : i18n('search'); const isSearching = Boolean( searchConversationId || searchTerm.trim().length ); return (
{({ ref }) => ( )} {showingAvatarPopup && popperRoot ? createPortal( {({ ref, style }) => ( { showSettings(); this.hideAvatarPopup(); }} onViewArchive={() => { showArchivedConversations(); this.hideAvatarPopup(); }} /> )} , popperRoot ) : null}
{searchConversationId ? ( ) : (
{!isSearching && (
); } }