New AvatarPopup component

This commit is contained in:
Scott Nonnenberg 2019-10-17 11:22:07 -07:00 committed by Ken Powers
parent 195de96269
commit dd1f9b055f
19 changed files with 432 additions and 30 deletions

View file

@ -4,16 +4,22 @@ import classNames from 'classnames';
import { getInitials } from '../util/getInitials';
import { LocalizerType } from '../types/Util';
interface Props {
export interface Props {
avatarPath?: string;
color?: string;
conversationType: 'group' | 'direct';
i18n: LocalizerType;
noteToSelf?: boolean;
name?: string;
phoneNumber?: string;
profileName?: string;
size: 28 | 52 | 80;
onClick?: () => unknown;
// Matches Popper's RefHandler type
innerRef?: (ref: HTMLElement | null) => void;
i18n: LocalizerType;
}
interface State {
@ -63,9 +69,15 @@ export class Avatar extends React.Component<Props, State> {
}
public renderNoImage() {
const { conversationType, name, noteToSelf, size } = this.props;
const {
conversationType,
name,
noteToSelf,
profileName,
size,
} = this.props;
const initials = getInitials(name);
const initials = getInitials(name || profileName);
const isGroup = conversationType === 'group';
if (noteToSelf) {
@ -105,7 +117,14 @@ export class Avatar extends React.Component<Props, State> {
}
public render() {
const { avatarPath, color, size, noteToSelf } = this.props;
const {
avatarPath,
color,
innerRef,
noteToSelf,
onClick,
size,
} = this.props;
const { imageBroken } = this.state;
const hasImage = !noteToSelf && avatarPath && !imageBroken;
@ -114,14 +133,20 @@ export class Avatar extends React.Component<Props, State> {
throw new Error(`Size ${size} is not supported!`);
}
const role = onClick ? 'button' : undefined;
return (
<div
className={classNames(
'module-avatar',
`module-avatar--${size}`,
hasImage ? 'module-avatar--with-image' : 'module-avatar--no-image',
!hasImage ? `module-avatar--${color}` : null
!hasImage ? `module-avatar--${color}` : null,
onClick ? 'module-avatar--with-click' : null
)}
ref={innerRef}
role={role}
onClick={onClick}
>
{hasImage ? this.renderImage() : this.renderNoImage()}
</div>

View file

@ -0,0 +1,48 @@
### With avatar
```jsx
<util.ConversationContext theme={util.theme}>
<AvatarPopup
color="pink"
profileName="John Smith"
phoneNumber="(800) 555-0001"
avatarPath={util.gifObjectUrl}
conversationType="direct"
onViewPreferences={(...args) => console.log('onViewPreferences', args)}
onViewArchive={(...args) => console.log('onViewArchive', args)}
i18n={util.i18n}
/>
</util.ConversationContext>
```
### With no avatar
```jsx
<util.ConversationContext theme={util.theme}>
<AvatarPopup
color="green"
profileName="John Smith"
phoneNumber="(800) 555-0001"
conversationType="direct"
onViewPreferences={(...args) => console.log('onViewPreferences', args)}
onViewArchive={(...args) => console.log('onViewArchive', args)}
i18n={util.i18n}
/>
</util.ConversationContext>
```
### With empty profileName
```jsx
<util.ConversationContext theme={util.theme}>
<AvatarPopup
color="green"
profileName={null}
phoneNumber="(800) 555-0001"
conversationType="direct"
onViewPreferences={(...args) => console.log('onViewPreferences', args)}
onViewArchive={(...args) => console.log('onViewArchive', args)}
i18n={util.i18n}
/>
</util.ConversationContext>
```

View file

@ -0,0 +1,71 @@
import * as React from 'react';
import classNames from 'classnames';
import { LocalizerType } from '../types/Util';
import { Avatar, Props as AvatarProps } from './Avatar';
import { isEmpty } from 'lodash';
export type Props = {
readonly i18n: LocalizerType;
onViewPreferences: () => unknown;
onViewArchive: () => unknown;
// Matches Popper's RefHandler type
innerRef?: (ref: HTMLElement | null) => void;
style: React.CSSProperties;
} & AvatarProps;
export const AvatarPopup = (props: Props) => {
const {
i18n,
profileName,
phoneNumber,
onViewPreferences,
onViewArchive,
style,
} = props;
const hasProfileName = !isEmpty(profileName);
return (
<div style={style} className="module-avatar-popup">
<div className="module-avatar-popup__profile">
<Avatar {...props} size={52} />
<div className="module-avatar-popup__profile__text">
<div className="module-avatar-popup__profile__name">
{hasProfileName ? profileName : phoneNumber}
</div>
{hasProfileName ? (
<div className="module-avatar-popup__profile__number">
{phoneNumber}
</div>
) : null}
</div>
</div>
<hr className="module-avatar-popup__divider" />
<button className="module-avatar-popup__item" onClick={onViewPreferences}>
<div
className={classNames(
'module-avatar-popup__item__icon',
'module-avatar-popup__item__icon-settings'
)}
/>
<div className="module-avatar-popup__item__text">
{i18n('mainMenuSettings')}
</div>
</button>
<button className="module-avatar-popup__item" onClick={onViewArchive}>
<div
className={classNames(
'module-avatar-popup__item__icon',
'module-avatar-popup__item__icon-archive'
)}
/>
<div className="module-avatar-popup__item__text">
{i18n('avatarMenuViewArchive')}
</div>
</button>
</div>
);
};

View file

@ -1,8 +1,12 @@
import React from 'react';
import classNames from 'classnames';
import { debounce } 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';
export interface PropsType {
@ -42,15 +46,36 @@ export interface PropsType {
clearConversationSearch: () => void;
clearSearch: () => void;
showArchivedConversations: () => void;
}
export class MainHeader extends React.Component<PropsType> {
interface StateType {
showingAvatarPopup: boolean;
popperRoot: HTMLDivElement | null;
}
export class MainHeader extends React.Component<PropsType, StateType> {
private readonly inputRef: React.RefObject<HTMLInputElement>;
constructor(props: PropsType) {
super(props);
this.inputRef = React.createRef();
this.state = {
showingAvatarPopup: false,
popperRoot: null,
};
}
public componentDidMount() {
const popperRoot = document.createElement('div');
document.body.appendChild(popperRoot);
this.setState({
popperRoot,
});
}
public componentDidUpdate(prevProps: PropsType) {
@ -65,6 +90,50 @@ export class MainHeader extends React.Component<PropsType> {
}
}
public handleOutsideClick = ({ target }: MouseEvent) => {
const { popperRoot, showingAvatarPopup } = this.state;
if (
showingAvatarPopup &&
popperRoot &&
!popperRoot.contains(target as Node)
) {
this.hideAvatarPopup();
}
};
public handleOutsideKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
this.hideAvatarPopup();
}
};
public showAvatarPopup = () => {
this.setState({
showingAvatarPopup: true,
});
document.addEventListener('click', this.handleOutsideClick);
document.addEventListener('keydown', this.handleOutsideKeyUp);
};
public hideAvatarPopup = () => {
document.removeEventListener('click', this.handleOutsideClick);
document.removeEventListener('keydown', this.handleOutsideKeyUp);
this.setState({
showingAvatarPopup: false,
});
};
public componentWillUnmount() {
const { popperRoot } = this.state;
if (popperRoot) {
document.body.removeChild(popperRoot);
document.removeEventListener('click', this.handleOutsideClick);
document.removeEventListener('keydown', this.handleOutsideKeyUp);
}
}
// tslint:disable-next-line member-ordering
public search = debounce((searchTerm: string) => {
const {
@ -177,6 +246,7 @@ export class MainHeader extends React.Component<PropsType> {
}
};
// tslint:disable-next-line:max-func-body-length
public render() {
const {
avatarPath,
@ -188,7 +258,9 @@ export class MainHeader extends React.Component<PropsType> {
searchConversationId,
searchConversationName,
searchTerm,
showArchivedConversations,
} = this.props;
const { showingAvatarPopup, popperRoot } = this.state;
const placeholder = searchConversationName
? i18n('searchIn', [searchConversationName])
@ -196,16 +268,53 @@ export class MainHeader extends React.Component<PropsType> {
return (
<div className="module-main-header">
<Avatar
avatarPath={avatarPath}
color={color}
conversationType="direct"
i18n={i18n}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
size={28}
/>
<Manager>
<Reference>
{({ ref }) => (
<Avatar
avatarPath={avatarPath}
color={color}
conversationType="direct"
i18n={i18n}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
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}
avatarPath={avatarPath}
size={28}
onViewPreferences={() => {
showSettings();
this.hideAvatarPopup();
}}
onViewArchive={() => {
showArchivedConversations();
this.hideAvatarPopup();
}}
/>
)}
</Popper>,
popperRoot
)
: null}
</Manager>
<div className="module-main-header__search">
{searchConversationId ? (
<button