diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 73de167a7c..cffe10c076 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1122,6 +1122,32 @@ "message": "Most recent:", "description": "Displayed in notifications when setting is 'name and message' and more than one message is waiting" }, + "notificationReaction": { + "message": "$sender$ reacted $emoji$ to your message", + "placeholders": { + "sender": { + "content": "$1", + "example": "John" + }, + "emoji": { + "content": "$2", + "example": "👍" + } + } + }, + "notificationReactionMostRecent": { + "message": "Most recent: $sender$ reacted $emoji$ to your message", + "placeholders": { + "sender": { + "content": "$1", + "example": "John" + }, + "emoji": { + "content": "$2", + "example": "👍" + } + } + }, "sendFailed": { "message": "Send failed", "description": "Shown on outgoing message if it fails to send" diff --git a/background.html b/background.html index 2f213bbfe4..2727bebd4d 100644 --- a/background.html +++ b/background.html @@ -454,6 +454,7 @@ + diff --git a/js/background.js b/js/background.js index 6c5a7dc8da..af3a6b40b7 100644 --- a/js/background.js +++ b/js/background.js @@ -8,7 +8,7 @@ Signal, storage, textsecure, - WebAPI + WebAPI, Whisper, */ @@ -850,6 +850,13 @@ if (stickerPreview) { return; } + + const reactionViewer = document.querySelector( + '.module-reaction-viewer' + ); + if (reactionViewer) { + return; + } } // Close Backbone-based confirmation dialog @@ -2070,6 +2077,23 @@ messageDescriptor.type ); + if (data.message.reaction) { + const { reaction } = data.message; + const reactionModel = Whisper.Reactions.add({ + emoji: reaction.emoji, + remove: reaction.remove, + targetAuthorE164: reaction.targetAuthorE164, + targetAuthorUuid: reaction.targetAuthorUuid, + targetTimestamp: reaction.targetTimestamp.toNumber(), + timestamp: Date.now(), + fromId: messageDescriptor.id, + }); + // Note: We do not wait for completion here + Whisper.Reactions.onReaction(reactionModel); + confirm(); + return; + } + // Don't wait for handleDataMessage, as it has its own per-conversation queueing message.handleDataMessage(data.message, event.confirm, { initialLoadComplete, @@ -2188,6 +2212,20 @@ `onSentMessage: Received duplicate transcript for message ${message.idForLogging()}, but it was not an update transcript. Dropping.` ); event.confirm(); + } else if (data.message.reaction) { + const { reaction } = data.message; + const reactionModel = Whisper.Reactions.add({ + emoji: reaction.emoji, + remove: reaction.remove, + targetAuthorE164: reaction.targetAuthorE164, + targetAuthorUuid: reaction.targetAuthorUuid, + targetTimestamp: reaction.targetTimestamp.toNumber(), + timestamp: Date.now(), + fromId: messageDescriptor.id, + }); + // Note: We do not wait for completion here + Whisper.Reactions.onReaction(reactionModel); + event.confirm(); } else { await ConversationController.getOrCreateAndWait( messageDescriptor.id, diff --git a/js/models/conversations.js b/js/models/conversations.js index 56201c00bd..c291bd755d 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -2070,33 +2070,35 @@ }); }, - notify(message) { - if (!message.isIncoming()) { - return Promise.resolve(); + async notify(message, reaction) { + if (!message.isIncoming() && !reaction) { + return; } + const conversationId = this.id; - return ConversationController.getOrCreateAndWait( - message.get('source'), + const sender = await ConversationController.getOrCreateAndWait( + reaction ? reaction.get('fromId') : message.get('source'), 'private' - ).then(sender => - sender.getNotificationIcon().then(iconUrl => { - const messageJSON = message.toJSON(); - const messageSentAt = messageJSON.sent_at; - const messageId = message.id; - const isExpiringMessage = Message.hasExpiration(messageJSON); - - Whisper.Notifications.add({ - conversationId, - iconUrl, - isExpiringMessage, - message: message.getNotificationText(), - messageId, - messageSentAt, - title: sender.getTitle(), - }); - }) ); + + const iconUrl = await sender.getNotificationIcon(); + + const messageJSON = message.toJSON(); + const messageSentAt = messageJSON.sent_at; + const messageId = message.id; + const isExpiringMessage = Message.hasExpiration(messageJSON); + + Whisper.Notifications.add({ + conversationId, + iconUrl, + isExpiringMessage, + message: message.getNotificationText(), + messageId, + messageSentAt, + title: sender.getTitle(), + reaction: reaction ? reaction.toJSON() : null, + }); }, notifyTyping(options = {}) { diff --git a/js/models/messages.js b/js/models/messages.js index 49e02787c3..26af9f5c3c 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -496,6 +496,25 @@ const isTapToView = this.isTapToView(); + const reactions = (this.get('reactions') || []).map(re => { + const c = this.findAndFormatContact(re.fromId); + + if (!c) { + return { + emoji: re.emoji, + from: { + id: re.fromId, + }, + }; + } + + return { + emoji: re.emoji, + timestamp: re.timestamp, + from: c, + }; + }); + return { text: this.createNonBreakingLastSeparator(this.get('body')), textPending: this.get('bodyPending'), @@ -518,6 +537,7 @@ isExpired: this.hasExpired, expirationLength, expirationTimestamp, + reactions, isTapToView, isTapToViewExpired: isTapToView && this.get('isErased'), @@ -1841,13 +1861,6 @@ `Starting handleDataMessage for message ${message.idForLogging()} in conversation ${conversation.idForLogging()}` ); - // Drop reaction messages at this time - if (initialMessage.reaction) { - window.log.info('Dropping reaction message', this.idForLogging()); - confirm(); - return; - } - // First, check for duplicates. If we find one, stop processing here. const existingMessage = await getMessageBySender(this.attributes, { Message: Whisper.Message, @@ -2173,6 +2186,12 @@ await conversation.notify(message); } + // Does this message have a pending, previously-received associated reaction? + const reaction = Whisper.Reactions.forMessage(message); + if (reaction) { + message.handleReaction(reaction); + } + Whisper.events.trigger('incrementProgress'); confirm(); } catch (error) { @@ -2187,6 +2206,38 @@ } }); }, + + async handleReaction(reaction) { + const reactions = this.get('reactions') || []; + + if (reaction.get('remove')) { + const newReactions = reactions.filter( + re => + re.emoji !== reaction.get('emoji') || + re.fromId !== reaction.get('fromId') + ); + this.set({ reactions: newReactions }); + } else { + const newReactions = reactions.filter( + re => re.fromId !== reaction.get('fromId') + ); + newReactions.push(reaction.toJSON()); + this.set({ reactions: newReactions }); + + const conversation = ConversationController.get( + this.get('conversationId') + ); + + // Only notify for reactions to our own messages + if (conversation && this.isOutgoing()) { + conversation.notify(this, reaction); + } + } + + await window.Signal.Data.saveMessage(this.attributes, { + Message: Whisper.Message, + }); + }, }); // Receive will be enabled before we enable send diff --git a/js/notifications.js b/js/notifications.js index 8df33aa421..52d6553d01 100644 --- a/js/notifications.js +++ b/js/notifications.js @@ -101,7 +101,19 @@ // eslint-disable-next-line prefer-destructuring iconUrl = last.iconUrl; if (numNotifications === 1) { - message = `${i18n('notificationFrom')} ${lastMessageTitle}`; + if (last.reaction) { + message = i18n('notificationReaction', [ + lastMessageTitle, + last.reaction.emoji, + ]); + } else { + message = `${i18n('notificationFrom')} ${lastMessageTitle}`; + } + } else if (last.reaction) { + message = i18n('notificationReactionMostRecent', [ + lastMessageTitle, + last.reaction.emoji, + ]); } else { message = `${i18n( 'notificationMostRecentFrom' @@ -113,8 +125,21 @@ if (numNotifications === 1) { // eslint-disable-next-line prefer-destructuring title = last.title; - // eslint-disable-next-line prefer-destructuring - message = last.message; + if (last.reaction) { + message = i18n('notificationReaction', [ + last.title, + last.reaction.emoji, + ]); + } else { + // eslint-disable-next-line prefer-destructuring + message = last.message; + } + } else if (last.reaction) { + title = newMessageCountLabel; + message = i18n('notificationReactionMostRecent', [ + last.title, + last.reaction.emoji, + ]); } else { title = newMessageCountLabel; message = `${i18n('notificationMostRecent')} ${last.message}`; diff --git a/js/reactions.js b/js/reactions.js new file mode 100644 index 0000000000..e63147dc89 --- /dev/null +++ b/js/reactions.js @@ -0,0 +1,98 @@ +/* global + Backbone, + Whisper, + MessageController +*/ + +/* eslint-disable more/no-then */ + +// eslint-disable-next-line func-names +(function() { + 'use strict'; + + window.Whisper = window.Whisper || {}; + Whisper.Reactions = new (Backbone.Collection.extend({ + forMessage(message) { + if (message.isOutgoing()) { + const outgoingReaction = this.findWhere({ + targetTimestamp: message.get('sent_at'), + }); + + if (outgoingReaction) { + window.log.info('Found early reaction for outgoing message'); + this.remove(outgoingReaction); + return outgoingReaction; + } + } + + const reactionBySource = this.findWhere({ + targetAuthorE164: message.get('source'), + targetTimestamp: message.get('sent_at'), + }); + + if (reactionBySource) { + window.log.info('Found early reaction for message'); + this.remove(reactionBySource); + return reactionBySource; + } + + return null; + }, + async onReaction(reaction) { + try { + const messages = await window.Signal.Data.getMessagesBySentAt( + reaction.get('targetTimestamp'), + { + MessageCollection: Whisper.MessageCollection, + } + ); + + const targetMessage = messages.find( + m => + m.get('source') === reaction.get('targetAuthorE164') || + // Outgoing messages don't have a source and are extremely unlikely + // to have the same timestamp + m.isOutgoing() + ); + + if (!targetMessage) { + window.log.info( + 'No message for reaction', + reaction.get('targetAuthorE164'), + reaction.get('targetAuthorUuid'), + reaction.get('targetTimestamp') + ); + + // Since we haven't received the message for which we are removing a + // reaction, we can just remove those pending reaction + if (reaction.get('remove')) { + this.remove(reaction); + const oldReaction = this.where({ + targetAuthorE164: reaction.get('targetAuthorE164'), + targetAuthorUuid: reaction.get('targetAuthorUuid'), + targetTimestamp: reaction.get('targetTimestamp'), + emoji: reaction.get('emoji'), + }); + oldReaction.forEach(r => this.remove(r)); + } + + return; + } + + const message = MessageController.register( + targetMessage.id, + targetMessage + ); + + await message.handleReaction(reaction); + + this.remove(reaction); + } catch (error) { + window.log.error( + 'Reactions.onReaction error:', + error && error.stack ? error.stack : error + ); + } + }, + }))(); +})(); diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index f5c576f7ce..937e9d6b76 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -90,9 +90,15 @@ .module-message__buttons--incoming { left: 100%; + &.module-message__buttons--has-reactions { + padding-left: 40px - 12px; // Adjust 40px by 12px margin on the button + } } .module-message__buttons--outgoing { right: 100%; + &.module-message__buttons--has-reactions { + padding-right: 40px - 12px; // Adjust 40px by 12px margin on the button + } } .module-message__buttons__download { @@ -1302,6 +1308,82 @@ align-items: center; } +.module-message__reactions { + position: absolute; + top: 0; + z-index: 2; + height: 100%; + + &--incoming { + right: -28px; + } + + &--outgoing { + left: -28px; + } +} + +.module-message__reactions__reaction { + @include button-reset; + + width: 33px; + height: 33px; + border: 1px solid; + border-radius: 33px; + display: flex; + justify-content: center; + align-items: center; + + position: absolute; + top: 0; + + &:first-of-type { + z-index: 2; + } + + &--incoming { + right: 0; + } + + &--outgoing { + left: 0; + } + + &:focus { + outline: none; + } + + @include keyboard-mode { + &:focus { + box-shadow: 0px 0px 0px 2px $color-signal-blue; + } + } + + @include light-theme() { + border-color: $color-white; + background: $color-gray-05; + } + + @include dark-theme() { + border-color: $color-gray-95; + background: $color-gray-75; + } + + &--is-me { + @include light-theme() { + background: $color-gray-25; + } + + @include dark-theme() { + background: $color-gray-45; + } + + @include ios-theme() { + background: $color-accent-blue; + } + } +} + // Module: Expire Timer .module-expire-timer { @@ -3372,6 +3454,51 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05', line-height: 28px; } +.module-avatar--32 { + height: 32px; + width: 32px; + + img { + height: 32px; + width: 32px; + } +} + +.module-avatar__icon--32.module-avatar__icon--group { + height: 20px; + width: 20px; + @include light-theme { + @include color-svg('../images/icons/v2/group-outline-20.svg', $color-white); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/group-outline-20.svg', + $color-gray-05 + ); + } +} +.module-avatar__icon--32.module-avatar__icon--direct { + height: 20px; + width: 20px; + @include light-theme { + @include color-svg( + '../images/icons/v2/profile-outline-20.svg', + $color-white + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/profile-outline-20.svg', + $color-gray-05 + ); + } +} + +.module-avatar__label--32 { + font-size: 14px; + line-height: 32px; +} + .module-avatar--52 { height: 52px; width: 52px; @@ -4678,6 +4805,121 @@ button.module-image__border-overlay:focus { // - https://medium.com/mofed/css-line-clamp-the-good-the-bad-and-the-straight-up-broken-865413f16e5 } +// Module: Reaction Viewer + +.module-reaction-viewer { + width: 320px; + height: 320px; + border-radius: 8px; + display: flex; + flex-direction: column; + + @include popper-shadow(); + + @include light-theme() { + background: $color-white; + } + + @include dark-theme() { + background: $color-gray-75; + } + + &__header { + width: 100%; + min-height: 44px; + padding: 0px 16px; + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + overflow-x: auto; + + &__button { + min-width: 45px; + min-height: 28px; + border: none; + border-radius: 18px; + padding: 0px; + display: flex; + justify-content: center; + align-items: center; + + &:not(:first-of-type) { + margin-left: 4px; + } + + &:focus { + outline: none; + } + @include keyboard-mode { + &:focus { + box-shadow: 0px 0px 0px 2px $color-signal-blue; + } + } + + background: none; + + &--selected { + @include light-theme() { + background: $color-gray-05; + } + + @include dark-theme() { + background: $color-gray-60; + } + } + + &__count { + @include font-body-2-bold(); + margin-left: 2px; + + @include light-theme() { + color: $color-gray-90; + } + + @include dark-theme() { + color: $color-gray-05; + } + } + } + } + + &__body { + flex-grow: 1; + padding: 0 16px; + overflow: auto; + + &__row { + margin-top: 8px; + min-height: 32px; + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + + &_avatar { + min-width: 32px; + } + + &__name { + @include font-body-1-bold(); + margin-left: 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + @include light-theme() { + color: $color-gray-90; + } + + @include dark-theme() { + color: $color-gray-05; + } + } + } + } +} + // Module: Left Pane .module-left-pane { diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index a19256e57e..d678b4f7b3 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -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 { 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!`); } diff --git a/ts/components/AvatarPopup.tsx b/ts/components/AvatarPopup.tsx index ab37cc52b4..876c95e8ba 100644 --- a/ts/components/AvatarPopup.tsx +++ b/ts/components/AvatarPopup.tsx @@ -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 (
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",