New AvatarPopup component
This commit is contained in:
parent
195de96269
commit
dd1f9b055f
19 changed files with 432 additions and 30 deletions
|
@ -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>
|
||||
|
|
48
ts/components/AvatarPopup.md
Normal file
48
ts/components/AvatarPopup.md
Normal 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>
|
||||
```
|
71
ts/components/AvatarPopup.tsx
Normal file
71
ts/components/AvatarPopup.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue