Select multiple messages and bulk deletion

pull/592/head
Maxim Shishmarev 6 years ago
parent f6d9d2a606
commit 8677fb15a0

@ -49,7 +49,7 @@ module.exports = {
quotes: [ quotes: [
'error', 'error',
'single', 'single',
{ avoidEscape: true, allowTemplateLiterals: false }, { avoidEscape: true, allowTemplateLiterals: true },
], ],
// Prettier overrides: // Prettier overrides:

@ -965,10 +965,18 @@
"message": "message":
"Are you sure? Clicking 'delete' will permanently remove this message for everyone in this channel." "Are you sure? Clicking 'delete' will permanently remove this message for everyone in this channel."
}, },
"deleteMultiplePublicWarning": {
"message":
"Are you sure? Clicking 'delete' will permanently remove these messages for everyone in this channel."
},
"deleteWarning": { "deleteWarning": {
"message": "message":
"Are you sure? Clicking 'delete' will permanently remove this message from this device only." "Are you sure? Clicking 'delete' will permanently remove this message from this device only."
}, },
"deleteMultipleWarning": {
"message":
"Are you sure? Clicking 'delete' will permanently remove these messages from this device only."
},
"deleteThisMessage": { "deleteThisMessage": {
"message": "Delete this message" "message": "Delete this message"
}, },
@ -1974,6 +1982,10 @@
"description": "description":
"Button action that the user can click to copy their public keys" "Button action that the user can click to copy their public keys"
}, },
"selectMessage": {
"message": "Select message",
"description": "Button action that the user can click to select the message"
},
"copiedMessage": { "copiedMessage": {
"message": "Copied message text", "message": "Copied message text",
"description": "description":

@ -127,6 +127,7 @@
<div class='bottom-bar' id='footer'> <div class='bottom-bar' id='footer'>
<div class='emoji-panel-container'></div> <div class='emoji-panel-container'></div>
<div class='member-list-container'></div> <div class='member-list-container'></div>
<div id='bulk-edit-view'></div>
<div class='attachment-list'></div> <div class='attachment-list'></div>
<div class='compose'> <div class='compose'>
<form class='send clearfix file-input'> <form class='send clearfix file-input'>
@ -704,6 +705,7 @@
<script type='text/javascript' src='js/views/key_verification_view.js'></script> <script type='text/javascript' src='js/views/key_verification_view.js'></script>
<script type='text/javascript' src='js/views/message_list_view.js'></script> <script type='text/javascript' src='js/views/message_list_view.js'></script>
<script type='text/javascript' src='js/views/member_list_view.js'></script> <script type='text/javascript' src='js/views/member_list_view.js'></script>
<script type='text/javascript' src='js/views/bulk_edit_view.js'></script>
<script type='text/javascript' src='js/views/group_member_list_view.js'></script> <script type='text/javascript' src='js/views/group_member_list_view.js'></script>
<script type='text/javascript' src='js/views/recorder_view.js'></script> <script type='text/javascript' src='js/views/recorder_view.js'></script>
<script type='text/javascript' src='js/views/conversation_view.js'></script> <script type='text/javascript' src='js/views/conversation_view.js'></script>

@ -181,6 +181,8 @@
this.messageSendQueue = new JobQueue(); this.messageSendQueue = new JobQueue();
this.selectedMessages = new Set();
// Keep props ready // Keep props ready
const generateProps = () => { const generateProps = () => {
this.cachedProps = this.getProps(); this.cachedProps = this.getProps();
@ -219,6 +221,43 @@
this.messageCollection.forEach(m => m.trigger('change')); this.messageCollection.forEach(m => m.trigger('change'));
}, },
addMessageSelection(id) {
// If the selection is empty, then we chage the mode to
// multiple selection by making it non-empty
const modeChanged = this.selectedMessages.size === 0;
this.selectedMessages.add(id);
if (modeChanged) {
this.messageCollection.forEach(m => m.trigger('change'));
}
this.trigger('message-selection-changed');
},
removeMessageSelection(id) {
this.selectedMessages.delete(id);
// If the selection is empty after the deletion then we
// must have unselected the last one (we assume the id is valid)
const modeChanged = this.selectedMessages.size === 0;
if (modeChanged) {
this.messageCollection.forEach(m => m.trigger('change'));
}
this.trigger('message-selection-changed');
},
resetMessageSelection() {
this.selectedMessages.clear();
this.messageCollection.forEach(m => {
// eslint-disable-next-line no-param-reassign
m.selected = false;
m.trigger('change');
});
this.trigger('message-selection-changed');
},
bumpTyping() { bumpTyping() {
// We don't send typing messages if the setting is disabled or we aren't friends // We don't send typing messages if the setting is disabled or we aren't friends
if (!this.isFriend() || !storage.get('typing-indicators-setting')) { if (!this.isFriend() || !storage.get('typing-indicators-setting')) {
@ -484,6 +523,8 @@
hasNickname: !!this.getNickname(), hasNickname: !!this.getNickname(),
isFriend: this.isFriend(), isFriend: this.isFriend(),
selectedMessages: this.selectedMessages,
onClick: () => this.trigger('select', this), onClick: () => this.trigger('select', this),
onBlockContact: () => this.block(), onBlockContact: () => this.block(),
onUnblockContact: () => this.unblock(), onUnblockContact: () => this.unblock(),
@ -2440,24 +2481,35 @@
}); });
}, },
async deletePublicMessage(message) { async deletePublicMessages(messages) {
const channelAPI = await this.getPublicSendData(); const channelAPI = await this.getPublicSendData();
if (!channelAPI) { if (!channelAPI) {
return false; return false;
} }
const serverId = message.getServerId();
const success = serverId let success;
? await channelAPI.deleteMessage(serverId) const shouldBeDeleted = [];
: false; if (messages.length > 1) {
success = await channelAPI.deleteMessages(
const shouldDeleteLocally = success || message.hasErrors() || !serverId; messages.map(m => m.getServerId())
// If the message has errors it is likely not saved );
// on the server, so we delete it locally unconditionally } else {
if (shouldDeleteLocally) { success = await channelAPI.deleteMessages([messages[0].getServerId()]);
this.removeMessage(message.id);
} }
messages.forEach(m => {
// If the message has errors it is likely not saved
// on the server, so we delete it locally unconditionally
const shouldDeleteLocally = success || m.hasErrors() || !m.getServerId();
if (shouldDeleteLocally) {
this.removeMessage(m.id);
}
});
/// TODO: not sure what to return here
return shouldDeleteLocally; return shouldDeleteLocally;
}, },

@ -125,6 +125,8 @@
); );
} }
this.selected = false;
generateProps(); generateProps();
}, },
idForLogging() { idForLogging() {
@ -639,6 +641,8 @@
isExpired: this.hasExpired, isExpired: this.hasExpired,
expirationLength, expirationLength,
expirationTimestamp, expirationTimestamp,
selected: this.selected,
multiSelectMode: conversation && conversation.selectedMessages.size > 0,
isP2p: !!this.get('isP2p'), isP2p: !!this.get('isP2p'),
isPublic: !!this.get('isPublic'), isPublic: !!this.get('isPublic'),
isRss: !!this.get('isRss'), isRss: !!this.get('isRss'),
@ -651,6 +655,7 @@
this.getSource() === this.OUR_NUMBER, this.getSource() === this.OUR_NUMBER,
onCopyText: () => this.copyText(), onCopyText: () => this.copyText(),
onSelectMessage: () => this.selectMessage(),
onCopyPubKey: () => this.copyPubKey(), onCopyPubKey: () => this.copyPubKey(),
onReply: () => this.trigger('reply', this), onReply: () => this.trigger('reply', this),
onRetrySend: () => this.retrySend(), onRetrySend: () => this.retrySend(),
@ -949,6 +954,20 @@
}); });
}, },
selectMessage() {
this.selected = !this.selected;
const convo = this.getConversation();
if (this.selected) {
convo.addMessageSelection(this);
} else {
convo.removeMessageSelection(this);
}
this.trigger('change');
},
copyText() { copyText() {
clipboard.writeText(this.get('body')); clipboard.writeText(this.get('body'));
window.Whisper.events.trigger('showToast', { window.Whisper.events.trigger('showToast', {

@ -519,20 +519,18 @@ class LokiPublicChannelAPI {
} }
} }
// delete a message on the server // delete messages on the server
async deleteMessage(serverId, canThrow = false) { async deleteMessages(serverIds, canThrow = false) {
const res = await this.serverRequest( const res = await this.serverRequest(
this.modStatus this.modStatus ? `loki/v1/moderation/messages` : `loki/v1/messages`,
? `loki/v1/moderation/message/${serverId}` { method: 'DELETE', params: { ids: serverIds } }
: `${this.baseChannelUrl}/messages/${serverId}`,
{ method: 'DELETE' }
); );
if (!res.err && res.response) { if (!res.err && res.response) {
log.info(`deleted ${serverId} on ${this.baseChannelUrl}`); log.info(`deleted ${serverIds} on ${this.baseChannelUrl}`);
return true; return true;
} }
// fire an alert // fire an alert
log.warn(`failed to delete ${serverId} on ${this.baseChannelUrl}`); log.warn(`failed to delete ${serverIds} on ${this.baseChannelUrl}`);
if (canThrow) { if (canThrow) {
throw new textsecure.PublicChatError( throw new textsecure.PublicChatError(
'Failed to delete public chat message' 'Failed to delete public chat message'

@ -44,6 +44,7 @@ const { Lightbox } = require('../../ts/components/Lightbox');
const { LightboxGallery } = require('../../ts/components/LightboxGallery'); const { LightboxGallery } = require('../../ts/components/LightboxGallery');
const { MainHeader } = require('../../ts/components/MainHeader'); const { MainHeader } = require('../../ts/components/MainHeader');
const { MemberList } = require('../../ts/components/conversation/MemberList'); const { MemberList } = require('../../ts/components/conversation/MemberList');
const { BulkEdit } = require('../../ts/components/conversation/BulkEdit');
const { const {
CreateGroupDialog, CreateGroupDialog,
} = require('../../ts/components/conversation/CreateGroupDialog'); } = require('../../ts/components/conversation/CreateGroupDialog');
@ -229,6 +230,7 @@ exports.setup = (options = {}) => {
CreateGroupDialog, CreateGroupDialog,
ConfirmDialog, ConfirmDialog,
UpdateGroupDialog, UpdateGroupDialog,
BulkEdit,
MediaGallery, MediaGallery,
Message, Message,
MessageBody, MessageBody,

@ -0,0 +1,41 @@
/* global Whisper, */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.BulkEditView = Whisper.View.extend({
initialize(options) {
this.selectedMessages = new Set();
this.render();
this.onCancel = options.onCancel;
this.onDelete = options.onDelete;
},
render() {
if (this.memberView) {
this.memberView.remove();
this.memberView = null;
}
this.memberView = new Whisper.ReactWrapperView({
className: 'bulk-edit-view',
Component: window.Signal.Components.BulkEdit,
props: {
messageCount: this.selectedMessages.size,
onCancel: this.onCancel,
onDelete: this.onDelete,
},
});
this.$el.append(this.memberView.el);
return this;
},
update(selectedMessages) {
this.selectedMessages = selectedMessages;
this.render();
},
});
})();

@ -147,6 +147,11 @@
'show-message-detail', 'show-message-detail',
this.showMessageDetail this.showMessageDetail
); );
this.listenTo(
this.model,
'message-selection-changed',
this.onMessageSelectionChanged
);
this.listenTo(this.model.messageCollection, 'navigate-to', url => { this.listenTo(this.model.messageCollection, 'navigate-to', url => {
window.location = url; window.location = url;
}); });
@ -303,6 +308,12 @@
this.memberView.render(); this.memberView.render();
this.bulkEditView = new Whisper.BulkEditView({
el: this.$('#bulk-edit-view'),
onCancel: this.resetMessageSelection.bind(this),
onDelete: this.deleteSelectedMessages.bind(this),
});
this.$messageField = this.$('.send-message'); this.$messageField = this.$('.send-message');
this.onResize = this.forceUpdateMessageFieldSize.bind(this); this.onResize = this.forceUpdateMessageFieldSize.bind(this);
@ -1353,31 +1364,57 @@
}); });
}, },
deleteMessage(message) { deleteSelectedMessages() {
const warningMessage = this.model.isPublic() const msgArray = Array.from(this.model.selectedMessages);
? i18n('deletePublicWarning')
: i18n('deleteWarning'); this.deleteMessages(msgArray, () => {
this.resetMessageSelection();
});
},
deleteMessages(messages, onSuccess) {
const multiple = messages.length > 1;
const warningMessage = (() => {
if (this.model.isPublic()) {
return multiple
? i18n('deleteMultiplePublicWarning')
: i18n('deletePublicWarning');
}
return multiple ? i18n('deleteMultipleWarning') : i18n('deleteWarning');
})();
const doDelete = async () => { const doDelete = async () => {
if (this.model.isPublic()) { if (this.model.isPublic()) {
const success = await this.model.deletePublicMessage(message); const success = await this.model.deletePublicMessages(messages);
if (!success) { if (!success) {
// Message failed to delete from server, show error? // Message failed to delete from server, show error?
return; return;
} }
} else { } else {
this.model.messageCollection.remove(message.id); messages.forEach(m => this.model.messageCollection.remove(m.id));
} }
await window.Signal.Data.removeMessage(message.id, {
Message: Whisper.Message, await Promise.all(
}); messages.map(async m => {
message.trigger('unload'); await window.Signal.Data.removeMessage(m.id, {
Message: Whisper.Message,
});
m.trigger('unload');
})
);
this.resetPanel(); this.resetPanel();
this.updateHeader(); this.updateHeader();
if (onSuccess) {
onSuccess();
}
}; };
// The message wasn't saved, so we don't show any warning // Only show a warning when at least one messages was successfully
if (message.hasErrors()) { // saved in on the server
if (!messages.some(m => !m.hasErrors())) {
doDelete(); doDelete();
return; return;
} }
@ -1392,6 +1429,10 @@
dialog.focusCancel(); dialog.focusCancel();
}, },
deleteMessage(message) {
this.deleteMessages([message]);
},
showLightbox({ attachment, message }) { showLightbox({ attachment, message }) {
const { contentType, path } = attachment; const { contentType, path } = attachment;
@ -1735,6 +1776,22 @@
} }
}, },
onMessageSelectionChanged() {
const selectionSize = this.model.selectedMessages.size;
if (selectionSize > 0) {
$('.compose').hide();
} else {
$('.compose').show();
}
this.bulkEditView.update(this.model.selectedMessages);
},
resetMessageSelection() {
this.model.resetMessageSelection();
},
toggleEmojiPanel(e) { toggleEmojiPanel(e) {
e.preventDefault(); e.preventDefault();
if (!this.emojiPanel) { if (!this.emojiPanel) {
@ -1747,6 +1804,9 @@
if (event.key !== 'Escape') { if (event.key !== 'Escape') {
return; return;
} }
// TODO: this view is not always in focus (e.g. after I've selected a message),
// so need to make Esc more robust
this.model.resetMessageSelection();
this.closeEmojiPanel(); this.closeEmojiPanel();
}, },
openEmojiPanel() { openEmojiPanel() {

@ -136,13 +136,8 @@
.message-list { .message-list {
list-style: none; list-style: none;
.message-wrapper {
margin-left: 16px;
margin-right: 16px;
}
li { li {
margin-bottom: 10px; margin-bottom: 2px;
&::after { &::after {
visibility: hidden; visibility: hidden;
@ -155,12 +150,92 @@
} }
} }
.group { .module-message__check-box {
.message-container, color: rgb(97, 97, 97);
.message-list { font-size: 20px;
.message-wrapper { padding: 4px;
margin-left: 44px; user-select: none;
} display: inline;
}
.check-box-container {
// background-color: blue;
align-items: center;
flex-direction: row;
display: inline-flex;
}
.check-box-visible {
transition-duration: 200ms;
opacity: 0.1;
width: 40px;
}
.check-box-invisible {
transition-duration: 200ms;
opacity: 0;
width: 0px;
}
.check-box-selected {
opacity: 1;
}
.loki-message-wrapper {
.react-contextmenu-wrapper {
display: inline-flex;
width: 100%;
}
}
.loki-message-wrapper {
padding-left: 16px;
padding-right: 16px;
}
.loki-message-wrapper {
display: flow-root;
padding-bottom: 4px;
padding-top: 4px;
}
.message-selected {
background-color: #60554060;
}
.bulk-edit-container {
display: flex;
border-top: solid;
border-width: 0.8px;
border-color: #80808090;
&.hidden {
display: none;
}
.delete-button {
color: orangered;
padding: 18px;
// This makes sure the message counter is right in the center
width: 80px;
margin-right: -80px;
user-select: none;
}
.cancel-button {
padding: 18px;
width: 80px;
margin-left: -80px;
user-select: none;
}
.message-counter {
color: darkgrey;
display: flex;
align-items: center;
user-select: none;
margin-left: auto;
margin-right: auto;
} }
} }

@ -99,8 +99,8 @@
} }
.module-message__buttons__download { .module-message__buttons__download {
height: 24px; min-height: 24px;
width: 24px; min-width: 24px;
display: inline-block; display: inline-block;
cursor: pointer; cursor: pointer;
@include color-svg('../images/download.svg', $color-light-45); @include color-svg('../images/download.svg', $color-light-45);
@ -117,8 +117,8 @@
} }
.module-message__buttons__reply { .module-message__buttons__reply {
height: 24px; min-height: 24px;
width: 24px; min-width: 24px;
display: inline-block; display: inline-block;
cursor: pointer; cursor: pointer;
@include color-svg('../images/reply.svg', $color-light-45); @include color-svg('../images/reply.svg', $color-light-45);
@ -531,6 +531,7 @@
letter-spacing: 0.3px; letter-spacing: 0.3px;
color: $color-gray-60; color: $color-gray-60;
text-transform: uppercase; text-transform: uppercase;
user-select: none;
} }
.module-message__metadata__badge { .module-message__metadata__badge {
@ -614,9 +615,9 @@
} }
.module-message__author-avatar { .module-message__author-avatar {
position: absolute; flex-direction: column-reverse;
bottom: 0px; display: inline-flex;
right: calc(100% + 4px); padding-right: 4px;
} }
.module-message__typing-container { .module-message__typing-container {
@ -2074,9 +2075,9 @@
} }
.module-avatar__icon--crown-wrapper { .module-avatar__icon--crown-wrapper {
position: absolute; position: relative;
bottom: 0; bottom: -38px;
right: 0; right: -16px;
height: 21px; height: 21px;
width: 21px; width: 21px;
transform: translate(25%, 25%); transform: translate(25%, 25%);

@ -556,6 +556,7 @@
<script type='text/javascript' src='../js/views/key_verification_view.js' data-cover></script> <script type='text/javascript' src='../js/views/key_verification_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/message_list_view.js' data-cover></script> <script type='text/javascript' src='../js/views/message_list_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/member_list_view.js' data-cover></script> <script type='text/javascript' src='../js/views/member_list_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/bulk_edit_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/group_member_list_view.js' data-cover></script> <script type='text/javascript' src='../js/views/group_member_list_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/recorder_view.js' data-cover></script> <script type='text/javascript' src='../js/views/recorder_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/conversation_view.js' data-cover></script> <script type='text/javascript' src='../js/views/conversation_view.js' data-cover></script>

@ -0,0 +1,44 @@
import React from 'react';
import classNames from 'classnames';
interface Props {
messageCount: number;
onCancel: any;
onDelete: any;
}
export class BulkEdit extends React.Component<Props> {
constructor(props: any) {
super(props);
}
public render() {
const classes = ['bulk-edit-container'];
if (this.props.messageCount === 0) {
classes.push('hidden');
}
return (
<div className={classNames(classes)}>
<span
className="delete-button"
role="button"
onClick={this.props.onDelete}
>
Delete
</span>
<span className="message-counter">
Messages selected: {this.props.messageCount}
</span>
<span
className="cancel-button"
role="button"
onClick={this.props.onCancel}
>
Cancel
</span>
</div>
);
}
}

@ -67,8 +67,9 @@ export class Image extends React.Component<Props> {
return ( return (
<div <div
role={role} role={role}
onClick={() => { onClick={(e: any) => {
if (canClick && onClick) { if (canClick && onClick) {
e.stopPropagation();
onClick(attachment); onClick(attachment);
} }
}} }}

@ -97,10 +97,14 @@ export interface Props {
isP2p?: boolean; isP2p?: boolean;
isPublic?: boolean; isPublic?: boolean;
isRss?: boolean; isRss?: boolean;
selected: boolean;
// whether or not to show check boxes
multiSelectMode: boolean;
onClickAttachment?: (attachment: AttachmentType) => void; onClickAttachment?: (attachment: AttachmentType) => void;
onClickLinkPreview?: (url: string) => void; onClickLinkPreview?: (url: string) => void;
onCopyText?: () => void; onCopyText?: () => void;
onSelectMessage: () => void;
onReply?: () => void; onReply?: () => void;
onRetrySend?: () => void; onRetrySend?: () => void;
onDownload?: (isDangerous: boolean) => void; onDownload?: (isDangerous: boolean) => void;
@ -313,42 +317,6 @@ export class Message extends React.PureComponent<Props, State> {
); );
} }
public renderAuthor() {
const {
authorName,
authorPhoneNumber,
authorProfileName,
conversationType,
direction,
i18n,
} = this.props;
const title = authorName ? authorName : authorPhoneNumber;
if (direction !== 'incoming' || conversationType !== 'group' || !title) {
return null;
}
const shortenedPubkey = window.shortenPubkey(authorPhoneNumber);
const displayedPubkey = authorProfileName
? shortenedPubkey
: authorPhoneNumber;
return (
<div className="module-message__author">
<ContactName
phoneNumber={displayedPubkey}
name={authorName}
profileName={authorProfileName}
module="module-message__author"
i18n={i18n}
boldProfileName={true}
/>
</div>
);
}
// tslint:disable-next-line max-func-body-length cyclomatic-complexity // tslint:disable-next-line max-func-body-length cyclomatic-complexity
public renderAttachment() { public renderAttachment() {
const { const {
@ -815,10 +783,11 @@ export class Message extends React.PureComponent<Props, State> {
const downloadButton = const downloadButton =
!multipleAttachments && firstAttachment && !firstAttachment.pending ? ( !multipleAttachments && firstAttachment && !firstAttachment.pending ? (
<div <div
onClick={() => { onClick={(e: any) => {
if (onDownload) { if (onDownload) {
onDownload(isDangerous); onDownload(isDangerous);
} }
e.stopPropagation();
}} }}
role="button" role="button"
className={classNames( className={classNames(
@ -830,7 +799,12 @@ export class Message extends React.PureComponent<Props, State> {
const replyButton = ( const replyButton = (
<div <div
onClick={onReply} onClick={(e: any) => {
if (onReply) {
onReply();
}
e.stopPropagation();
}}
role="button" role="button"
className={classNames( className={classNames(
'module-message__buttons__reply', 'module-message__buttons__reply',
@ -873,6 +847,7 @@ export class Message extends React.PureComponent<Props, State> {
const { const {
attachments, attachments,
onCopyText, onCopyText,
onSelectMessage,
direction, direction,
status, status,
isDeletable, isDeletable,
@ -892,6 +867,15 @@ export class Message extends React.PureComponent<Props, State> {
const isDangerous = isFileDangerous(fileName || ''); const isDangerous = isFileDangerous(fileName || '');
const multipleAttachments = attachments && attachments.length > 1; const multipleAttachments = attachments && attachments.length > 1;
// Wraps a function to prevent event propagation, thus preventing
// message selection whenever any of the menu buttons are pressed.
const wrap = (f: any) => (event: Event) => {
event.stopPropagation();
if (f) {
f();
}
};
return ( return (
<ContextMenu id={triggerId}> <ContextMenu id={triggerId}>
{!multipleAttachments && attachments && attachments[0] ? ( {!multipleAttachments && attachments && attachments[0] ? (
@ -899,7 +883,8 @@ export class Message extends React.PureComponent<Props, State> {
attributes={{ attributes={{
className: 'module-message__context__download', className: 'module-message__context__download',
}} }}
onClick={() => { onClick={(e: Event) => {
e.stopPropagation();
if (onDownload) { if (onDownload) {
onDownload(isDangerous); onDownload(isDangerous);
} }
@ -908,12 +893,16 @@ export class Message extends React.PureComponent<Props, State> {
{i18n('downloadAttachment')} {i18n('downloadAttachment')}
</MenuItem> </MenuItem>
) : null} ) : null}
<MenuItem onClick={onCopyText}>{i18n('copyMessage')}</MenuItem>
<MenuItem onClick={wrap(onCopyText)}>{i18n('copyMessage')}</MenuItem>
<MenuItem onClick={wrap(onSelectMessage)}>
{i18n('selectMessage')}
</MenuItem>
<MenuItem <MenuItem
attributes={{ attributes={{
className: 'module-message__context__reply', className: 'module-message__context__reply',
}} }}
onClick={onReply} onClick={wrap(onReply)}
> >
{i18n('replyToMessage')} {i18n('replyToMessage')}
</MenuItem> </MenuItem>
@ -921,7 +910,7 @@ export class Message extends React.PureComponent<Props, State> {
attributes={{ attributes={{
className: 'module-message__context__more-info', className: 'module-message__context__more-info',
}} }}
onClick={onShowDetail} onClick={wrap(onShowDetail)}
> >
{i18n('moreInfo')} {i18n('moreInfo')}
</MenuItem> </MenuItem>
@ -930,7 +919,7 @@ export class Message extends React.PureComponent<Props, State> {
attributes={{ attributes={{
className: 'module-message__context__retry-send', className: 'module-message__context__retry-send',
}} }}
onClick={onRetrySend} onClick={wrap(onRetrySend)}
> >
{i18n('retrySend')} {i18n('retrySend')}
</MenuItem> </MenuItem>
@ -940,13 +929,15 @@ export class Message extends React.PureComponent<Props, State> {
attributes={{ attributes={{
className: 'module-message__context__delete-message', className: 'module-message__context__delete-message',
}} }}
onClick={onDelete} onClick={wrap(onDelete)}
> >
{i18n('deleteMessage')} {i18n('deleteMessage')}
</MenuItem> </MenuItem>
) : null} ) : null}
{isPublic ? ( {isPublic ? (
<MenuItem onClick={onCopyPubKey}>{i18n('copyPublicKey')}</MenuItem> <MenuItem onClick={wrap(onCopyPubKey)}>
{i18n('copyPublicKey')}
</MenuItem>
) : null} ) : null}
</ContextMenu> </ContextMenu>
); );
@ -1025,6 +1016,8 @@ export class Message extends React.PureComponent<Props, State> {
id, id,
isRss, isRss,
timestamp, timestamp,
selected,
multiSelectMode,
} = this.props; } = this.props;
const { expired, expiring } = this.state; const { expired, expiring } = this.state;
@ -1049,13 +1042,32 @@ export class Message extends React.PureComponent<Props, State> {
const mentionMe = const mentionMe =
mentions && mentions &&
mentions.some(m => m.slice(1) === window.lokiPublicChatAPI.ourKey); mentions.some(m => m.slice(1) === window.lokiPublicChatAPI.ourKey);
const shouldHightlight =
mentionMe && direction === 'incoming' && this.props.isPublic; const isIncoming = direction === 'incoming';
const divClass = shouldHightlight ? 'message-highlighted' : ''; const shouldHightlight = mentionMe && isIncoming && this.props.isPublic;
const divClasses = ['loki-message-wrapper'];
if (shouldHightlight) {
divClasses.push('message-highlighted');
}
if (selected) {
divClasses.push('message-selected');
}
return ( return (
<div className={divClass}> <div
className={classNames(divClasses)}
role="button"
onClick={() => {
const selection = window.getSelection();
if (selection && selection.type === 'Range') {
return;
}
this.props.onSelectMessage();
}}
>
<ContextMenuTrigger id={rightClickTriggerId}> <ContextMenuTrigger id={rightClickTriggerId}>
{this.renderCheckBox()}
{this.renderAvatar()}
<div <div
className={classNames( className={classNames(
'module-message', 'module-message',
@ -1063,15 +1075,15 @@ export class Message extends React.PureComponent<Props, State> {
expiring ? 'module-message--expired' : null expiring ? 'module-message--expired' : null
)} )}
> >
{this.renderError(direction === 'incoming')} {this.renderError(isIncoming)}
{isRss {isRss
? null ? null
: this.renderMenu(direction === 'outgoing', triggerId)} : this.renderMenu(!isIncoming, triggerId)}
<div <div
className={classNames( className={classNames(
'module-message__container', 'module-message__container',
`module-message__container--${direction}`, `module-message__container--${direction}`,
direction === 'incoming' isIncoming
? `module-message__container--incoming-${authorColor}` ? `module-message__container--incoming-${authorColor}`
: null : null
)} )}
@ -1087,17 +1099,74 @@ export class Message extends React.PureComponent<Props, State> {
{this.renderText()} {this.renderText()}
{this.renderMetadata()} {this.renderMetadata()}
{this.renderSendMessageButton()} {this.renderSendMessageButton()}
{this.renderAvatar()}
</div> </div>
{this.renderError(direction === 'outgoing')} {this.renderError(!isIncoming)}
{isRss {(isRss || multiSelectMode)
? null
: this.renderMenu(isIncoming, triggerId)}
{multiSelectMode ? null : this.renderContextMenu(triggerId)}
{multiSelectMode
? null ? null
: this.renderMenu(direction === 'incoming', triggerId)} : this.renderContextMenu(rightClickTriggerId)}
{this.renderContextMenu(triggerId)}
{this.renderContextMenu(rightClickTriggerId)}
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
</div> </div>
); );
} }
private renderCheckBox() {
const classes = ['check-box-container'];
if (this.props.multiSelectMode) {
classes.push('check-box-visible');
} else {
classes.push('check-box-invisible');
}
if (this.props.selected) {
classes.push('check-box-selected');
}
return (
<div className={classNames(classes)}>
<span className="module-message__check-box"></span>
</div>
);
}
private renderAuthor() {
const {
authorName,
authorPhoneNumber,
authorProfileName,
conversationType,
direction,
i18n,
} = this.props;
const title = authorName ? authorName : authorPhoneNumber;
if (direction !== 'incoming' || conversationType !== 'group' || !title) {
return null;
}
const shortenedPubkey = window.shortenPubkey(authorPhoneNumber);
const displayedPubkey = authorProfileName
? shortenedPubkey
: authorPhoneNumber;
return (
<div className="module-message__author">
<ContactName
phoneNumber={displayedPubkey}
name={authorName}
profileName={authorProfileName}
module="module-message__author"
i18n={i18n}
boldProfileName={true}
/>
</div>
);
}
} }

Loading…
Cancel
Save