signal-desktop/ts/components/MainHeader.tsx

479 lines
12 KiB
TypeScript
Raw Normal View History

// Copyright 2018-2021 Signal Messenger, LLC
2020-10-30 20:34:04 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
2019-08-09 23:12:29 +00:00
import classNames from 'classnames';
import { debounce, get } from 'lodash';
2019-10-17 18:22:07 +00:00
import { Manager, Popper, Reference } from 'react-popper';
import { createPortal } from 'react-dom';
2019-10-17 18:22:07 +00:00
import { showSettings } from '../shims/Whisper';
import { Avatar } from './Avatar';
2019-10-17 18:22:07 +00:00
import { AvatarPopup } from './AvatarPopup';
import { LocalizerType } from '../types/Util';
import { ColorType } from '../types/Colors';
import { ConversationType } from '../state/ducks/conversations';
export type PropsType = {
2019-01-14 21:49:58 +00:00
searchTerm: string;
2019-08-09 23:12:29 +00:00
searchConversationName?: string;
searchConversationId?: string;
2019-11-07 21:36:16 +00:00
startSearchCounter: number;
selectedConversation: undefined | ConversationType;
2019-01-14 21:49:58 +00:00
// To be used as an ID
ourConversationId: string;
ourUuid: string;
2019-01-14 21:49:58 +00:00
ourNumber: string;
regionCode: string;
// For display
2020-07-24 01:35:32 +00:00
phoneNumber?: string;
isMe?: boolean;
name?: string;
2020-05-27 21:37:06 +00:00
color?: ColorType;
2020-07-24 01:35:32 +00:00
isVerified?: boolean;
profileName?: string;
2020-07-24 01:35:32 +00:00
title: string;
avatarPath?: string;
2019-01-14 21:49:58 +00:00
i18n: LocalizerType;
2019-01-14 21:49:58 +00:00
updateSearchTerm: (searchTerm: string) => void;
startSearch: () => void;
searchInConversation: (id: string, name: string) => void;
searchMessages: (
2019-01-14 21:49:58 +00:00
query: string,
options: {
2019-08-09 23:12:29 +00:00
searchConversationId?: string;
2019-01-14 21:49:58 +00:00
regionCode: string;
}
) => void;
searchDiscussions: (
query: string,
options: {
ourConversationId: string;
2019-01-14 21:49:58 +00:00
ourNumber: string;
ourUuid: string;
2019-01-14 21:49:58 +00:00
noteToSelf: string;
}
) => void;
2019-08-09 23:12:29 +00:00
clearConversationSearch: () => void;
2019-01-14 21:49:58 +00:00
clearSearch: () => void;
2019-10-17 18:22:07 +00:00
showArchivedConversations: () => void;
startComposing: () => void;
};
2019-10-17 18:22:07 +00:00
type StateType = {
2019-10-17 18:22:07 +00:00
showingAvatarPopup: boolean;
popperRoot: HTMLDivElement | null;
};
2019-10-17 18:22:07 +00:00
export class MainHeader extends React.Component<PropsType, StateType> {
2019-01-14 21:49:58 +00:00
private readonly inputRef: React.RefObject<HTMLInputElement>;
2019-08-09 23:12:29 +00:00
constructor(props: PropsType) {
2019-01-14 21:49:58 +00:00
super(props);
this.inputRef = React.createRef();
2019-10-17 18:22:07 +00:00
this.state = {
showingAvatarPopup: false,
popperRoot: null,
};
}
2020-09-12 00:46:52 +00:00
public componentDidUpdate(prevProps: PropsType): void {
2019-11-07 21:36:16 +00:00
const { searchConversationId, startSearchCounter } = this.props;
2019-01-14 21:49:58 +00:00
2019-08-09 23:12:29 +00:00
// When user chooses to search in a given conversation we focus the field for them
if (
searchConversationId &&
searchConversationId !== prevProps.searchConversationId
) {
this.setFocus();
}
2019-11-07 21:36:16 +00:00
// When user chooses to start a new search, we focus the field
if (startSearchCounter !== prevProps.startSearchCounter) {
this.setSelected();
}
2019-01-14 21:49:58 +00:00
}
2020-09-12 00:46:52 +00:00
public handleOutsideClick = ({ target }: MouseEvent): void => {
2019-10-17 18:22:07 +00:00
const { popperRoot, showingAvatarPopup } = this.state;
if (
showingAvatarPopup &&
popperRoot &&
!popperRoot.contains(target as Node)
) {
this.hideAvatarPopup();
}
};
2020-09-12 00:46:52 +00:00
public showAvatarPopup = (): void => {
const popperRoot = document.createElement('div');
document.body.appendChild(popperRoot);
2019-10-17 18:22:07 +00:00
this.setState({
showingAvatarPopup: true,
popperRoot,
2019-10-17 18:22:07 +00:00
});
document.addEventListener('click', this.handleOutsideClick);
};
2020-09-12 00:46:52 +00:00
public hideAvatarPopup = (): void => {
const { popperRoot } = this.state;
2019-10-17 18:22:07 +00:00
document.removeEventListener('click', this.handleOutsideClick);
2019-10-17 18:22:07 +00:00
this.setState({
showingAvatarPopup: false,
popperRoot: null,
2019-10-17 18:22:07 +00:00
});
if (popperRoot && document.body.contains(popperRoot)) {
document.body.removeChild(popperRoot);
}
2019-10-17 18:22:07 +00:00
};
public componentDidMount(): void {
document.addEventListener('keydown', this.handleGlobalKeyDown);
}
2020-09-12 00:46:52 +00:00
public componentWillUnmount(): void {
2019-10-17 18:22:07 +00:00
const { popperRoot } = this.state;
document.removeEventListener('click', this.handleOutsideClick);
document.removeEventListener('keydown', this.handleGlobalKeyDown);
if (popperRoot && document.body.contains(popperRoot)) {
2019-10-17 18:22:07 +00:00
document.body.removeChild(popperRoot);
}
}
2020-09-12 00:46:52 +00:00
public search = debounce((searchTerm: string): void => {
2019-08-09 23:12:29 +00:00
const {
i18n,
ourConversationId,
2019-08-09 23:12:29 +00:00
ourNumber,
ourUuid,
2019-08-09 23:12:29 +00:00
regionCode,
searchDiscussions,
searchMessages,
2019-08-09 23:12:29 +00:00
searchConversationId,
} = this.props;
if (searchDiscussions && !searchConversationId) {
searchDiscussions(searchTerm, {
2019-01-14 21:49:58 +00:00
noteToSelf: i18n('noteToSelf').toLowerCase(),
ourConversationId,
2019-01-14 21:49:58 +00:00
ourNumber,
ourUuid,
});
}
if (searchMessages) {
searchMessages(searchTerm, {
searchConversationId,
2019-01-14 21:49:58 +00:00
regionCode,
});
}
}, 200);
2019-01-14 21:49:58 +00:00
2020-09-12 00:46:52 +00:00
public updateSearch = (event: React.FormEvent<HTMLInputElement>): void => {
2019-08-09 23:12:29 +00:00
const {
updateSearchTerm,
clearConversationSearch,
clearSearch,
searchConversationId,
} = this.props;
2019-01-14 21:49:58 +00:00
const searchTerm = event.currentTarget.value;
if (!searchTerm) {
2019-08-09 23:12:29 +00:00
if (searchConversationId) {
clearConversationSearch();
} else {
clearSearch();
}
2019-01-14 21:49:58 +00:00
return;
}
if (updateSearchTerm) {
updateSearchTerm(searchTerm);
}
if (searchTerm.length < 2) {
return;
}
2019-08-09 23:12:29 +00:00
this.search(searchTerm);
};
2019-01-14 21:49:58 +00:00
2020-09-12 00:46:52 +00:00
public clearSearch = (): void => {
2019-01-14 21:49:58 +00:00
const { clearSearch } = this.props;
clearSearch();
this.setFocus();
2019-08-09 23:12:29 +00:00
};
2019-01-14 21:49:58 +00:00
2020-09-12 00:46:52 +00:00
public clearConversationSearch = (): void => {
2019-08-09 23:12:29 +00:00
const { clearConversationSearch } = this.props;
2019-01-14 21:49:58 +00:00
2019-08-09 23:12:29 +00:00
clearConversationSearch();
this.setFocus();
};
public handleInputKeyDown = (
2020-09-12 00:46:52 +00:00
event: React.KeyboardEvent<HTMLInputElement>
): void => {
2019-08-09 23:12:29 +00:00
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') {
2019-08-09 23:12:29 +00:00
return;
}
if (searchConversationId && searchTerm) {
clearConversationSearch();
} else {
2019-01-14 21:49:58 +00:00
clearSearch();
}
2019-11-07 21:36:16 +00:00
event.preventDefault();
event.stopPropagation();
2019-08-09 23:12:29 +00:00
};
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();
}
};
2020-09-12 00:46:52 +00:00
public handleXButton = (): void => {
2019-08-09 23:12:29 +00:00
const {
searchConversationId,
clearConversationSearch,
clearSearch,
} = this.props;
2019-01-14 21:49:58 +00:00
2019-08-09 23:12:29 +00:00
if (searchConversationId) {
clearConversationSearch();
} else {
clearSearch();
}
this.setFocus();
};
2020-09-12 00:46:52 +00:00
public setFocus = (): void => {
2019-01-14 21:49:58 +00:00
if (this.inputRef.current) {
this.inputRef.current.focus();
}
2019-08-09 23:12:29 +00:00
};
2019-01-14 21:49:58 +00:00
2020-09-12 00:46:52 +00:00
public setSelected = (): void => {
2019-11-07 21:36:16 +00:00
if (this.inputRef.current) {
this.inputRef.current.select();
}
};
2020-09-12 00:46:52 +00:00
public render(): JSX.Element {
const {
avatarPath,
color,
2019-08-09 23:12:29 +00:00
i18n,
name,
startComposing,
phoneNumber,
profileName,
2020-07-24 01:35:32 +00:00
title,
2019-08-09 23:12:29 +00:00
searchConversationId,
searchConversationName,
searchTerm,
2019-10-17 18:22:07 +00:00
showArchivedConversations,
} = this.props;
2019-10-17 18:22:07 +00:00
const { showingAvatarPopup, popperRoot } = this.state;
2019-08-09 23:12:29 +00:00
const placeholder = searchConversationName
? i18n('searchIn', [searchConversationName])
: i18n('search');
const isSearching = Boolean(
searchConversationId || searchTerm.trim().length
);
return (
<div className="module-main-header">
2019-10-17 18:22:07 +00:00
<Manager>
<Reference>
{({ ref }) => (
<Avatar
avatarPath={avatarPath}
className="module-main-header__avatar"
2019-10-17 18:22:07 +00:00
color={color}
conversationType="direct"
i18n={i18n}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
2020-07-24 01:35:32 +00:00
title={title}
2019-10-17 18:22:07 +00:00
size={28}
innerRef={ref}
onClick={this.showAvatarPopup}
/>
)}
</Reference>
{showingAvatarPopup && popperRoot
? createPortal(
<Popper placement="bottom-end">
{({ ref, style }) => (
<AvatarPopup
innerRef={ref}
i18n={i18n}
style={style}
color={color}
conversationType="direct"
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
2020-07-24 01:35:32 +00:00
title={title}
2019-10-17 18:22:07 +00:00
avatarPath={avatarPath}
size={28}
onViewPreferences={() => {
showSettings();
this.hideAvatarPopup();
}}
onViewArchive={() => {
showArchivedConversations();
this.hideAvatarPopup();
}}
/>
)}
</Popper>,
popperRoot
)
: null}
</Manager>
2019-01-14 21:49:58 +00:00
<div className="module-main-header__search">
2019-08-09 23:12:29 +00:00
{searchConversationId ? (
2019-08-20 19:34:52 +00:00
<button
className="module-main-header__search__in-conversation-pill"
onClick={this.clearSearch}
2019-11-07 21:36:16 +00:00
tabIndex={-1}
2020-09-12 00:46:52 +00:00
type="button"
aria-label={i18n('clearSearch')}
2019-08-20 19:34:52 +00:00
>
2019-08-09 23:12:29 +00:00
<div className="module-main-header__search__in-conversation-pill__avatar-container">
<div className="module-main-header__search__in-conversation-pill__avatar" />
</div>
2019-08-20 19:34:52 +00:00
<div className="module-main-header__search__in-conversation-pill__x-button" />
</button>
2019-08-09 23:12:29 +00:00
) : (
<button
className="module-main-header__search__icon"
onClick={this.setFocus}
2019-11-07 21:36:16 +00:00
tabIndex={-1}
2020-09-12 00:46:52 +00:00
type="button"
aria-label={i18n('search')}
2019-08-09 23:12:29 +00:00
/>
)}
2019-01-14 21:49:58 +00:00
<input
type="text"
ref={this.inputRef}
2019-08-09 23:12:29 +00:00
className={classNames(
'module-main-header__search__input',
2019-08-20 19:34:52 +00:00
searchTerm
? 'module-main-header__search__input--with-text'
: null,
2019-08-09 23:12:29 +00:00
searchConversationId
? 'module-main-header__search__input--in-conversation'
: null
)}
placeholder={placeholder}
2019-01-14 21:49:58 +00:00
dir="auto"
onKeyDown={this.handleInputKeyDown}
2019-01-14 21:49:58 +00:00
value={searchTerm}
2019-08-09 23:12:29 +00:00
onChange={this.updateSearch}
2019-01-14 21:49:58 +00:00
/>
{searchTerm ? (
2019-11-07 21:36:16 +00:00
<button
tabIndex={-1}
2019-01-14 21:49:58 +00:00
className="module-main-header__search__cancel-icon"
2019-08-09 23:12:29 +00:00
onClick={this.handleXButton}
2020-09-12 00:46:52 +00:00
type="button"
aria-label={i18n('cancel')}
2019-01-14 21:49:58 +00:00
/>
) : null}
</div>
{!isSearching && (
<button
aria-label={i18n('newConversation')}
className="module-main-header__compose-icon"
onClick={startComposing}
title={i18n('newConversation')}
type="button"
/>
)}
</div>
);
}
}