diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx
index 718ce92533..14beb79296 100644
--- a/ts/components/CompositionInput.tsx
+++ b/ts/components/CompositionInput.tsx
@@ -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
) => r1,
- (_r1: any, r2: React.Ref) => r2,
- (_r1: any, _r2: any, r3: React.MutableRefObject) => 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 }) => (
{
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 (
diff --git a/ts/components/_util.ts b/ts/components/_util.ts
index b2346d3cfc..7f868b92f4 100644
--- a/ts/components/_util.ts
+++ b/ts/components/_util.ts
@@ -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
(...refs: Array[>) {
+ 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;
+ }
+ });
+ };
+}
diff --git a/ts/components/conversation/Message.md b/ts/components/conversation/Message.md
index fc6347ca51..3c20cd4ccc 100644
--- a/ts/components/conversation/Message.md
+++ b/ts/components/conversation/Message.md
@@ -447,6 +447,374 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean
```
+### Reactions
+
+#### One Reaction
+
+```jsx
+
+ ]
+
+
+
+
+
+
+```
+
+#### One Reaction - Ours
+
+```jsx
+
+
+
+
+
+
+
+
+```
+
+#### Multiple reactions, ordered by most common then most recent
+
+```jsx
+
+
+
+
+
+
+
+
+```
+
+#### Multiple reactions, ours is most recent/common
+
+```jsx
+
+
+
+
+
+
+
+
+```
+
+#### Multiple reactions, ours not on top
+
+```jsx
+
+
+
+
+
+
+
+
+```
+
+#### Small message
+
+```jsx
+
+
+
+
+
+
+
+
+```
+
### Long data
```jsx
diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx
index 646daa99c5..70807870ca 100644
--- a/ts/components/conversation/Message.tsx
+++ b/ts/components/conversation/Message.tsx
@@ -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 {
public menuTriggerRef: Trigger | undefined;
- public focusRef: React.RefObject = React.createRef();
public audioRef: React.RefObject = React.createRef();
+ public focusRef: React.RefObject = 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 {
isSelected: props.isSelected,
prevSelectedCounter: props.isSelectedCounter,
+
+ reactionsHeight: 0,
+ reactionViewerRoot: null,
};
}
@@ -271,6 +291,7 @@ export class Message extends React.PureComponent {
if (this.expiredTimeout) {
clearTimeout(this.expiredTimeout);
}
+ this.toggleReactionViewer(true);
}
public componentDidUpdate(prevProps: Props) {
@@ -945,6 +966,9 @@ export class Message extends React.PureComponent {
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 {
{first}
@@ -1289,6 +1314,165 @@ export class Message extends React.PureComponent
{
);
}
+ 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 (
+
+
+ {({ ref: popperRef }) => (
+ {
+ this.setState({ reactionsHeight: bounds.height });
+ }}
+ >
+ {({ measureRef }) => (
+
+ {toRender.map((re, i) => (
+
+ ))}
+
+ )}
+
+ )}
+
+ {reactionViewerRoot &&
+ createPortal(
+
+ {({ ref, style }) => (
+
+ )}
+ ,
+ reactionViewerRoot
+ )}
+
+ );
+ }
+
public renderContents() {
const { isTapToView } = this.props;
@@ -1564,6 +1748,7 @@ export class Message extends React.PureComponent {
{this.renderError(direction === 'outgoing')}
{this.renderMenu(direction === 'incoming', triggerId)}
{this.renderContextMenu(triggerId)}
+ {this.renderReactions(direction === 'outgoing')}
);
}
diff --git a/ts/components/conversation/ReactionViewer.md b/ts/components/conversation/ReactionViewer.md
new file mode 100644
index 0000000000..e74f1c1693
--- /dev/null
+++ b/ts/components/conversation/ReactionViewer.md
@@ -0,0 +1,118 @@
+### Reaction Viewer
+
+#### Few Reactions
+
+```jsx
+
+
+
+```
+
+#### Many Reactions
+
+```jsx
+
+
+
+```
+
+#### Name Overflow
+
+```jsx
+
+
+
+```
diff --git a/ts/components/conversation/ReactionViewer.tsx b/ts/components/conversation/ReactionViewer.tsx
new file mode 100644
index 0000000000..5bdd41a3c7
--- /dev/null
+++ b/ts/components/conversation/ReactionViewer.tsx
@@ -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;
+ onClose?: () => unknown;
+};
+
+export type Props = OwnProps &
+ Pick, 'style'> &
+ Pick;
+
+const emojis = ['❤️', '👍', '👎', '😂', '😮', '😢', '😡'];
+
+export const ReactionViewer = React.forwardRef(
+ ({ 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(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 (
+
+
+ {emojis
+ .filter(e => Boolean(grouped[e]))
+ .map((e, index) => {
+ const re = grouped[e];
+ const maybeFocusRef = index === 0 ? focusRef : undefined;
+
+ return (
+
+ );
+ })}
+
+
+ {grouped[selected].map(re => (
+
+
+
+ {re.from.name || re.from.profileName}
+
+
+ ))}
+
+
+ );
+ }
+);
diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx
index ee548d16c8..b949b9aa4c 100644
--- a/ts/components/conversation/Timeline.tsx
+++ b/ts/components/conversation/Timeline.tsx
@@ -918,6 +918,17 @@ export class Timeline extends React.PureComponent {
// 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();
}
diff --git a/ts/components/emoji/Emoji.tsx b/ts/components/emoji/Emoji.tsx
index 9203d0b923..4718956748 100644
--- a/ts/components/emoji/Emoji.tsx
+++ b/ts/components/emoji/Emoji.tsx
@@ -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}')` }
: {};
diff --git a/ts/components/emoji/EmojiPicker.tsx b/ts/components/emoji/EmojiPicker.tsx
index 7b20b7abcf..7aa2f25ab9 100644
--- a/ts/components/emoji/EmojiPicker.tsx
+++ b/ts/components/emoji/EmojiPicker.tsx
@@ -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) {
diff --git a/ts/components/emoji/lib.ts b/ts/components/emoji/lib.ts
index f0e06d8257..05dc8b73be 100644
--- a/ts/components/emoji/lib.ts
+++ b/ts/components/emoji/lib.ts
@@ -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;
});
}
});
diff --git a/ts/components/hooks.ts b/ts/components/hooks.ts
new file mode 100644
index 0000000000..8b19ffe501
--- /dev/null
+++ b/ts/components/hooks.ts
@@ -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,
+ // 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]);
+};
diff --git a/ts/components/stickers/StickerPicker.tsx b/ts/components/stickers/StickerPicker.tsx
index 005a7f03a6..8945f00e98 100644
--- a/ts/components/stickers/StickerPicker.tsx
+++ b/ts/components/stickers/StickerPicker.tsx
@@ -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;
diff --git a/ts/components/stickers/StickerPreviewModal.tsx b/ts/components/stickers/StickerPreviewModal.tsx
index 3481df6792..f035f09311 100644
--- a/ts/components/stickers/StickerPreviewModal.tsx
+++ b/ts/components/stickers/StickerPreviewModal.tsx
@@ -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');
diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json
index ece1a63bbf..c13b36d69b 100644
--- a/ts/util/lint/exceptions.json
+++ b/ts/util/lint/exceptions.json
@@ -9232,20 +9232,35 @@
{
"rule": "React-createRef",
"path": "ts/components/conversation/Message.js",
- "line": " this.focusRef = react_1.default.createRef();",
- "lineNumber": 32,
+ "line": " this.audioRef = react_1.default.createRef();",
+ "lineNumber": 45,
"reasonCategory": "usageTrusted",
- "updated": "2019-11-01T22:46:33.013Z",
- "reasonDetail": "Used for setting focus only"
+ "updated": "2020-01-09T21:39:25.233Z"
+ },
+ {
+ "rule": "React-createRef",
+ "path": "ts/components/conversation/Message.js",
+ "line": " this.reactionsContainerRef = react_1.default.createRef();",
+ "lineNumber": 47,
+ "reasonCategory": "usageTrusted",
+ "updated": "2020-01-09T21:42:44.292Z",
+ "reasonDetail": "Used for detecting clicks outside reaction viewer"
},
{
"rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx",
- "line": " public focusRef: React.RefObject = React.createRef();",
- "lineNumber": 153,
+ "line": " public audioRef: React.RefObject = React.createRef();",
+ "lineNumber": 167,
"reasonCategory": "usageTrusted",
- "updated": "2020-01-06T17:05:33.013Z",
- "reasonDetail": "Used for setting focus only"
+ "updated": "2020-01-13T22:33:23.241Z"
+ },
+ {
+ "rule": "React-createRef",
+ "path": "ts/components/conversation/Message.tsx",
+ "line": " > = React.createRef();",
+ "lineNumber": 171,
+ "reasonCategory": "usageTrusted",
+ "updated": "2020-01-13T22:33:23.241Z"
},
{
"rule": "React-createRef",