Render incoming Reactions
This commit is contained in:
parent
b113eb19f0
commit
6cc0f2abce
25 changed files with 1411 additions and 134 deletions
|
@ -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"
|
||||
|
|
|
@ -454,6 +454,7 @@
|
|||
<script type='text/javascript' src='js/read_receipts.js'></script>
|
||||
<script type='text/javascript' src='js/read_syncs.js'></script>
|
||||
<script type='text/javascript' src='js/view_syncs.js'></script>
|
||||
<script type='text/javascript' src='js/reactions.js'></script>
|
||||
<script type='text/javascript' src='js/libphonenumber-util.js'></script>
|
||||
<script type='text/javascript' src='js/models/messages.js'></script>
|
||||
<script type='text/javascript' src='js/models/conversations.js'></script>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 = {}) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}`;
|
||||
|
|
98
js/reactions.js
Normal file
98
js/reactions.js
Normal file
|
@ -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
|
||||
);
|
||||
}
|
||||
},
|
||||
}))();
|
||||
})();
|
|
@ -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 {
|
||||
|
|
|
@ -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<Props, State> {
|
|||
|
||||
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!`);
|
||||
}
|
||||
|
||||
|
|
|
@ -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 (
|
||||
<div style={style} className="module-avatar-popup">
|
||||
|
|
|
@ -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<HTMLDivElement>) => r1,
|
||||
(_r1: any, r2: React.Ref<HTMLDivElement>) => r2,
|
||||
(_r1: any, _r2: any, r3: React.MutableRefObject<HTMLDivElement>) => 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 }) => (
|
||||
<div
|
||||
className="module-composition-input__input"
|
||||
ref={combineRefs(popperRef, measureRef, rootElRef)}
|
||||
ref={mergeRefs(popperRef, measureRef, rootElRef)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import * as React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useRestoreFocus } from './hooks';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
|
||||
export type Props = {
|
||||
|
@ -172,18 +173,7 @@ export const ShortcutGuide = (props: Props) => {
|
|||
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 (
|
||||
<div className="module-shortcut-guide">
|
||||
|
|
|
@ -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<T>(...refs: Array<Ref<T>>) {
|
||||
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;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
@ -447,6 +447,374 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean
|
|||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### Reactions
|
||||
|
||||
#### One Reaction
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme} ios={util.ios} mode={util.mode}>
|
||||
<div className="module-message-container">
|
||||
<Message
|
||||
direction="incoming"
|
||||
status="delivered"
|
||||
authorColor="red"
|
||||
timestamp={Date.now()}
|
||||
text="I'd like to order one large phone with extra phones please. cell phone, no no no rotary... and payphone on half."
|
||||
i18n={util.i18n}
|
||||
reactions={[
|
||||
{
|
||||
emoji: '👍',
|
||||
from: { id: '+14155552671', name: 'Amelia Briggs' },
|
||||
timestamp: 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="module-message-container">
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
authorColor="red"
|
||||
timestamp={Date.now()}
|
||||
text="I'd like to order one large phone with extra phones please. cell phone, no no no rotary... and payphone on half."
|
||||
i18n={util.i18n}
|
||||
reactions={[
|
||||
{
|
||||
emoji: '👍',
|
||||
from: { id: '+14155552671', name: 'Amelia Briggs' },
|
||||
timestamp: 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### One Reaction - Ours
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme} ios={util.ios} mode={util.mode}>
|
||||
<div className="module-message-container">
|
||||
<Message
|
||||
direction="incoming"
|
||||
status="delivered"
|
||||
authorColor="red"
|
||||
timestamp={Date.now()}
|
||||
text="I'd like to order one large phone with extra phones please. cell phone, no no no rotary... and payphone on half."
|
||||
i18n={util.i18n}
|
||||
reactions={[
|
||||
{
|
||||
emoji: '👍',
|
||||
from: { id: '+14155552671', isMe: true, name: 'Amelia Briggs' },
|
||||
timestamp: 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="module-message-container">
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
authorColor="red"
|
||||
timestamp={Date.now()}
|
||||
text="I'd like to order one large phone with extra phones please. cell phone, no no no rotary... and payphone on half."
|
||||
i18n={util.i18n}
|
||||
reactions={[
|
||||
{
|
||||
emoji: '👍',
|
||||
from: { id: '+14155552671', isMe: true, name: 'Amelia Briggs' },
|
||||
timestamp: 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Multiple reactions, ordered by most common then most recent
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme} ios={util.ios} mode={util.mode}>
|
||||
<div className="module-message-container">
|
||||
<Message
|
||||
direction="incoming"
|
||||
status="delivered"
|
||||
authorColor="red"
|
||||
timestamp={Date.now()}
|
||||
text="I'd like to order one large phone with extra phones please. cell phone, no no no rotary... and payphone on half."
|
||||
i18n={util.i18n}
|
||||
reactions={[
|
||||
{
|
||||
emoji: '👍',
|
||||
from: { id: '+14155552671', name: 'Amelia Briggs' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '👍',
|
||||
from: { id: '+14155552671', name: 'Joel Ferrari' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '😡',
|
||||
from: { id: '+14155552671', name: 'Adam Burrell' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '😮',
|
||||
from: { id: '+14155552671', name: 'Rick Owens' },
|
||||
timestamp: 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="module-message-container">
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
authorColor="red"
|
||||
timestamp={Date.now()}
|
||||
text="I'd like to order one large phone with extra phones please. cell phone, no no no rotary... and payphone on half."
|
||||
i18n={util.i18n}
|
||||
reactions={[
|
||||
{
|
||||
emoji: '👍',
|
||||
from: { id: '+14155552671', name: 'Amelia Briggs' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '👍',
|
||||
from: { id: '+14155552671', name: 'Joel Ferrari' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '😡',
|
||||
from: { id: '+14155552671', name: 'Adam Burrell' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '😮',
|
||||
from: { id: '+14155552671', name: 'Rick Owens' },
|
||||
timestamp: 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Multiple reactions, ours is most recent/common
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme} ios={util.ios} mode={util.mode}>
|
||||
<div className="module-message-container">
|
||||
<Message
|
||||
direction="incoming"
|
||||
status="delivered"
|
||||
authorColor="red"
|
||||
timestamp={Date.now()}
|
||||
text="I'd like to order one large phone with extra phones please. cell phone, no no no rotary... and payphone on half."
|
||||
i18n={util.i18n}
|
||||
reactions={[
|
||||
{
|
||||
emoji: '👍',
|
||||
from: { id: '+14155552671', isMe: true, name: 'Amelia Briggs' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '👍',
|
||||
from: { id: '+14155552671', name: 'Joel Ferrari' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '😡',
|
||||
from: { id: '+14155552671', name: 'Adam Burrell' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '😮',
|
||||
from: { id: '+14155552671', name: 'Rick Owens' },
|
||||
timestamp: 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="module-message-container">
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
authorColor="red"
|
||||
timestamp={Date.now()}
|
||||
text="I'd like to order one large phone with extra phones please. cell phone, no no no rotary... and payphone on half."
|
||||
i18n={util.i18n}
|
||||
reactions={[
|
||||
{
|
||||
emoji: '👍',
|
||||
from: { id: '+14155552671', name: 'Amelia Briggs' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '👍',
|
||||
from: { id: '+14155552671', isMe: true, name: 'Joel Ferrari' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '😡',
|
||||
from: { id: '+14155552671', name: 'Adam Burrell' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '😮',
|
||||
from: { id: '+14155552671', name: 'Rick Owens' },
|
||||
timestamp: 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Multiple reactions, ours not on top
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme} ios={util.ios} mode={util.mode}>
|
||||
<div className="module-message-container">
|
||||
<Message
|
||||
direction="incoming"
|
||||
status="delivered"
|
||||
authorColor="red"
|
||||
timestamp={Date.now()}
|
||||
text="I'd like to order one large phone with extra phones please. cell phone, no no no rotary... and payphone on half."
|
||||
i18n={util.i18n}
|
||||
reactions={[
|
||||
{
|
||||
emoji: '👍',
|
||||
from: { id: '+14155552671', name: 'Amelia Briggs' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '👍',
|
||||
from: { id: '+14155552671', name: 'Joel Ferrari' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '😡',
|
||||
from: { id: '+14155552671', name: 'Adam Burrell' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '😮',
|
||||
from: { id: '+14155552671', isMe: true, name: 'Rick Owens' },
|
||||
timestamp: 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="module-message-container">
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
authorColor="red"
|
||||
timestamp={Date.now()}
|
||||
text="I'd like to order one large phone with extra phones please. cell phone, no no no rotary... and payphone on half."
|
||||
i18n={util.i18n}
|
||||
reactions={[
|
||||
{
|
||||
emoji: '👍',
|
||||
from: { id: '+14155552671', name: 'Amelia Briggs' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '👍',
|
||||
from: { id: '+14155552671', name: 'Joel Ferrari' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '😡',
|
||||
from: { id: '+14155552671', name: 'Adam Burrell' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '😮',
|
||||
from: { id: '+14155552671', isMe: true, name: 'Rick Owens' },
|
||||
timestamp: 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Small message
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme} ios={util.ios} mode={util.mode}>
|
||||
<div className="module-message-container">
|
||||
<Message
|
||||
direction="incoming"
|
||||
status="delivered"
|
||||
authorColor="red"
|
||||
timestamp={Date.now()}
|
||||
text="Burgertime!"
|
||||
i18n={util.i18n}
|
||||
reactions={[
|
||||
{
|
||||
emoji: '👍',
|
||||
from: { id: '+14155552671', name: 'Amelia Briggs' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '👍',
|
||||
from: { id: '+14155552671', name: 'Joel Ferrari' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '😡',
|
||||
from: { id: '+14155552671', name: 'Adam Burrell' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '😮',
|
||||
from: { id: '+14155552671', name: 'Rick Owens' },
|
||||
timestamp: 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="module-message-container">
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
authorColor="red"
|
||||
timestamp={Date.now()}
|
||||
text="Burgertime!"
|
||||
i18n={util.i18n}
|
||||
reactions={[
|
||||
{
|
||||
emoji: '👍',
|
||||
from: { id: '+14155552671', name: 'Amelia Briggs' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '👍',
|
||||
from: { id: '+14155552671', name: 'Joel Ferrari' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '😡',
|
||||
from: { id: '+14155552671', name: 'Adam Burrell' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
emoji: '😮',
|
||||
from: { id: '+14155552671', name: 'Rick Owens' },
|
||||
timestamp: 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### Long data
|
||||
|
||||
```jsx
|
||||
|
|
|
@ -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<Props, State> {
|
||||
public menuTriggerRef: Trigger | undefined;
|
||||
public focusRef: React.RefObject<HTMLDivElement> = React.createRef();
|
||||
public audioRef: React.RefObject<HTMLAudioElement> = React.createRef();
|
||||
public focusRef: React.RefObject<HTMLDivElement> = 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<Props, State> {
|
|||
|
||||
isSelected: props.isSelected,
|
||||
prevSelectedCounter: props.isSelectedCounter,
|
||||
|
||||
reactionsHeight: 0,
|
||||
reactionViewerRoot: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -271,6 +291,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
if (this.expiredTimeout) {
|
||||
clearTimeout(this.expiredTimeout);
|
||||
}
|
||||
this.toggleReactionViewer(true);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Props) {
|
||||
|
@ -945,6 +966,9 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
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<Props, State> {
|
|||
<div
|
||||
className={classNames(
|
||||
'module-message__buttons',
|
||||
`module-message__buttons--${direction}`
|
||||
`module-message__buttons--${direction}`,
|
||||
hasReactions ? 'module-message__buttons--has-reactions' : null
|
||||
)}
|
||||
>
|
||||
{first}
|
||||
|
@ -1289,6 +1314,165 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Manager>
|
||||
<Reference>
|
||||
{({ ref: popperRef }) => (
|
||||
<Measure
|
||||
bounds={true}
|
||||
onResize={({ bounds = { height: 0 } }) => {
|
||||
this.setState({ reactionsHeight: bounds.height });
|
||||
}}
|
||||
>
|
||||
{({ measureRef }) => (
|
||||
<div
|
||||
ref={mergeRefs(
|
||||
this.reactionsContainerRef,
|
||||
measureRef,
|
||||
popperRef
|
||||
)}
|
||||
className={classNames(
|
||||
'module-message__reactions',
|
||||
outgoing
|
||||
? 'module-message__reactions--outgoing'
|
||||
: 'module-message__reactions--incoming'
|
||||
)}
|
||||
>
|
||||
{toRender.map((re, i) => (
|
||||
<button
|
||||
key={`${re.emoji}-${i}`}
|
||||
className={classNames(
|
||||
'module-message__reactions__reaction',
|
||||
outgoing
|
||||
? 'module-message__reactions__reaction--outgoing'
|
||||
: 'module-message__reactions__reaction--incoming',
|
||||
re.isMe
|
||||
? 'module-message__reactions__reaction--is-me'
|
||||
: null
|
||||
)}
|
||||
style={{
|
||||
top: `${i * offset}px`,
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
this.toggleReactionViewer();
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
// Prevent enter key from opening stickers/attachments
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Emoji size={18} emoji={re.emoji} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Measure>
|
||||
)}
|
||||
</Reference>
|
||||
{reactionViewerRoot &&
|
||||
createPortal(
|
||||
<Popper placement={popperPlacement}>
|
||||
{({ ref, style }) => (
|
||||
<ReactionViewer
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
zIndex: 2,
|
||||
marginTop: -(height - reactionHeight * 0.75),
|
||||
...(outgoing
|
||||
? {
|
||||
marginRight: reactionHeight * -0.375,
|
||||
}
|
||||
: {
|
||||
marginLeft: reactionHeight * -0.375,
|
||||
}),
|
||||
}}
|
||||
reactions={reactions}
|
||||
i18n={i18n}
|
||||
onClose={this.toggleReactionViewer}
|
||||
/>
|
||||
)}
|
||||
</Popper>,
|
||||
reactionViewerRoot
|
||||
)}
|
||||
</Manager>
|
||||
);
|
||||
}
|
||||
|
||||
public renderContents() {
|
||||
const { isTapToView } = this.props;
|
||||
|
||||
|
@ -1564,6 +1748,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
{this.renderError(direction === 'outgoing')}
|
||||
{this.renderMenu(direction === 'incoming', triggerId)}
|
||||
{this.renderContextMenu(triggerId)}
|
||||
{this.renderReactions(direction === 'outgoing')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
118
ts/components/conversation/ReactionViewer.md
Normal file
118
ts/components/conversation/ReactionViewer.md
Normal file
|
@ -0,0 +1,118 @@
|
|||
### Reaction Viewer
|
||||
|
||||
#### Few Reactions
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme} ios={util.ios} mode={util.mode}>
|
||||
<ReactionViewer
|
||||
i18n={util.i18n}
|
||||
reactions={[
|
||||
{ emoji: '❤️', from: { id: '+14155552671', name: 'Amelia Briggs' } },
|
||||
{ emoji: '👍', from: { id: '+14155552671', name: 'Joel Ferrari' } },
|
||||
]}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Many Reactions
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme} ios={util.ios} mode={util.mode}>
|
||||
<ReactionViewer
|
||||
i18n={util.i18n}
|
||||
reactions={[
|
||||
{
|
||||
emoji: '❤️',
|
||||
timestamp: 1,
|
||||
from: { id: '+14155552671', name: 'Ameila Briggs' },
|
||||
},
|
||||
{
|
||||
emoji: '❤️',
|
||||
timestamp: 2,
|
||||
from: { id: '+14155552672', name: 'Adam Burrel' },
|
||||
},
|
||||
{
|
||||
emoji: '❤️',
|
||||
timestamp: 3,
|
||||
from: { id: '+14155552673', name: 'Rick Owens' },
|
||||
},
|
||||
{
|
||||
emoji: '❤️',
|
||||
timestamp: 4,
|
||||
from: { id: '+14155552674', name: 'Bojack Horseman' },
|
||||
},
|
||||
{
|
||||
emoji: '❤️',
|
||||
timestamp: 4,
|
||||
from: { id: '+14155552675', name: 'Cayce Pollard' },
|
||||
},
|
||||
{
|
||||
emoji: '❤️',
|
||||
timestamp: 5,
|
||||
from: { id: '+14155552676', name: 'Foo McBarrington' },
|
||||
},
|
||||
{
|
||||
emoji: '❤️',
|
||||
timestamp: 6,
|
||||
from: { id: '+14155552677', name: 'Ameila Briggs' },
|
||||
},
|
||||
{
|
||||
emoji: '❤️',
|
||||
timestamp: 7,
|
||||
from: { id: '+14155552678', name: 'Adam Burrel' },
|
||||
},
|
||||
{
|
||||
emoji: '❤️',
|
||||
timestamp: 8,
|
||||
from: { id: '+14155552679', name: 'Rick Owens' },
|
||||
},
|
||||
{
|
||||
emoji: '👍',
|
||||
timestamp: 9,
|
||||
from: { id: '+14155552671', name: 'Adam Burrel' },
|
||||
},
|
||||
{
|
||||
emoji: '👎',
|
||||
timestamp: 10,
|
||||
from: { id: '+14155552671', name: 'Rick Owens' },
|
||||
},
|
||||
{
|
||||
emoji: '😂',
|
||||
timestamp: 11,
|
||||
from: { id: '+14155552671', name: 'Bojack Horseman' },
|
||||
},
|
||||
{
|
||||
emoji: '😮',
|
||||
timestamp: 12,
|
||||
from: { id: '+14155552671', name: 'Cayce Pollard' },
|
||||
},
|
||||
{
|
||||
emoji: '😢',
|
||||
timestamp: 13,
|
||||
from: { id: '+14155552671', name: 'Foo McBarrington' },
|
||||
},
|
||||
{
|
||||
emoji: '😡',
|
||||
timestamp: 14,
|
||||
from: { id: '+14155552671', name: 'Foo McBarrington' },
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Name Overflow
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme} ios={util.ios} mode={util.mode}>
|
||||
<ReactionViewer
|
||||
i18n={util.i18n}
|
||||
reactions={[
|
||||
{
|
||||
emoji: '❤️',
|
||||
from: { id: '+14155552671', name: 'Foo McBarringtonMcBazzingtonMcKay' },
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
113
ts/components/conversation/ReactionViewer.tsx
Normal file
113
ts/components/conversation/ReactionViewer.tsx
Normal file
|
@ -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<Reaction>;
|
||||
onClose?: () => unknown;
|
||||
};
|
||||
|
||||
export type Props = OwnProps &
|
||||
Pick<React.HTMLProps<HTMLDivElement>, 'style'> &
|
||||
Pick<AvatarProps, 'i18n'>;
|
||||
|
||||
const emojis = ['❤️', '👍', '👎', '😂', '😮', '😢', '😡'];
|
||||
|
||||
export const ReactionViewer = React.forwardRef<HTMLDivElement, Props>(
|
||||
({ 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<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-viewer">
|
||||
<header className="module-reaction-viewer__header">
|
||||
{emojis
|
||||
.filter(e => Boolean(grouped[e]))
|
||||
.map((e, index) => {
|
||||
const re = grouped[e];
|
||||
const maybeFocusRef = index === 0 ? focusRef : undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={e}
|
||||
ref={maybeFocusRef}
|
||||
className={classNames(
|
||||
'module-reaction-viewer__header__button',
|
||||
selected === e
|
||||
? 'module-reaction-viewer__header__button--selected'
|
||||
: null
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelected(e);
|
||||
}}
|
||||
>
|
||||
<Emoji size={18} emoji={e} />
|
||||
<span className="module-reaction-viewer__header__button__count">
|
||||
{re.length}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</header>
|
||||
<main className="module-reaction-viewer__body">
|
||||
{grouped[selected].map(re => (
|
||||
<div
|
||||
key={`${re.from.id}-${re.emoji}`}
|
||||
className="module-reaction-viewer__body__row"
|
||||
>
|
||||
<div className="module-reaction-viewer__body__row__avatar">
|
||||
<Avatar
|
||||
avatarPath={re.from.avatarPath}
|
||||
conversationType="direct"
|
||||
size={32}
|
||||
i18n={i18n}
|
||||
/>
|
||||
</div>
|
||||
<span className="module-reaction-viewer__body__row__name">
|
||||
{re.from.name || re.from.profileName}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -918,6 +918,17 @@ export class Timeline extends React.PureComponent<Props, State> {
|
|||
|
||||
// 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();
|
||||
}
|
||||
|
|
|
@ -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}')` }
|
||||
: {};
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
26
ts/components/hooks.ts
Normal file
26
ts/components/hooks.ts
Normal file
|
@ -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<any>,
|
||||
// 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]);
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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<HTMLDivElement> = React.createRef();",
|
||||
"lineNumber": 153,
|
||||
"line": " public audioRef: React.RefObject<HTMLAudioElement> = 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",
|
||||
|
|
Loading…
Add table
Reference in a new issue