Render incoming Reactions

This commit is contained in:
Ken Powers 2020-01-17 17:23:19 -05:00 committed by Scott Nonnenberg
parent b113eb19f0
commit 6cc0f2abce
25 changed files with 1411 additions and 134 deletions

View file

@ -12,7 +12,7 @@ export interface Props {
name?: string;
phoneNumber?: string;
profileName?: string;
size: 28 | 52 | 80;
size: 28 | 32 | 52 | 80;
onClick?: () => unknown;
@ -143,7 +143,7 @@ export class Avatar extends React.Component<Props, State> {
const hasImage = !noteToSelf && avatarPath && !imageBroken;
if (size !== 28 && size !== 52 && size !== 80) {
if (![28, 32, 52, 80].includes(size)) {
throw new Error(`Size ${size} is not supported!`);
}

View file

@ -1,10 +1,12 @@
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';
import { Avatar, Props as AvatarProps } from './Avatar';
import { useRestoreFocus } from './hooks';
import { LocalizerType } from '../types/Util';
export type Props = {
readonly i18n: LocalizerType;
@ -32,18 +34,7 @@ export const AvatarPopup = (props: Props) => {
// Note: mechanisms to dismiss this view are all in its host, MainHeader
// Focus first button after initial render, restore focus on teardown
React.useEffect(() => {
const lastFocused = document.activeElement as any;
if (focusRef.current) {
focusRef.current.focus();
}
return () => {
if (lastFocused && lastFocused.focus) {
lastFocused.focus();
}
};
}, []);
useRestoreFocus(focusRef);
return (
<div style={style} className="module-avatar-popup">

View file

@ -1,6 +1,5 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import { createSelector } from 'reselect';
import {
CompositeDecorator,
ContentBlock,
@ -16,7 +15,7 @@ import {
} from 'draft-js';
import Measure, { ContentRect } from 'react-measure';
import { Manager, Popper, Reference } from 'react-popper';
import { get, head, isFunction, noop, trimEnd } from 'lodash';
import { get, head, noop, trimEnd } from 'lodash';
import classNames from 'classnames';
import emojiRegex from 'emoji-regex';
import { Emoji } from './emoji/Emoji';
@ -28,6 +27,7 @@ import {
search,
} from './emoji/lib';
import { LocalizerType } from '../types/Util';
import { mergeRefs } from './_util';
const MAX_LENGTH = 64 * 1024;
const colonsRegex = /(?:^|\s):[a-z0-9-_+]+:?/gi;
@ -188,20 +188,6 @@ const compositeDecorator = new CompositeDecorator([
},
]);
// A selector which combines multiple react refs into a single, referentially-equal functional ref.
const combineRefs = createSelector(
(r1: React.Ref<HTMLDivElement>) => r1,
(_r1: any, r2: React.Ref<HTMLDivElement>) => r2,
(_r1: any, _r2: any, r3: React.MutableRefObject<HTMLDivElement>) => r3,
(r1, r2, r3) => (el: HTMLDivElement) => {
if (isFunction(r1) && isFunction(r2)) {
r1(el);
r2(el);
}
r3.current = el;
}
);
const getInitialEditorState = (startingText?: string) => {
if (!startingText) {
return EditorState.createEmpty(compositeDecorator);
@ -771,7 +757,7 @@ export const CompositionInput = ({
{({ measureRef }) => (
<div
className="module-composition-input__input"
ref={combineRefs(popperRef, measureRef, rootElRef)}
ref={mergeRefs(popperRef, measureRef, rootElRef)}
>
<div
className={classNames(

View file

@ -1,5 +1,6 @@
import * as React from 'react';
import classNames from 'classnames';
import { useRestoreFocus } from './hooks';
import { LocalizerType } from '../types/Util';
export type Props = {
@ -172,18 +173,7 @@ export const ShortcutGuide = (props: Props) => {
const isMacOS = platform === 'darwin';
// Restore focus on teardown
React.useEffect(() => {
const lastFocused = document.activeElement as any;
if (focusRef.current) {
focusRef.current.focus();
}
return () => {
if (lastFocused && lastFocused.focus) {
lastFocused.focus();
}
};
}, []);
useRestoreFocus(focusRef);
return (
<div className="module-shortcut-guide">

View file

@ -1,5 +1,21 @@
// A separate file so this doesn't get picked up by StyleGuidist over real components
import { Ref } from 'react';
import { isFunction } from 'lodash';
export function cleanId(id: string): string {
return id.replace(/[^\u0020-\u007e\u00a0-\u00ff]/g, '_');
}
export function mergeRefs<T>(...refs: Array<Ref<T>>) {
return (t: T) => {
refs.forEach(r => {
if (isFunction(r)) {
r(t);
} else if (r) {
// @ts-ignore: React's typings for ref objects is annoying
r.current = t;
}
});
};
}

View file

@ -447,6 +447,374 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean
</util.ConversationContext>
```
### Reactions
#### One Reaction
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios} mode={util.mode}>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
authorColor="red"
timestamp={Date.now()}
text="I'd like to order one large phone with extra phones please. cell phone, no no no rotary... and payphone on half."
i18n={util.i18n}
reactions={[
{
emoji: '👍',
from: { id: '+14155552671', name: 'Amelia Briggs' },
timestamp: 1,
},
]}
/>
</div>
<div className="module-message-container">
<Message
direction="outgoing"
status="delivered"
authorColor="red"
timestamp={Date.now()}
text="I'd like to order one large phone with extra phones please. cell phone, no no no rotary... and payphone on half."
i18n={util.i18n}
reactions={[
{
emoji: '👍',
from: { id: '+14155552671', name: 'Amelia Briggs' },
timestamp: 1,
},
]}
/>
</div>
</util.ConversationContext>
```
#### One Reaction - Ours
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios} mode={util.mode}>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
authorColor="red"
timestamp={Date.now()}
text="I'd like to order one large phone with extra phones please. cell phone, no no no rotary... and payphone on half."
i18n={util.i18n}
reactions={[
{
emoji: '👍',
from: { id: '+14155552671', isMe: true, name: 'Amelia Briggs' },
timestamp: 1,
},
]}
/>
</div>
<div className="module-message-container">
<Message
direction="outgoing"
status="delivered"
authorColor="red"
timestamp={Date.now()}
text="I'd like to order one large phone with extra phones please. cell phone, no no no rotary... and payphone on half."
i18n={util.i18n}
reactions={[
{
emoji: '👍',
from: { id: '+14155552671', isMe: true, name: 'Amelia Briggs' },
timestamp: 1,
},
]}
/>
</div>
</util.ConversationContext>
```
#### Multiple reactions, ordered by most common then most recent
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios} mode={util.mode}>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
authorColor="red"
timestamp={Date.now()}
text="I'd like to order one large phone with extra phones please. cell phone, no no no rotary... and payphone on half."
i18n={util.i18n}
reactions={[
{
emoji: '👍',
from: { id: '+14155552671', name: 'Amelia Briggs' },
timestamp: 1,
},
{
emoji: '👍',
from: { id: '+14155552671', name: 'Joel Ferrari' },
timestamp: 1,
},
{
emoji: '😡',
from: { id: '+14155552671', name: 'Adam Burrell' },
timestamp: 1,
},
{
emoji: '😮',
from: { id: '+14155552671', name: 'Rick Owens' },
timestamp: 2,
},
]}
/>
</div>
<div className="module-message-container">
<Message
direction="outgoing"
status="delivered"
authorColor="red"
timestamp={Date.now()}
text="I'd like to order one large phone with extra phones please. cell phone, no no no rotary... and payphone on half."
i18n={util.i18n}
reactions={[
{
emoji: '👍',
from: { id: '+14155552671', name: 'Amelia Briggs' },
timestamp: 1,
},
{
emoji: '👍',
from: { id: '+14155552671', name: 'Joel Ferrari' },
timestamp: 1,
},
{
emoji: '😡',
from: { id: '+14155552671', name: 'Adam Burrell' },
timestamp: 1,
},
{
emoji: '😮',
from: { id: '+14155552671', name: 'Rick Owens' },
timestamp: 2,
},
]}
/>
</div>
</util.ConversationContext>
```
#### Multiple reactions, ours is most recent/common
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios} mode={util.mode}>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
authorColor="red"
timestamp={Date.now()}
text="I'd like to order one large phone with extra phones please. cell phone, no no no rotary... and payphone on half."
i18n={util.i18n}
reactions={[
{
emoji: '👍',
from: { id: '+14155552671', isMe: true, name: 'Amelia Briggs' },
timestamp: 1,
},
{
emoji: '👍',
from: { id: '+14155552671', name: 'Joel Ferrari' },
timestamp: 1,
},
{
emoji: '😡',
from: { id: '+14155552671', name: 'Adam Burrell' },
timestamp: 1,
},
{
emoji: '😮',
from: { id: '+14155552671', name: 'Rick Owens' },
timestamp: 2,
},
]}
/>
</div>
<div className="module-message-container">
<Message
direction="outgoing"
status="delivered"
authorColor="red"
timestamp={Date.now()}
text="I'd like to order one large phone with extra phones please. cell phone, no no no rotary... and payphone on half."
i18n={util.i18n}
reactions={[
{
emoji: '👍',
from: { id: '+14155552671', name: 'Amelia Briggs' },
timestamp: 1,
},
{
emoji: '👍',
from: { id: '+14155552671', isMe: true, name: 'Joel Ferrari' },
timestamp: 1,
},
{
emoji: '😡',
from: { id: '+14155552671', name: 'Adam Burrell' },
timestamp: 1,
},
{
emoji: '😮',
from: { id: '+14155552671', name: 'Rick Owens' },
timestamp: 2,
},
]}
/>
</div>
</util.ConversationContext>
```
#### Multiple reactions, ours not on top
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios} mode={util.mode}>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
authorColor="red"
timestamp={Date.now()}
text="I'd like to order one large phone with extra phones please. cell phone, no no no rotary... and payphone on half."
i18n={util.i18n}
reactions={[
{
emoji: '👍',
from: { id: '+14155552671', name: 'Amelia Briggs' },
timestamp: 1,
},
{
emoji: '👍',
from: { id: '+14155552671', name: 'Joel Ferrari' },
timestamp: 1,
},
{
emoji: '😡',
from: { id: '+14155552671', name: 'Adam Burrell' },
timestamp: 1,
},
{
emoji: '😮',
from: { id: '+14155552671', isMe: true, name: 'Rick Owens' },
timestamp: 2,
},
]}
/>
</div>
<div className="module-message-container">
<Message
direction="outgoing"
status="delivered"
authorColor="red"
timestamp={Date.now()}
text="I'd like to order one large phone with extra phones please. cell phone, no no no rotary... and payphone on half."
i18n={util.i18n}
reactions={[
{
emoji: '👍',
from: { id: '+14155552671', name: 'Amelia Briggs' },
timestamp: 1,
},
{
emoji: '👍',
from: { id: '+14155552671', name: 'Joel Ferrari' },
timestamp: 1,
},
{
emoji: '😡',
from: { id: '+14155552671', name: 'Adam Burrell' },
timestamp: 1,
},
{
emoji: '😮',
from: { id: '+14155552671', isMe: true, name: 'Rick Owens' },
timestamp: 2,
},
]}
/>
</div>
</util.ConversationContext>
```
#### Small message
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios} mode={util.mode}>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
authorColor="red"
timestamp={Date.now()}
text="Burgertime!"
i18n={util.i18n}
reactions={[
{
emoji: '👍',
from: { id: '+14155552671', name: 'Amelia Briggs' },
timestamp: 1,
},
{
emoji: '👍',
from: { id: '+14155552671', name: 'Joel Ferrari' },
timestamp: 1,
},
{
emoji: '😡',
from: { id: '+14155552671', name: 'Adam Burrell' },
timestamp: 1,
},
{
emoji: '😮',
from: { id: '+14155552671', name: 'Rick Owens' },
timestamp: 2,
},
]}
/>
</div>
<div className="module-message-container">
<Message
direction="outgoing"
status="delivered"
authorColor="red"
timestamp={Date.now()}
text="Burgertime!"
i18n={util.i18n}
reactions={[
{
emoji: '👍',
from: { id: '+14155552671', name: 'Amelia Briggs' },
timestamp: 1,
},
{
emoji: '👍',
from: { id: '+14155552671', name: 'Joel Ferrari' },
timestamp: 1,
},
{
emoji: '😡',
from: { id: '+14155552671', name: 'Adam Burrell' },
timestamp: 1,
},
{
emoji: '😮',
from: { id: '+14155552671', name: 'Rick Owens' },
timestamp: 2,
},
]}
/>
</div>
</util.ConversationContext>
```
### Long data
```jsx

View file

@ -1,6 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom';
import ReactDOM, { createPortal } from 'react-dom';
import classNames from 'classnames';
import Measure from 'react-measure';
import { clamp, groupBy, orderBy, take } from 'lodash';
import { Manager, Popper, Reference } from 'react-popper';
import { Avatar } from '../Avatar';
import { Spinner } from '../Spinner';
@ -12,6 +15,11 @@ import { Timestamp } from './Timestamp';
import { ContactName } from './ContactName';
import { Quote, QuotedAttachmentType } from './Quote';
import { EmbeddedContact } from './EmbeddedContact';
import {
OwnProps as ReactionViewerProps,
ReactionViewer,
} from './ReactionViewer';
import { Emoji } from '../emoji/Emoji';
import {
canDisplayImage,
@ -31,6 +39,7 @@ import { ContactType } from '../../types/Contact';
import { getIncrement } from '../../util/timer';
import { isFileDangerous } from '../../util/isFileDangerous';
import { ColorType, LocalizerType } from '../../types/Util';
import { mergeRefs } from '../_util';
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
interface Trigger {
@ -92,6 +101,8 @@ export type PropsData = {
expirationLength?: number;
expirationTimestamp?: number;
reactions?: ReactionViewerProps['reactions'];
};
type PropsHousekeeping = {
@ -143,6 +154,9 @@ interface State {
isSelected: boolean;
prevSelectedCounter: number;
reactionsHeight: number;
reactionViewerRoot: HTMLDivElement | null;
}
const EXPIRATION_CHECK_MINIMUM = 2000;
@ -150,8 +164,11 @@ const EXPIRED_DELAY = 600;
export class Message extends React.PureComponent<Props, State> {
public menuTriggerRef: Trigger | undefined;
public focusRef: React.RefObject<HTMLDivElement> = React.createRef();
public audioRef: React.RefObject<HTMLAudioElement> = React.createRef();
public focusRef: React.RefObject<HTMLDivElement> = React.createRef();
public reactionsContainerRef: React.RefObject<
HTMLDivElement
> = React.createRef();
public expirationCheckInterval: any;
public expiredTimeout: any;
@ -167,6 +184,9 @@ export class Message extends React.PureComponent<Props, State> {
isSelected: props.isSelected,
prevSelectedCounter: props.isSelectedCounter,
reactionsHeight: 0,
reactionViewerRoot: null,
};
}
@ -271,6 +291,7 @@ export class Message extends React.PureComponent<Props, State> {
if (this.expiredTimeout) {
clearTimeout(this.expiredTimeout);
}
this.toggleReactionViewer(true);
}
public componentDidUpdate(prevProps: Props) {
@ -945,6 +966,9 @@ export class Message extends React.PureComponent<Props, State> {
return null;
}
const { reactions } = this.props;
const hasReactions = reactions && reactions.length > 0;
const multipleAttachments = attachments && attachments.length > 1;
const firstAttachment = attachments && attachments[0];
@ -1003,7 +1027,8 @@ export class Message extends React.PureComponent<Props, State> {
<div
className={classNames(
'module-message__buttons',
`module-message__buttons--${direction}`
`module-message__buttons--${direction}`,
hasReactions ? 'module-message__buttons--has-reactions' : null
)}
>
{first}
@ -1289,6 +1314,165 @@ export class Message extends React.PureComponent<Props, State> {
);
}
public toggleReactionViewer = (onlyRemove = false) => {
this.setState(({ reactionViewerRoot }) => {
if (reactionViewerRoot) {
document.body.removeChild(reactionViewerRoot);
document.body.removeEventListener(
'click',
this.handleClickOutside,
true
);
return { reactionViewerRoot: null };
}
if (!onlyRemove) {
const root = document.createElement('div');
document.body.appendChild(root);
document.body.addEventListener('click', this.handleClickOutside, true);
return {
reactionViewerRoot: root,
};
}
return { reactionViewerRoot: null };
});
};
public handleClickOutside = (e: MouseEvent) => {
const { reactionViewerRoot } = this.state;
const { current: reactionsContainer } = this.reactionsContainerRef;
if (reactionViewerRoot && reactionsContainer) {
if (
!reactionViewerRoot.contains(e.target as HTMLElement) &&
!reactionsContainer.contains(e.target as HTMLElement)
) {
this.toggleReactionViewer(true);
}
}
};
// tslint:disable-next-line max-func-body-length
public renderReactions(outgoing: boolean) {
const { reactions, i18n } = this.props;
if (!reactions || (reactions && reactions.length === 0)) {
return null;
}
// Group by emoji and order each group by timestamp descending
const grouped = Object.values(groupBy(reactions, 'emoji')).map(res =>
orderBy(res, ['timestamp'], ['desc'])
);
// Order groups by length and subsequently by most recent reaction
const ordered = orderBy(
grouped,
['length', ([{ timestamp }]) => timestamp],
['desc', 'desc']
);
// Take the first two groups for rendering
const toRender = take(ordered, 2).map(res => ({
emoji: res[0].emoji,
isMe: res.some(re => Boolean(re.from.isMe)),
}));
const reactionHeight = 32;
const { reactionsHeight: height, reactionViewerRoot } = this.state;
const offset = clamp((height - reactionHeight) / toRender.length, 4, 28);
const popperPlacement = outgoing ? 'bottom-end' : 'bottom-start';
return (
<Manager>
<Reference>
{({ ref: popperRef }) => (
<Measure
bounds={true}
onResize={({ bounds = { height: 0 } }) => {
this.setState({ reactionsHeight: bounds.height });
}}
>
{({ measureRef }) => (
<div
ref={mergeRefs(
this.reactionsContainerRef,
measureRef,
popperRef
)}
className={classNames(
'module-message__reactions',
outgoing
? 'module-message__reactions--outgoing'
: 'module-message__reactions--incoming'
)}
>
{toRender.map((re, i) => (
<button
key={`${re.emoji}-${i}`}
className={classNames(
'module-message__reactions__reaction',
outgoing
? 'module-message__reactions__reaction--outgoing'
: 'module-message__reactions__reaction--incoming',
re.isMe
? 'module-message__reactions__reaction--is-me'
: null
)}
style={{
top: `${i * offset}px`,
}}
onClick={e => {
e.stopPropagation();
this.toggleReactionViewer();
}}
onKeyDown={e => {
// Prevent enter key from opening stickers/attachments
if (e.key === 'Enter') {
e.stopPropagation();
}
}}
>
<Emoji size={18} emoji={re.emoji} />
</button>
))}
</div>
)}
</Measure>
)}
</Reference>
{reactionViewerRoot &&
createPortal(
<Popper placement={popperPlacement}>
{({ ref, style }) => (
<ReactionViewer
ref={ref}
style={{
...style,
zIndex: 2,
marginTop: -(height - reactionHeight * 0.75),
...(outgoing
? {
marginRight: reactionHeight * -0.375,
}
: {
marginLeft: reactionHeight * -0.375,
}),
}}
reactions={reactions}
i18n={i18n}
onClose={this.toggleReactionViewer}
/>
)}
</Popper>,
reactionViewerRoot
)}
</Manager>
);
}
public renderContents() {
const { isTapToView } = this.props;
@ -1564,6 +1748,7 @@ export class Message extends React.PureComponent<Props, State> {
{this.renderError(direction === 'outgoing')}
{this.renderMenu(direction === 'incoming', triggerId)}
{this.renderContextMenu(triggerId)}
{this.renderReactions(direction === 'outgoing')}
</div>
);
}

View file

@ -0,0 +1,118 @@
### Reaction Viewer
#### Few Reactions
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios} mode={util.mode}>
<ReactionViewer
i18n={util.i18n}
reactions={[
{ emoji: '❤️', from: { id: '+14155552671', name: 'Amelia Briggs' } },
{ emoji: '👍', from: { id: '+14155552671', name: 'Joel Ferrari' } },
]}
/>
</util.ConversationContext>
```
#### Many Reactions
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios} mode={util.mode}>
<ReactionViewer
i18n={util.i18n}
reactions={[
{
emoji: '❤️',
timestamp: 1,
from: { id: '+14155552671', name: 'Ameila Briggs' },
},
{
emoji: '❤️',
timestamp: 2,
from: { id: '+14155552672', name: 'Adam Burrel' },
},
{
emoji: '❤️',
timestamp: 3,
from: { id: '+14155552673', name: 'Rick Owens' },
},
{
emoji: '❤️',
timestamp: 4,
from: { id: '+14155552674', name: 'Bojack Horseman' },
},
{
emoji: '❤️',
timestamp: 4,
from: { id: '+14155552675', name: 'Cayce Pollard' },
},
{
emoji: '❤️',
timestamp: 5,
from: { id: '+14155552676', name: 'Foo McBarrington' },
},
{
emoji: '❤️',
timestamp: 6,
from: { id: '+14155552677', name: 'Ameila Briggs' },
},
{
emoji: '❤️',
timestamp: 7,
from: { id: '+14155552678', name: 'Adam Burrel' },
},
{
emoji: '❤️',
timestamp: 8,
from: { id: '+14155552679', name: 'Rick Owens' },
},
{
emoji: '👍',
timestamp: 9,
from: { id: '+14155552671', name: 'Adam Burrel' },
},
{
emoji: '👎',
timestamp: 10,
from: { id: '+14155552671', name: 'Rick Owens' },
},
{
emoji: '😂',
timestamp: 11,
from: { id: '+14155552671', name: 'Bojack Horseman' },
},
{
emoji: '😮',
timestamp: 12,
from: { id: '+14155552671', name: 'Cayce Pollard' },
},
{
emoji: '😢',
timestamp: 13,
from: { id: '+14155552671', name: 'Foo McBarrington' },
},
{
emoji: '😡',
timestamp: 14,
from: { id: '+14155552671', name: 'Foo McBarrington' },
},
]}
/>
</util.ConversationContext>
```
#### Name Overflow
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios} mode={util.mode}>
<ReactionViewer
i18n={util.i18n}
reactions={[
{
emoji: '❤️',
from: { id: '+14155552671', name: 'Foo McBarringtonMcBazzingtonMcKay' },
},
]}
/>
</util.ConversationContext>
```

View file

@ -0,0 +1,113 @@
import * as React from 'react';
import { groupBy, mapValues, orderBy } from 'lodash';
import classNames from 'classnames';
import { Avatar, Props as AvatarProps } from '../Avatar';
import { Emoji } from '../emoji/Emoji';
import { useRestoreFocus } from '../hooks';
export type Reaction = {
emoji: string;
timestamp: number;
from: {
id: string;
color?: string;
profileName?: string;
name?: string;
isMe?: boolean;
avatarPath?: string;
};
};
export type OwnProps = {
reactions: Array<Reaction>;
onClose?: () => unknown;
};
export type Props = OwnProps &
Pick<React.HTMLProps<HTMLDivElement>, 'style'> &
Pick<AvatarProps, 'i18n'>;
const emojis = ['❤️', '👍', '👎', '😂', '😮', '😢', '😡'];
export const ReactionViewer = React.forwardRef<HTMLDivElement, Props>(
({ i18n, reactions, onClose, ...rest }, ref) => {
const grouped = mapValues(groupBy(reactions, 'emoji'), res =>
orderBy(res, ['timestamp'], ['desc'])
);
const filtered = emojis.filter(e => Boolean(grouped[e]));
const [selected, setSelected] = React.useState(filtered[0]);
const focusRef = React.useRef<HTMLButtonElement>(null);
// Handle escape key
React.useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (onClose && e.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handler);
return () => {
document.removeEventListener('keydown', handler);
};
}, [onClose]);
// Focus first button and restore focus on unmount
useRestoreFocus(focusRef);
return (
<div {...rest} ref={ref} className="module-reaction-viewer">
<header className="module-reaction-viewer__header">
{emojis
.filter(e => Boolean(grouped[e]))
.map((e, index) => {
const re = grouped[e];
const maybeFocusRef = index === 0 ? focusRef : undefined;
return (
<button
key={e}
ref={maybeFocusRef}
className={classNames(
'module-reaction-viewer__header__button',
selected === e
? 'module-reaction-viewer__header__button--selected'
: null
)}
onClick={() => {
setSelected(e);
}}
>
<Emoji size={18} emoji={e} />
<span className="module-reaction-viewer__header__button__count">
{re.length}
</span>
</button>
);
})}
</header>
<main className="module-reaction-viewer__body">
{grouped[selected].map(re => (
<div
key={`${re.from.id}-${re.emoji}`}
className="module-reaction-viewer__body__row"
>
<div className="module-reaction-viewer__body__row__avatar">
<Avatar
avatarPath={re.from.avatarPath}
conversationType="direct"
size={32}
i18n={i18n}
/>
</div>
<span className="module-reaction-viewer__body__row__name">
{re.from.name || re.from.profileName}
</span>
</div>
))}
</main>
</div>
);
}
);

View file

@ -918,6 +918,17 @@ export class Timeline extends React.PureComponent<Props, State> {
// Thanks to https://gist.github.com/pstoica/4323d3e6e37e8a23dd59
setTimeout(() => {
// If focus moved to one of our portals, we do not clear the selected
// message so that focus stays inside the portal. We need to be careful
// to not create colliding keyboard shortcuts between selected messages
// and our portals!
const portals = Array.from(
document.querySelectorAll('body > div:not(.inbox)')
);
if (portals.some(el => el.contains(document.activeElement))) {
return;
}
if (!currentTarget.contains(document.activeElement)) {
clearSelectedMessage();
}

View file

@ -1,10 +1,11 @@
import * as React from 'react';
import classNames from 'classnames';
import { getImagePath, SkinToneKey } from './lib';
import { emojiToImage, getImagePath, SkinToneKey } from './lib';
export type OwnProps = {
inline?: boolean;
shortName: string;
emoji?: string;
shortName?: string;
skinTone?: SkinToneKey | number;
size?: 16 | 18 | 20 | 24 | 28 | 32 | 64 | 66;
children?: React.ReactNode;
@ -21,13 +22,18 @@ export const Emoji = React.memo(
size = 28,
shortName,
skinTone,
emoji,
inline,
className,
children,
}: Props,
ref
) => {
const image = getImagePath(shortName, skinTone);
const image = shortName
? getImagePath(shortName, skinTone)
: emoji
? emojiToImage(emoji)
: '';
const backgroundStyle = inline
? { backgroundImage: `url('${image}')` }
: {};

View file

@ -17,6 +17,7 @@ import {
} from 'lodash';
import { Emoji } from './Emoji';
import { dataByCategory, search } from './lib';
import { useRestoreFocus } from '../hooks';
import { LocalizerType } from '../../types/Util';
export type EmojiPickDataType = { skinTone?: number; shortName: string };
@ -173,19 +174,8 @@ export const EmojiPicker = React.memo(
};
}, [onClose, searchMode]);
// Restore focus on teardown
React.useEffect(() => {
const lastFocused = document.activeElement as any;
if (focusRef.current) {
focusRef.current.focus();
}
return () => {
if (lastFocused && lastFocused.focus) {
lastFocused.focus();
}
};
}, []);
// Focus after initial render, restore focus on teardown
useRestoreFocus(focusRef);
const emojiGrid = React.useMemo(() => {
if (searchText) {

View file

@ -125,6 +125,7 @@ export const preloadImages = async () => {
const dataByShortName = keyBy(data, 'short_name');
const imageByEmoji: { [key: string]: string } = {};
const dataByEmoji: { [key: string]: EmojiData } = {};
export const dataByCategory = mapValues(
groupBy(data, ({ category }) => {
@ -314,12 +315,14 @@ data.forEach(emoji => {
}
imageByEmoji[convertShortName(short_name)] = makeImagePath(image);
dataByEmoji[convertShortName(short_name)] = emoji;
if (skin_variations) {
Object.entries(skin_variations).forEach(([tone, variation]) => {
imageByEmoji[
convertShortName(short_name, tone as SkinToneKey)
] = makeImagePath(variation.image);
dataByEmoji[convertShortName(short_name, tone as SkinToneKey)] = emoji;
});
}
});

26
ts/components/hooks.ts Normal file
View file

@ -0,0 +1,26 @@
import * as React from 'react';
// Restore focus on teardown
export const useRestoreFocus = (
// The ref for the element to receive initial focus
focusRef: React.RefObject<any>,
// Allow for an optional root element that must exist
root: boolean | HTMLElement | null = true
) => {
React.useEffect(() => {
if (!root) {
return;
}
const lastFocused = document.activeElement as any;
if (focusRef.current) {
focusRef.current.focus();
}
return () => {
if (lastFocused && lastFocused.focus) {
lastFocused.focus();
}
};
}, [focusRef, root]);
};

View file

@ -2,6 +2,7 @@
/* tslint:disable:cyclomatic-complexity */
import * as React from 'react';
import classNames from 'classnames';
import { useRestoreFocus } from '../hooks';
import { StickerPackType, StickerType } from '../../state/ducks/stickers';
import { LocalizerType } from '../../types/Util';
@ -122,18 +123,7 @@ export const StickerPicker = React.memo(
}, [onClose]);
// Focus popup on after initial render, restore focus on teardown
React.useEffect(() => {
const lastFocused = document.activeElement as any;
if (focusRef.current) {
focusRef.current.focus();
}
return () => {
if (lastFocused && lastFocused.focus) {
lastFocused.focus();
}
};
}, []);
useRestoreFocus(focusRef);
const isEmpty = stickers.length === 0;
const addPackRef = isEmpty ? focusRef : undefined;

View file

@ -7,6 +7,7 @@ import { ConfirmationDialog } from '../ConfirmationDialog';
import { LocalizerType } from '../../types/Util';
import { StickerPackType } from '../../state/ducks/stickers';
import { Spinner } from '../Spinner';
import { useRestoreFocus } from '../hooks';
export type OwnProps = {
readonly onClose: () => unknown;
@ -79,22 +80,7 @@ export const StickerPreviewModal = React.memo(
const [confirmingUninstall, setConfirmingUninstall] = React.useState(false);
// Restore focus on teardown
React.useEffect(() => {
if (!root) {
return;
}
const lastFocused = document.activeElement as any;
if (focusRef.current) {
focusRef.current.focus();
}
return () => {
if (lastFocused && lastFocused.focus) {
lastFocused.focus();
}
};
}, [root]);
useRestoreFocus(focusRef, root);
React.useEffect(() => {
const div = document.createElement('div');