Send Reactions

This commit is contained in:
Ken Powers 2020-01-23 18:57:37 -05:00 committed by Scott Nonnenberg
parent 3b050116fc
commit 153503efc5
16 changed files with 683 additions and 41 deletions

View file

@ -762,6 +762,10 @@
"message": "Download Attachment",
"description": "Shown in a message's triple-dot menu if there isn't room for a dedicated download button"
},
"reactToMessage": {
"message": "React to Message",
"description": "Shown in triple-dot menu next to message to allow user to react to the associated message"
},
"replyToMessage": {
"message": "Reply to Message",
"description": "Shown in triple-dot menu next to message to allow user to start crafting a message with a quotation"
@ -1831,6 +1835,10 @@
"message": "Toggle reply to selected message",
"description": "Shown in the shortcuts guide"
},
"Keyboard--toggle-reaction-picker": {
"message": "Toggle reaction picker for selected message",
"description": "Shown in the shortcuts guide"
},
"Keyboard--save-attachment": {
"message": "Save attachment from selected message",
"description": "Shown in the shortcuts guide"
@ -2144,5 +2152,9 @@
"StickerCreator--Authentication--error": {
"message": "Please set up Signal on your phone and desktop to use the Sticker Pack Creator",
"description": "The error message which appears when the user has not linked their account and attempts to use the Sticker Creator"
},
"Reactions--error": {
"message": "Failed to send reaction. Please try again.",
"description": "Shown when a reaction fails to send."
}
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>add-emoji-outline-24</title><path d="M14,21a8.912,8.912,0,0,1-6.545-2.823l1.09-1.029A7.432,7.432,0,0,0,14,19.5a7.5,7.5,0,0,0,0-15A7.432,7.432,0,0,0,8.545,6.852L7.455,5.823A8.912,8.912,0,0,1,14,3a9,9,0,0,1,0,18Zm4.608-5.812a.749.749,0,1,0-1.216-.876,4.266,4.266,0,0,1-6.786.015.749.749,0,1,0-1.212.882,5.762,5.762,0,0,0,9.214-.021ZM11.25,8.75A1.476,1.476,0,0,0,10,10.375,1.476,1.476,0,0,0,11.25,12a1.476,1.476,0,0,0,1.25-1.625A1.476,1.476,0,0,0,11.25,8.75Zm5.5,0a1.476,1.476,0,0,0-1.25,1.625A1.476,1.476,0,0,0,16.75,12,1.476,1.476,0,0,0,18,10.375,1.476,1.476,0,0,0,16.75,8.75ZM8,11.25H4.75V8H3.25v3.25H0v1.5H3.25V16h1.5V12.75H8Z"/></svg>

After

Width:  |  Height:  |  Size: 726 B

View file

@ -857,6 +857,11 @@
if (reactionViewer) {
return;
}
const reactionPicker = document.querySelector('module-reaction-picker');
if (reactionPicker) {
return;
}
}
// Close Backbone-based confirmation dialog
@ -2223,6 +2228,7 @@
targetTimestamp: reaction.targetTimestamp.toNumber(),
timestamp: Date.now(),
fromId: ourNumber,
fromSync: true,
});
// Note: We do not wait for completion here
Whisper.Reactions.onReaction(reactionModel);

View file

@ -952,6 +952,138 @@
window.reduxActions.stickers.useSticker(packId, stickerId);
},
/**
* Sends a reaction message
* @param {object} reaction - The reaction to send
* @param {string} reaction.emoji - The emoji to react with
* @param {boolean} [reaction.remove] - Set to `true` if we are removing a
* reaction with the given emoji
* @param {object} target - The target of the reaction
* @param {string} target.targetAuthorE164 - The E164 address of the target
* message's author
* @param {number} target.targetTimestamp - The sent_at timestamp of the
* target message
*/
async sendReactionMessage(reaction, target) {
if (!window.ENABLE_REACTION_SEND) {
return null;
}
const timestamp = Date.now();
const outgoingReaction = { ...reaction, ...target };
const reactionModel = Whisper.Reactions.add({
...outgoingReaction,
fromId: this.ourNumber || textsecure.storage.user.getNumber(),
timestamp,
fromSync: true,
});
Whisper.Reactions.onReaction(reactionModel);
const destination = this.id;
const recipients = this.getRecipients();
let profileKey;
if (this.get('profileSharing')) {
profileKey = storage.get('profileKey');
}
return this.queueJob(async () => {
window.log.info(
'Sending reaction to conversation',
this.idForLogging(),
'with timestamp',
timestamp
);
// Here we move attachments to disk
const attributes = {
id: window.getGuid(),
type: 'outgoing',
conversationId: destination,
sent_at: timestamp,
received_at: timestamp,
recipients,
reaction: outgoingReaction,
};
if (this.isPrivate()) {
attributes.destination = destination;
}
// We are only creating this model so we can use its sync message
// sending functionality. It will not be saved to the datbase.
const message = new Whisper.Message(attributes);
// We're offline!
if (!textsecure.messaging) {
throw new Error('Cannot send reaction while offline!');
}
// Special-case the self-send case - we send only a sync message
if (this.isMe()) {
const dataMessage = await textsecure.messaging.getMessageProto(
destination,
null,
null,
null,
null,
null,
outgoingReaction,
timestamp,
null,
profileKey
);
return message.sendSyncMessageOnly(dataMessage);
}
const options = this.getSendOptions();
const groupNumbers = this.getRecipients();
const promise = (() => {
if (this.isPrivate()) {
return textsecure.messaging.sendMessageToNumber(
destination,
null,
null,
null,
null,
null,
outgoingReaction,
timestamp,
null,
profileKey,
options
);
}
return textsecure.messaging.sendMessageToGroup(
destination,
groupNumbers,
null,
null,
null,
null,
null,
outgoingReaction,
timestamp,
null,
profileKey,
options
);
})();
return message.send(this.wrapSend(promise));
}).catch(error => {
window.log.error('Error sending reaction', reaction, target, error);
const reverseReaction = reactionModel.clone();
reverseReaction.set('remove', !reverseReaction.get('remove'));
Whisper.Reactions.onReaction(reverseReaction);
throw error;
});
},
sendMessage(body, attachments, quote, preview, sticker) {
this.clearTypingTimers();
@ -1055,6 +1187,7 @@
quote,
preview,
sticker,
null,
now,
expireTimer,
profileKey
@ -1076,6 +1209,7 @@
quote,
preview,
sticker,
null,
now,
expireTimer,
profileKey,
@ -1090,6 +1224,7 @@
quote,
preview,
sticker,
null,
now,
expireTimer,
profileKey,
@ -1401,6 +1536,7 @@
null,
[],
null,
null,
message.get('sent_at'),
expireTimer,
profileKey,

View file

@ -295,6 +295,9 @@
openLink: url => {
this.trigger('navigate-to', url);
},
reactWith: emoji => {
this.trigger('react-with', emoji);
},
},
errors,
contacts: sortedContacts,
@ -515,6 +518,12 @@
};
});
const selectedReaction = (
(this.get('reactions') || []).find(
re => re.fromId === this.OUR_NUMBER
) || {}
).emoji;
return {
text: this.createNonBreakingLastSeparator(this.get('body')),
textPending: this.get('bodyPending'),
@ -538,6 +547,7 @@
expirationLength,
expirationTimestamp,
reactions,
selectedReaction,
isTapToView,
isTapToViewExpired: isTapToView && this.get('isErased'),
@ -1234,6 +1244,7 @@
quoteWithData,
previewWithData,
stickerWithData,
null,
this.get('sent_at'),
this.get('expireTimer'),
profileKey
@ -1253,6 +1264,7 @@
quoteWithData,
previewWithData,
stickerWithData,
null,
this.get('sent_at'),
this.get('expireTimer'),
profileKey,
@ -1326,6 +1338,7 @@
quoteWithData,
previewWithData,
stickerWithData,
null,
this.get('sent_at'),
this.get('expireTimer'),
profileKey
@ -2229,7 +2242,7 @@
);
// Only notify for reactions to our own messages
if (conversation && this.isOutgoing()) {
if (conversation && this.isOutgoing() && !reaction.get('fromSync')) {
conversation.notify(this, reaction);
}
}

View file

@ -142,6 +142,38 @@
return { toastMessage: i18n('attachmentSaved') };
},
});
Whisper.ReactionFailedToast = Whisper.ToastView.extend({
className: 'toast toast-clickable',
initialize() {
this.timeout = 4000;
if (window.getInteractionMode() === 'keyboard') {
setTimeout(() => {
this.$el.focus();
}, 1);
}
},
events: {
click: 'onClick',
keydown: 'onKeydown',
},
onClick() {
this.close();
},
onKeydown(event) {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
event.stopPropagation();
this.close();
},
render_attributes() {
return { toastMessage: i18n('Reactions--error') };
},
});
const MAX_MESSAGE_BODY_LENGTH = 64 * 1024;
Whisper.MessageBodyTooLongToast = Whisper.ToastView.extend({
@ -438,6 +470,9 @@
setupTimeline() {
const { id } = this.model;
const reactToMessage = (messageId, reaction) => {
this.sendReactionMessage(messageId, reaction);
};
const replyToMessage = messageId => {
this.setQuoteMessage(messageId);
};
@ -636,6 +671,7 @@
markMessageRead,
openConversation,
openLink,
reactToMessage,
replyToMessage,
retrySend,
scrollToQuotedMessage,
@ -2418,6 +2454,24 @@
});
},
async sendReactionMessage(messageId, reaction) {
const messageModel = messageId
? await getMessageById(messageId, {
Message: Whisper.Message,
})
: null;
try {
await this.model.sendReactionMessage(reaction, {
targetAuthorE164: messageModel.getSource(),
targetTimestamp: messageModel.get('sent_at'),
});
} catch (error) {
window.log.error('Error sending reaction', error, messageId, reaction);
this.showToast(Whisper.ReactionFailedToast);
}
},
async sendStickerMessage(options = {}) {
try {
const contacts = await this.getUntrustedContacts(options);

View file

@ -32,6 +32,7 @@ function Message(options) {
this.quote = options.quote;
this.recipients = options.recipients;
this.sticker = options.sticker;
this.reaction = options.reaction;
this.timestamp = options.timestamp;
if (!(this.recipients instanceof Array)) {
@ -123,6 +124,9 @@ Message.prototype = {
proto.sticker.data = this.sticker.attachmentPointer;
}
}
if (this.reaction) {
proto.reaction = this.reaction;
}
if (Array.isArray(this.preview)) {
proto.preview = this.preview.map(preview => {
const item = new textsecure.protobuf.DataMessage.Preview();
@ -927,6 +931,7 @@ MessageSender.prototype = {
quote,
preview,
sticker,
reaction,
timestamp,
expireTimer,
profileKey,
@ -940,6 +945,7 @@ MessageSender.prototype = {
quote,
preview,
sticker,
reaction,
expireTimer,
profileKey,
flags,
@ -967,6 +973,7 @@ MessageSender.prototype = {
quote,
preview,
sticker,
reaction,
timestamp,
expireTimer,
profileKey,
@ -981,6 +988,7 @@ MessageSender.prototype = {
quote,
preview,
sticker,
reaction,
expireTimer,
profileKey,
},
@ -1065,6 +1073,7 @@ MessageSender.prototype = {
quote,
preview,
sticker,
reaction,
timestamp,
expireTimer,
profileKey,
@ -1080,6 +1089,7 @@ MessageSender.prototype = {
quote,
preview,
sticker,
reaction,
expireTimer,
profileKey,
group: {

View file

@ -16,6 +16,7 @@ window.isFocused = () => browserWindow.isFocused();
// Waiting for clients to implement changes on receive side
window.ENABLE_STICKER_SEND = true;
window.ENABLE_REACTION_SEND = false;
window.TIMESTAMP_VALIDATION = false;
window.PAD_ALL_ATTACHMENTS = false;
window.SEND_RECIPIENT_UPDATES = false;

View file

@ -89,13 +89,14 @@
}
.module-message__buttons--incoming {
left: 100%;
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: 100%;
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
}
@ -129,6 +130,37 @@
}
}
.module-message__buttons__react {
height: 24px;
width: 24px;
display: inline-block;
cursor: pointer;
@include light-theme {
@include color-svg(
'../images/icons/v2/add-emoji-outline-24.svg',
$color-gray-45
);
&:hover {
@include color-svg(
'../images/icons/v2/add-emoji-outline-24.svg',
$color-gray-90
);
}
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/add-emoji-outline-24.svg',
$color-gray-45
);
&:hover {
@include color-svg(
'../images/icons/v2/add-emoji-outline-24.svg',
$color-gray-02
);
}
}
}
.module-message__buttons__download--incoming {
margin-left: 12px;
}
@ -261,7 +293,7 @@
// This is the component we put the outline around when the whole message is selected
.module-message--selected .module-message__container {
@include mouse-mode {
animation: message--mouse-selected 1s cubic-bezier(0.19, 1, 0.22, 1);
animation: message--mouse-selected 1s $ease-out-expo;
}
}
.module-message:focus .module-message__container {
@ -3829,7 +3861,7 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05',
right: 1px;
border-radius: 10px;
animation: message--mouse-selected 1s cubic-bezier(0.19, 1, 0.22, 1);
animation: message--mouse-selected 1s $ease-out-expo;
}
}
@ -4920,6 +4952,111 @@ button.module-image__border-overlay:focus {
}
}
// Module: Reaction Picker
@keyframes module-reaction-picker__background-fade {
from {
background: transparent;
}
to {
// This color is the same in both light and dark themes
background: rgba($color-black, 0.8);
}
}
@keyframes module-reaction-picker__emoji-fade {
from {
transform: translate3d(0, 24px, 0);
opacity: 0;
}
to {
transform: translate3d(0, 0, 0);
opacity: 1;
}
}
.module-reaction-picker {
width: 320px;
height: 56px;
border-radius: 30px;
position: relative;
margin: 4px 0;
z-index: 2;
animation: {
name: module-reaction-picker__background-fade;
duration: 400ms;
timing-function: $ease-out-expo;
fill-mode: forwards;
}
&__emoji-btn {
@include button-reset;
display: flex;
min-width: 52px;
min-height: 52px;
border-radius: 52px;
position: absolute;
top: 2px;
@for $i from 0 through 6 {
&:nth-of-type(#{$i + 1}) {
left: 2px + ($i * 44px);
// Prevent animation jank
opacity: 0;
animation: {
name: module-reaction-picker__emoji-fade;
duration: 400ms;
timing-function: $ease-out-expo;
delay: #{$i * 10ms};
fill-mode: forwards;
}
}
}
transition: background 400ms $ease-out-expo;
&--selected {
// This color is the same in both light and dark themes
background: rgba($color-white, 0.3);
}
@include keyboard-mode {
&:focus:before {
content: '';
display: block;
width: 4px;
height: 4px;
background: $color-signal-blue;
border-radius: 2px;
position: absolute;
bottom: 4px;
left: calc(50% - 2px);
}
}
$emoji-btn: &;
&__emoji {
position: absolute;
left: 2px;
top: 2px;
transform-origin: center;
$scale: 32 / 48;
transform: scale3d($scale, $scale, $scale);
transition: transform 400ms $ease-out-expo;
#{$emoji-btn}:hover &,
.keyboard-mode #{$emoji-btn}:focus & {
transform: scale3d(1, 1, 1) translate3d(0, -24px, 0);
z-index: 1;
}
}
}
}
// Module: Left Pane
.module-left-pane {
@ -6575,6 +6712,7 @@ button.module-image__border-overlay:focus {
@include emoji-size(24px);
@include emoji-size(28px);
@include emoji-size(32px);
@include emoji-size(48px);
@include emoji-size(64px);
@include emoji-size(66px);
}
@ -7486,6 +7624,9 @@ button.module-image__border-overlay:focus {
.module-message__buttons__reply {
display: none;
}
.module-message__buttons__react {
display: none;
}
// To limit messages with things forcing them wider, like long attachment names
.module-message__container {
@ -7518,6 +7659,9 @@ button.module-image__border-overlay:focus {
.module-message__buttons__reply {
display: inline-block;
}
.module-message__buttons__react {
display: inline-block;
}
// To hide in larger breakpoints
.module-message__context__download {
@ -7551,6 +7695,9 @@ button.module-image__border-overlay:focus {
.module-message__buttons__reply {
display: inline-block;
}
.module-message__buttons__react {
display: inline-block;
}
// To hide in larger breakpoints
.module-message__context__download {
@ -7559,4 +7706,7 @@ button.module-image__border-overlay:focus {
.module-message__context__reply {
display: none;
}
.module-message__context__react {
display: none;
}
}

View file

@ -182,3 +182,5 @@ $color-signal-blue-tint-alpha-50: rgba($color-ios-blue-tint, 0.5);
$left-pane-width: 320px;
$header-height: 48px;
$ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);

View file

@ -22,6 +22,7 @@ type KeyType =
| 'A'
| 'C'
| 'D'
| 'E'
| 'F'
| 'J'
| 'L'
@ -130,6 +131,10 @@ const MESSAGE_SHORTCUTS: Array<ShortcutType> = [
description: 'Keyboard--toggle-reply',
keys: ['commandOrCtrl', 'shift', 'R'],
},
{
description: 'Keyboard--toggle-reaction-picker',
keys: ['commandOrCtrl', 'shift', 'E'],
},
{
description: 'Keyboard--save-attachment',
keys: ['commandOrCtrl', 'S'],

View file

@ -19,6 +19,7 @@ import {
OwnProps as ReactionViewerProps,
ReactionViewer,
} from './ReactionViewer';
import { ReactionPicker } from './ReactionPicker';
import { Emoji } from '../emoji/Emoji';
import {
@ -103,6 +104,7 @@ export type PropsData = {
expirationTimestamp?: number;
reactions?: ReactionViewerProps['reactions'];
selectedReaction?: string;
};
type PropsHousekeeping = {
@ -115,6 +117,10 @@ type PropsHousekeeping = {
export type PropsActions = {
clearSelectedMessage: () => unknown;
reactToMessage: (
id: string,
{ emoji, remove }: { emoji: string; remove: boolean }
) => void;
replyToMessage: (id: string) => void;
retrySend: (id: string) => void;
deleteMessage: (id: string) => void;
@ -157,6 +163,9 @@ interface State {
reactionsHeight: number;
reactionViewerRoot: HTMLDivElement | null;
reactionPickerRoot: HTMLDivElement | null;
isWide: boolean;
}
const EXPIRATION_CHECK_MINIMUM = 2000;
@ -170,6 +179,8 @@ export class Message extends React.PureComponent<Props, State> {
HTMLDivElement
> = React.createRef();
public wideMl: MediaQueryList;
public expirationCheckInterval: any;
public expiredTimeout: any;
public selectedTimeout: any;
@ -177,6 +188,9 @@ export class Message extends React.PureComponent<Props, State> {
public constructor(props: Props) {
super(props);
this.wideMl = window.matchMedia('(min-width: 926px)');
this.wideMl.addEventListener('change', this.handleWideMlChange);
this.state = {
expiring: false,
expired: false,
@ -187,6 +201,9 @@ export class Message extends React.PureComponent<Props, State> {
reactionsHeight: 0,
reactionViewerRoot: null,
reactionPickerRoot: null,
isWide: this.wideMl.matches,
};
}
@ -213,6 +230,10 @@ export class Message extends React.PureComponent<Props, State> {
return state;
}
public handleWideMlChange = (event: MediaQueryListEvent) => {
this.setState({ isWide: event.matches });
};
public captureMenuTrigger = (triggerRef: Trigger) => {
this.menuTriggerRef = triggerRef;
};
@ -292,6 +313,9 @@ export class Message extends React.PureComponent<Props, State> {
clearTimeout(this.expiredTimeout);
}
this.toggleReactionViewer(true);
this.toggleReactionPicker(true);
this.wideMl.removeEventListener('change', this.handleWideMlChange);
}
public componentDidUpdate(prevProps: Props) {
@ -954,6 +978,7 @@ export class Message extends React.PureComponent<Props, State> {
public renderMenu(isCorrectSide: boolean, triggerId: string) {
const {
attachments,
// tslint:disable-next-line max-func-body-length
direction,
disableMenu,
id,
@ -967,6 +992,7 @@ export class Message extends React.PureComponent<Props, State> {
}
const { reactions } = this.props;
const { reactionPickerRoot, isWide } = this.state;
const hasReactions = reactions && reactions.length > 0;
const multipleAttachments = attachments && attachments.length > 1;
@ -989,6 +1015,30 @@ export class Message extends React.PureComponent<Props, State> {
/>
) : null;
const reactButton = (
<Reference>
{({ ref: popperRef }) => {
// Only attach the popper reference to the reaction button if it is
// visible in the page (it is hidden when the page is narrow)
const maybePopperRef = isWide ? popperRef : undefined;
return (
<div
ref={maybePopperRef}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
this.toggleReactionPicker();
}}
role="button"
className="module-message__buttons__react"
/>
);
}}
</Reference>
);
const replyButton = (
<div
onClick={(event: React.MouseEvent) => {
@ -1007,37 +1057,77 @@ export class Message extends React.PureComponent<Props, State> {
);
const menuButton = (
<ContextMenuTrigger id={triggerId} ref={this.captureMenuTrigger as any}>
<div
// This a menu meant for mouse use only
role="button"
onClick={this.showMenu}
className={classNames(
'module-message__buttons__menu',
`module-message__buttons__download--${direction}`
)}
/>
</ContextMenuTrigger>
<Reference>
{({ ref: popperRef }) => {
// Only attach the popper reference to the collapsed menu button if
// the reaction button is not visible in the page (it is hidden when
// the page is narrow)
const maybePopperRef = !isWide ? popperRef : undefined;
return (
<ContextMenuTrigger
id={triggerId}
ref={this.captureMenuTrigger as any}
>
<div
// This a menu meant for mouse use only
ref={maybePopperRef}
role="button"
onClick={this.showMenu}
className={classNames(
'module-message__buttons__menu',
`module-message__buttons__download--${direction}`
)}
/>
</ContextMenuTrigger>
);
}}
</Reference>
);
const first = direction === 'incoming' ? downloadButton : menuButton;
const last = direction === 'incoming' ? menuButton : downloadButton;
// @ts-ignore
const ENABLE_REACTION_SEND: boolean = window.ENABLE_REACTION_SEND;
return (
<div
className={classNames(
'module-message__buttons',
`module-message__buttons--${direction}`,
hasReactions ? 'module-message__buttons--has-reactions' : null
)}
>
{first}
{replyButton}
{last}
</div>
<Manager>
<div
className={classNames(
'module-message__buttons',
`module-message__buttons--${direction}`,
hasReactions ? 'module-message__buttons--has-reactions' : null
)}
>
{ENABLE_REACTION_SEND ? reactButton : null}
{downloadButton}
{replyButton}
{menuButton}
</div>
{reactionPickerRoot &&
createPortal(
<Popper placement="top">
{({ ref, style }) => (
<ReactionPicker
ref={ref}
style={style}
selected={this.props.selectedReaction}
onClose={this.toggleReactionPicker}
onPick={emoji => {
this.toggleReactionPicker(true);
this.props.reactToMessage(id, {
emoji,
remove: emoji === this.props.selectedReaction,
});
}}
/>
)}
</Popper>,
reactionPickerRoot
)}
</Manager>
);
}
// tslint:disable-next-line max-func-body-length
public renderContextMenu(triggerId: string) {
const {
attachments,
@ -1056,6 +1146,9 @@ export class Message extends React.PureComponent<Props, State> {
const showRetry = status === 'error' && direction === 'outgoing';
const multipleAttachments = attachments && attachments.length > 1;
// @ts-ignore
const ENABLE_REACTION_SEND: boolean = window.ENABLE_REACTION_SEND;
const menu = (
<ContextMenu id={triggerId}>
{!isSticker &&
@ -1072,6 +1165,21 @@ export class Message extends React.PureComponent<Props, State> {
{i18n('downloadAttachment')}
</MenuItem>
) : null}
{ENABLE_REACTION_SEND ? (
<MenuItem
attributes={{
className: 'module-message__context__react',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
this.toggleReactionPicker();
}}
>
{i18n('reactToMessage')}
</MenuItem>
) : null}
<MenuItem
attributes={{
className: 'module-message__context__reply',
@ -1320,7 +1428,7 @@ export class Message extends React.PureComponent<Props, State> {
document.body.removeChild(reactionViewerRoot);
document.body.removeEventListener(
'click',
this.handleClickOutside,
this.handleClickOutsideReactionViewer,
true
);
@ -1330,7 +1438,11 @@ export class Message extends React.PureComponent<Props, State> {
if (!onlyRemove) {
const root = document.createElement('div');
document.body.appendChild(root);
document.body.addEventListener('click', this.handleClickOutside, true);
document.body.addEventListener(
'click',
this.handleClickOutsideReactionViewer,
true
);
return {
reactionViewerRoot: root,
@ -1341,7 +1453,38 @@ export class Message extends React.PureComponent<Props, State> {
});
};
public handleClickOutside = (e: MouseEvent) => {
public toggleReactionPicker = (onlyRemove = false) => {
this.setState(({ reactionPickerRoot }) => {
if (reactionPickerRoot) {
document.body.removeChild(reactionPickerRoot);
document.body.removeEventListener(
'click',
this.handleClickOutsideReactionPicker,
true
);
return { reactionPickerRoot: null };
}
if (!onlyRemove) {
const root = document.createElement('div');
document.body.appendChild(root);
document.body.addEventListener(
'click',
this.handleClickOutsideReactionPicker,
true
);
return {
reactionPickerRoot: root,
};
}
return { reactionPickerRoot: null };
});
};
public handleClickOutsideReactionViewer = (e: MouseEvent) => {
const { reactionViewerRoot } = this.state;
const { current: reactionsContainer } = this.reactionsContainerRef;
if (reactionViewerRoot && reactionsContainer) {
@ -1354,6 +1497,15 @@ export class Message extends React.PureComponent<Props, State> {
}
};
public handleClickOutsideReactionPicker = (e: MouseEvent) => {
const { reactionPickerRoot } = this.state;
if (reactionPickerRoot) {
if (!reactionPickerRoot.contains(e.target as HTMLElement)) {
this.toggleReactionPicker(true);
}
}
};
// tslint:disable-next-line max-func-body-length
public renderReactions(outgoing: boolean) {
const { reactions, i18n } = this.props;
@ -1631,6 +1783,14 @@ export class Message extends React.PureComponent<Props, State> {
};
public handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (
(event.key === 'E' || event.key === 'e') &&
(event.metaKey || event.ctrlKey) &&
event.shiftKey
) {
this.toggleReactionPicker();
}
if (event.key !== 'Enter' && event.key !== 'Space') {
return;
}

View file

@ -0,0 +1,24 @@
### Reaction Picker
#### Base
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios} mode={util.mode}>
<ReactionPicker onPick={e => console.log(`Picked reaction: ${e}`)} />
</util.ConversationContext>
```
#### Selected
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios} mode={util.mode}>
{['❤️', '👍', '👎', '😂', '😮', '😢', '😡'].map(e => (
<div key={e} style={{ height: '100px' }}>
<ReactionPicker
selected={e}
onPick={e => console.log(`Picked reaction: ${e}`)}
/>
</div>
))}
</util.ConversationContext>
```

View file

@ -0,0 +1,68 @@
import * as React from 'react';
import classNames from 'classnames';
import { Emoji } from '../emoji/Emoji';
import { useRestoreFocus } from '../hooks';
export type OwnProps = {
selected?: string;
onClose?: () => unknown;
onPick: (emoji: string) => unknown;
};
export type Props = OwnProps & Pick<React.HTMLProps<HTMLDivElement>, 'style'>;
const emojis = ['❤️', '👍', '👎', '😂', '😮', '😢', '😡'];
export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
({ selected, onClose, onPick, ...rest }, ref) => {
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-picker">
{emojis.map((emoji, index) => {
const maybeFocusRef = index === 0 ? focusRef : undefined;
return (
<button
key={emoji}
ref={maybeFocusRef}
tabIndex={0}
className={classNames(
'module-reaction-picker__emoji-btn',
emoji === selected
? 'module-reaction-picker__emoji-btn--selected'
: null
)}
onClick={e => {
e.stopPropagation();
onPick(emoji);
}}
>
<div className="module-reaction-picker__emoji-btn__emoji">
<Emoji size={48} emoji={emoji} />
</div>
</button>
);
})}
</div>
);
}
);

View file

@ -7,7 +7,7 @@ export type OwnProps = {
emoji?: string;
shortName?: string;
skinTone?: SkinToneKey | number;
size?: 16 | 18 | 20 | 24 | 28 | 32 | 64 | 66;
size?: 16 | 18 | 20 | 24 | 28 | 32 | 48 | 64 | 66;
children?: React.ReactNode;
};

View file

@ -9233,34 +9233,34 @@
"rule": "React-createRef",
"path": "ts/components/conversation/Message.js",
"line": " this.audioRef = react_1.default.createRef();",
"lineNumber": 45,
"lineNumber": 46,
"reasonCategory": "usageTrusted",
"updated": "2020-01-09T21:39:25.233Z"
"updated": "2020-01-21T15:46:51.245Z"
},
{
"rule": "React-createRef",
"path": "ts/components/conversation/Message.js",
"line": " this.reactionsContainerRef = react_1.default.createRef();",
"lineNumber": 47,
"lineNumber": 48,
"reasonCategory": "usageTrusted",
"updated": "2020-01-09T21:42:44.292Z",
"updated": "2020-01-21T15:46:51.245Z",
"reasonDetail": "Used for detecting clicks outside reaction viewer"
},
{
"rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx",
"line": " public audioRef: React.RefObject<HTMLAudioElement> = React.createRef();",
"lineNumber": 167,
"lineNumber": 176,
"reasonCategory": "usageTrusted",
"updated": "2020-01-13T22:33:23.241Z"
"updated": "2020-01-21T23:01:37.636Z"
},
{
"rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx",
"line": " > = React.createRef();",
"lineNumber": 171,
"lineNumber": 180,
"reasonCategory": "usageTrusted",
"updated": "2020-01-13T22:33:23.241Z"
"updated": "2020-01-21T23:01:37.636Z"
},
{
"rule": "React-createRef",