diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 3ccf19b14..eb6301a56 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1152,6 +1152,40 @@ } } }, + "notificationReactionMessage": { + "message": "$sender$ reacted $emoji$ to: $message$", + "placeholders": { + "sender": { + "content": "$1", + "example": "John" + }, + "emoji": { + "content": "$2", + "example": "👍" + }, + "message": { + "content": "$3", + "example": "Sounds good." + } + } + }, + "notificationReactionMessageMostRecent": { + "message": "Most recent: $sender$ reacted $emoji$ to: $message$", + "placeholders": { + "sender": { + "content": "$1", + "example": "John" + }, + "emoji": { + "content": "$2", + "example": "👍" + }, + "message": { + "content": "$3", + "example": "Sounds good." + } + } + }, "sendFailed": { "message": "Send failed", "description": "Shown on outgoing message if it fails to send" diff --git a/js/notifications.js b/js/notifications.js index 52d6553d0..0ef387b60 100644 --- a/js/notifications.js +++ b/js/notifications.js @@ -126,9 +126,10 @@ // eslint-disable-next-line prefer-destructuring title = last.title; if (last.reaction) { - message = i18n('notificationReaction', [ + message = i18n('notificationReactionMessage', [ last.title, last.reaction.emoji, + last.message, ]); } else { // eslint-disable-next-line prefer-destructuring @@ -136,9 +137,10 @@ } } else if (last.reaction) { title = newMessageCountLabel; - message = i18n('notificationReactionMostRecent', [ + message = i18n('notificationReactionMessageMostRecent', [ last.title, last.reaction.emoji, + last.message, ]); } else { title = newMessageCountLabel; diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 4f1322464..56aaa5369 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -90,16 +90,10 @@ .module-message__buttons--incoming { left: calc(100% + 8px); - &.module-message__buttons--has-reactions { - padding-left: 40px - 12px; // Adjust 40px by 12px margin on the button - } } .module-message__buttons--outgoing { right: calc(100% + 8px); flex-direction: row-reverse; - &.module-message__buttons--has-reactions { - padding-right: 40px - 12px; // Adjust 40px by 12px margin on the button - } } .module-message__buttons__download { @@ -1096,6 +1090,14 @@ align-items: center; margin-top: 3px; margin-bottom: -3px; + + &--outgoing { + justify-content: flex-end; + } + + &--with-reactions { + margin-bottom: -2px; + } } // With an image and no caption, this section needs to be on top of the image overlay @@ -1171,10 +1173,6 @@ } } -.module-message__metadata__spacer { - flex-grow: 1; -} - .module-message__metadata__status-icon { width: 12px; height: 12px; @@ -1342,43 +1340,57 @@ .module-message__reactions { position: absolute; - top: 0; + bottom: 0px; z-index: 2; - height: 100%; - - &--incoming { - right: -28px; - } - - &--outgoing { - left: -28px; - } + height: 22px; + display: flex; } .module-message__reactions__reaction { @include button-reset; - width: 33px; - height: 33px; + min-width: 28px; + height: 22px; border: 1px solid; border-radius: 33px; display: flex; justify-content: center; align-items: center; - position: absolute; - top: 0; - - &:first-of-type { - z-index: 2; + &--with-count { + min-width: 40px; } - &--incoming { - right: 0; - } + &__count { + @include font-caption-bold; - &--outgoing { - left: 0; + margin-left: 4px; + + &--no-emoji { + margin-left: 0px; + } + + @include light-theme { + color: $color-gray-60; + } + + @include dark-theme { + color: $color-gray-25; + } + + &--is-me { + @include light-theme { + color: $color-gray-75; + } + + @include dark-theme { + color: $color-gray-15; + } + + @include ios-theme { + color: $color-white-alpha-90; + } + } } &:focus { @@ -7633,6 +7645,10 @@ button.module-image__border-overlay:focus { .module-message__container { // 2px to allow for 1px border max-width: 302px; + + &--with-reactions { + margin-bottom: 12px; + } } /* Spec: container > 438px and container < 593px */ diff --git a/ts/components/conversation/Message.md b/ts/components/conversation/Message.md index 3c20cd4cc..aa4cbd45d 100644 --- a/ts/components/conversation/Message.md +++ b/ts/components/conversation/Message.md @@ -449,369 +449,253 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean ### Reactions -#### One Reaction - ```jsx - -
- + {[ + { reactions: [{ emoji: '👍', from: { id: '+14155552671' } }] }, + { + reactions: [ + { emoji: '👍', from: { id: '+14155552671', name: 'Jack Sparrow' } }, { - emoji: '👍', - from: { id: '+14155552671', name: 'Amelia Briggs' }, - timestamp: 1, - }, - ]} - /> -
-
- -
-
-``` - -#### 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 - -
- -
-
- -
+ { emoji: '😂', from: { id: '+14155552675', name: 'Amelia Briggs' } }, + { emoji: '😂', from: { id: '+14155552676', name: 'Amelia Briggs' } }, + { emoji: '😡', from: { id: '+14155552677', name: 'Amelia Briggs' } }, + { emoji: '👎', from: { id: '+14155552678', name: 'Amelia Briggs' } }, + { emoji: '❤️', from: { id: '+14155552679', name: 'Amelia Briggs' } }, + ], + }, + { + outgoing: true, + short: true, + reactions: [ + { emoji: '👍', from: { id: '+14155552671', name: 'Amelia Briggs' } }, + { emoji: '👍', from: { id: '+14155552672', name: 'Amelia Briggs' } }, + { emoji: '👍', from: { id: '+14155552673', name: 'Amelia Briggs' } }, + { emoji: '😂', from: { id: '+14155552674', name: 'Amelia Briggs' } }, + { emoji: '😂', from: { id: '+14155552675', name: 'Amelia Briggs' } }, + { emoji: '😂', from: { id: '+14155552676', name: 'Amelia Briggs' } }, + { emoji: '😡', from: { id: '+14155552677', name: 'Amelia Briggs' } }, + { emoji: '👎', from: { id: '+14155552678', name: 'Amelia Briggs' } }, + { emoji: '❤️', from: { id: '+14155552679', name: 'Amelia Briggs' } }, + ], + }, + { + outgoing: true, + short: true, + reactions: [ + { emoji: '👍', from: { id: '+14155552671', name: 'Amelia Briggs' } }, + { emoji: '👍', from: { id: '+14155552672', name: 'Amelia Briggs' } }, + { emoji: '👍', from: { id: '+14155552673', name: 'Amelia Briggs' } }, + { emoji: '😂', from: { id: '+14155552674', name: 'Amelia Briggs' } }, + { emoji: '😂', from: { id: '+14155552675', name: 'Amelia Briggs' } }, + { emoji: '😂', from: { id: '+14155552676', name: 'Amelia Briggs' } }, + ], + }, + { + outgoing: true, + short: true, + reactions: [ + { emoji: '👍', from: { id: '+14155552671', name: 'Amelia Briggs' } }, + { emoji: '👍', from: { id: '+14155552672', name: 'Amelia Briggs' } }, + { emoji: '👍', from: { id: '+14155552673', name: 'Amelia Briggs' } }, + ], + }, + ].map((spec, i) => ( +
+ +
+ ))}
``` diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 5229f2b21..037a4e040 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -2,7 +2,7 @@ import React from 'react'; import ReactDOM, { createPortal } from 'react-dom'; import classNames from 'classnames'; import Measure from 'react-measure'; -import { clamp, groupBy, orderBy, take } from 'lodash'; +import { drop, groupBy, orderBy, take } from 'lodash'; import { Manager, Popper, Reference } from 'react-popper'; import { Avatar } from '../Avatar'; @@ -161,11 +161,12 @@ interface State { isSelected: boolean; prevSelectedCounter: number; - reactionsHeight: number; reactionViewerRoot: HTMLDivElement | null; reactionPickerRoot: HTMLDivElement | null; isWide: boolean; + + containerWidth: number; } const EXPIRATION_CHECK_MINIMUM = 2000; @@ -199,11 +200,12 @@ export class Message extends React.PureComponent { isSelected: props.isSelected, prevSelectedCounter: props.isSelectedCounter, - reactionsHeight: 0, reactionViewerRoot: null, reactionPickerRoot: null, isWide: this.wideMl.matches, + + containerWidth: 0, }; } @@ -370,6 +372,7 @@ export class Message extends React.PureComponent { } } + // tslint:disable-next-line cyclomatic-complexity public renderMetadata() { const { collapseMetadata, @@ -379,6 +382,7 @@ export class Message extends React.PureComponent { i18n, isSticker, isTapToViewExpired, + reactions, status, text, textPending, @@ -391,6 +395,7 @@ export class Message extends React.PureComponent { const isShowingImage = this.isShowingImage(); const withImageNoCaption = Boolean(!isSticker && !text && isShowingImage); + const withReactions = reactions && reactions.length > 0; const showError = status === 'error' && direction === 'outgoing'; const metadataDirection = isSticker ? undefined : direction; @@ -398,12 +403,13 @@ export class Message extends React.PureComponent {
- {showError ? ( { return null; } - const { reactions } = this.props; const { reactionPickerRoot, isWide } = this.state; - const hasReactions = reactions && reactions.length > 0; const multipleAttachments = attachments && attachments.length > 1; const firstAttachment = attachments && attachments[0]; @@ -1093,8 +1097,7 @@ export class Message extends React.PureComponent {
{ENABLE_REACTION_SEND ? reactButton : null} @@ -1524,16 +1527,43 @@ export class Message extends React.PureComponent { ['length', ([{ timestamp }]) => timestamp], ['desc', 'desc'] ); - // Take the first two groups for rendering - const toRender = take(ordered, 2).map(res => ({ + // Take the first three groups for rendering + const toRender = take(ordered, 3).map(res => ({ emoji: res[0].emoji, + count: res.length, isMe: res.some(re => Boolean(re.from.isMe)), })); + const someNotRendered = ordered.length > 3; + // We only drop two here because the third emoji would be replaced by the + // more button + const maybeNotRendered = drop(ordered, 2); + const maybeNotRenderedTotal = maybeNotRendered.reduce( + (sum, res) => sum + res.length, + 0 + ); + const notRenderedIsMe = + someNotRendered && + maybeNotRendered.some(res => res.some(re => Boolean(re.from.isMe))); - const reactionHeight = 32; - const { reactionsHeight: height, reactionViewerRoot } = this.state; + const { reactionViewerRoot, containerWidth } = this.state; - const offset = clamp((height - reactionHeight) / toRender.length, 4, 28); + // Calculate the width of the reactions container + const reactionsWidth = toRender.reduce((sum, res, i, arr) => { + if (someNotRendered && i === arr.length - 1) { + return sum + 28; + } + + if (res.count > 1) { + return sum + 40; + } + + return sum + 28; + }, 0); + + const reactionsXAxisOffset = Math.max( + containerWidth - reactionsWidth - 6, + 6 + ); const popperPlacement = outgoing ? 'bottom-end' : 'bottom-start'; @@ -1541,58 +1571,83 @@ export class Message extends React.PureComponent { {({ ref: popperRef }) => ( - { - this.setState({ reactionsHeight: bounds.height }); +
- {({ measureRef }) => ( -
- {toRender.map((re, i) => ( - - ))} -
- )} - + } + }} + > + {isMore ? ( + + +{maybeNotRenderedTotal} + + ) : ( + + + {re.count > 1 ? ( + + {re.count} + + ) : null} + + )} + + ); + })} +
)}
{reactionViewerRoot && @@ -1604,14 +1659,6 @@ export class Message extends React.PureComponent { style={{ ...style, zIndex: 2, - marginTop: -(height - reactionHeight * 0.75), - ...(outgoing - ? { - marginRight: reactionHeight * -0.375, - } - : { - marginLeft: reactionHeight * -0.375, - }), }} reactions={reactions} i18n={i18n} @@ -1816,6 +1863,7 @@ export class Message extends React.PureComponent { isTapToView, isTapToViewExpired, isTapToViewError, + reactions, } = this.props; const { isSelected } = this.state; @@ -1844,6 +1892,9 @@ export class Message extends React.PureComponent { : null, isTapToViewError ? 'module-message__container--with-tap-to-view-error' + : null, + reactions && reactions.length > 0 + ? 'module-message__container--with-reactions' : null ); const containerStyles = { @@ -1851,11 +1902,24 @@ export class Message extends React.PureComponent { }; return ( -
- {this.renderAuthor()} - {this.renderContents()} - {this.renderAvatar()} -
+ { + this.setState({ containerWidth: bounds.width }); + }} + > + {({ measureRef }) => ( +
+ {this.renderAuthor()} + {this.renderContents()} + {this.renderAvatar()} +
+ )} +
); } diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 73369e1c0..b7f602d15 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -61,6 +61,20 @@ export type MessageType = { pending: boolean; }; }; + unread: boolean; + reactions?: Array<{ + emoji: string; + timestamp: number; + from: { + id: string; + color?: string; + avatarPath?: string; + name?: string; + profileName?: string; + isMe?: boolean; + phoneNumber?: string; + }; + }>; // No need to go beyond this; unused at this stage, since this goes into // a reducer still in plain JavaScript and comes out well-formed @@ -573,6 +587,14 @@ function hasMessageHeightChanged( return true; } + const currentReactions = message.reactions || []; + const lastReactions = previous.reactions || []; + const reactionsChanged = + (currentReactions.length === 0) !== (lastReactions.length === 0); + if (reactionsChanged) { + return true; + } + return false; } diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index edbcc83a8..79b0cf6ca 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -9250,17 +9250,17 @@ "rule": "React-createRef", "path": "ts/components/conversation/Message.tsx", "line": " public audioRef: React.RefObject = React.createRef();", - "lineNumber": 176, + "lineNumber": 177, "reasonCategory": "usageTrusted", - "updated": "2020-01-21T23:01:37.636Z" + "updated": "2020-02-03T17:18:39.600Z" }, { "rule": "React-createRef", "path": "ts/components/conversation/Message.tsx", "line": " > = React.createRef();", - "lineNumber": 180, + "lineNumber": 181, "reasonCategory": "usageTrusted", - "updated": "2020-01-21T23:01:37.636Z" + "updated": "2020-02-03T17:18:39.600Z" }, { "rule": "React-createRef",