Send Reactions
This commit is contained in:
parent
3b050116fc
commit
153503efc5
16 changed files with 683 additions and 41 deletions
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
1
images/icons/v2/add-emoji-outline-24.svg
Normal file
1
images/icons/v2/add-emoji-outline-24.svg
Normal 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 |
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
24
ts/components/conversation/ReactionPicker.md
Normal file
24
ts/components/conversation/ReactionPicker.md
Normal 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>
|
||||
```
|
68
ts/components/conversation/ReactionPicker.tsx
Normal file
68
ts/components/conversation/ReactionPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue