Basic and very raw implementation of the Reply To feature.
This commit is contained in:
+31
-1
@@ -1361,7 +1361,7 @@ img.channel {
|
|||||||
flex: none;
|
flex: none;
|
||||||
padding: 0.4rem 0.4rem 0.4rem 0.6rem;
|
padding: 0.4rem 0.4rem 0.4rem 0.6rem;
|
||||||
background-color: #eceff1;
|
background-color: #eceff1;
|
||||||
box-shadow: 0px -2px 3px -1px rgba(0,0,0,0.1); /* Faint shadow above panel */
|
/* box-shadow: 0px -2px 3px -1px rgba(0,0,0,0.1); */ /* Faint shadow above panel */
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
height: 4rem;
|
height: 4rem;
|
||||||
}
|
}
|
||||||
@@ -1372,6 +1372,12 @@ img.channel {
|
|||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#send-message-wrapper {
|
||||||
|
background-color: #eceff1;
|
||||||
|
box-shadow: 0px -2px 3px -1px rgba(0,0,0,0.1); /* Faint shadow above panel */
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
#sendMessage {
|
#sendMessage {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
@@ -1509,6 +1515,7 @@ img.channel {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
transition: background-color 1s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-box .right {
|
.chat-box .right {
|
||||||
@@ -2327,3 +2334,26 @@ ul.tabbar li.active > a {
|
|||||||
.cropper .zoom:focus {
|
.cropper .zoom:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Blue quote bar left of the quoted (cited) text */
|
||||||
|
.reply-quote {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
padding-left: 1em;
|
||||||
|
border-left: solid #039be5;
|
||||||
|
line-height: 1.5em;
|
||||||
|
font-weight: 100;
|
||||||
|
margin-left: 0.2em;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-quote-header {
|
||||||
|
color: #039be5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-quote-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ export const MAX_IMAGE_DIM = 1024;
|
|||||||
// Linear dimensions of image preview: shrink image under this size for previews.
|
// Linear dimensions of image preview: shrink image under this size for previews.
|
||||||
export const IMAGE_PREVIEW_DIM = 64;
|
export const IMAGE_PREVIEW_DIM = 64;
|
||||||
|
|
||||||
|
// Linear dimensions of image thumbnail: shrink image under this size for thumbnails in reply quote previews.
|
||||||
|
export const IMAGE_THUMBNAIL_DIM = 25;
|
||||||
|
|
||||||
// Maximum number of online users to be shown in a topic title bar. Others will be
|
// Maximum number of online users to be shown in a topic title bar. Others will be
|
||||||
// hidden under "+X more"
|
// hidden under "+X more"
|
||||||
export const MAX_ONLINE_IN_TOPIC = 4;
|
export const MAX_ONLINE_IN_TOPIC = 4;
|
||||||
|
|||||||
@@ -760,6 +760,13 @@
|
|||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
|
"menu_item_reply": {
|
||||||
|
"translation": "Reply",
|
||||||
|
"defaultMessage": "Reply",
|
||||||
|
"description": "Reply to message",
|
||||||
|
"missing": false,
|
||||||
|
"obsolete": false
|
||||||
|
},
|
||||||
"menu_item_restore_topic": {
|
"menu_item_restore_topic": {
|
||||||
"translation": "Restore",
|
"translation": "Restore",
|
||||||
"defaultMessage": "Restore",
|
"defaultMessage": "Restore",
|
||||||
|
|||||||
@@ -244,3 +244,27 @@ export function base64ReEncode(str) {
|
|||||||
}
|
}
|
||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert a base64 encoded string with the provided mime type into a Blob.
|
||||||
|
export function base64ToBlob(str, mime) {
|
||||||
|
if (!str) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Make blob.
|
||||||
|
const bin = atob(str);
|
||||||
|
const length = bin.length;
|
||||||
|
const buf = new ArrayBuffer(length);
|
||||||
|
const arr = new Uint8Array(buf);
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
arr[i] = bin.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Blob([buf], { type: mime });
|
||||||
|
} catch(err) {
|
||||||
|
console.log("Failed to convert base64 to blob: ", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
+291
-8
@@ -17,11 +17,13 @@ import LogoView from './logo-view.jsx';
|
|||||||
import SendMessage from '../widgets/send-message.jsx';
|
import SendMessage from '../widgets/send-message.jsx';
|
||||||
|
|
||||||
import { DEFAULT_P2P_ACCESS_MODE, IMAGE_PREVIEW_DIM, KEYPRESS_DELAY, MESSAGES_PAGE,
|
import { DEFAULT_P2P_ACCESS_MODE, IMAGE_PREVIEW_DIM, KEYPRESS_DELAY, MESSAGES_PAGE,
|
||||||
MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE, READ_DELAY } from '../config.js';
|
MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE, READ_DELAY,
|
||||||
import { SUPPORTED_IMAGE_FORMATS, blobToBase64, filePasted, fileToBase64,
|
IMAGE_THUMBNAIL_DIM, BROKEN_IMAGE_SIZE, REM_SIZE } from '../config.js';
|
||||||
imageScaled, makeImageUrl } from '../lib/blob-helpers.js';
|
import { SUPPORTED_IMAGE_FORMATS, blobToBase64, base64ToBlob, filePasted, fileToBase64,
|
||||||
|
imageScaled, makeImageUrl, fitImageSize } from '../lib/blob-helpers.js';
|
||||||
import HashNavigation from '../lib/navigation.js';
|
import HashNavigation from '../lib/navigation.js';
|
||||||
import { bytesToHumanSize, shortDateFormat } from '../lib/strformat.js';
|
import { bytesToHumanSize, shortDateFormat } from '../lib/strformat.js';
|
||||||
|
import { sanitizeImageUrl } from '../lib/utils.js';
|
||||||
|
|
||||||
// Run timer with this frequency (ms) for checking notification queue.
|
// Run timer with this frequency (ms) for checking notification queue.
|
||||||
const NOTIFICATION_EXEC_INTERVAL = 300;
|
const NOTIFICATION_EXEC_INTERVAL = 300;
|
||||||
@@ -51,7 +53,18 @@ const messages = defineMessages({
|
|||||||
id: 'file_attachment_too_large',
|
id: 'file_attachment_too_large',
|
||||||
defaultMessage: 'The file size {size} exceeds the {limit} limit.',
|
defaultMessage: 'The file size {size} exceeds the {limit} limit.',
|
||||||
description: 'Error message when attachment is too large'
|
description: 'Error message when attachment is too large'
|
||||||
}
|
},
|
||||||
|
|
||||||
|
drafty_form: {
|
||||||
|
id: 'drafty_form',
|
||||||
|
defaultMessage: 'Form: ',
|
||||||
|
description: 'Comment for form in drafty preview'
|
||||||
|
},
|
||||||
|
drafty_attachment: {
|
||||||
|
id: 'drafty_attachment',
|
||||||
|
defaultMessage: 'Attachment',
|
||||||
|
description: 'Comment for attachment in drafty preview'
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Checks if the access permissions are granted but not yet accepted.
|
// Checks if the access permissions are granted but not yet accepted.
|
||||||
@@ -101,6 +114,12 @@ class MessagesView extends React.Component {
|
|||||||
this.postReadNotification = this.postReadNotification.bind(this);
|
this.postReadNotification = this.postReadNotification.bind(this);
|
||||||
this.clearNotificationQueue = this.clearNotificationQueue.bind(this);
|
this.clearNotificationQueue = this.clearNotificationQueue.bind(this);
|
||||||
|
|
||||||
|
this.handleSendMessage = this.handleSendMessage.bind(this);
|
||||||
|
this.handlePickReply = this.handlePickReply.bind(this);
|
||||||
|
this.handleCancelReply = this.handleCancelReply.bind(this);
|
||||||
|
this.handleQuoteClick = this.handleQuoteClick.bind(this);
|
||||||
|
this.convertIntoThumbnails = this.convertIntoThumbnails.bind(this);
|
||||||
|
|
||||||
this.readNotificationQueue = [];
|
this.readNotificationQueue = [];
|
||||||
this.readNotificationTimer = null;
|
this.readNotificationTimer = null;
|
||||||
}
|
}
|
||||||
@@ -224,7 +243,8 @@ class MessagesView extends React.Component {
|
|||||||
scrollPosition: 0,
|
scrollPosition: 0,
|
||||||
fetchingMessages: false,
|
fetchingMessages: false,
|
||||||
peerMessagingDisabled: false,
|
peerMessagingDisabled: false,
|
||||||
channel: false
|
channel: false,
|
||||||
|
reply: null
|
||||||
};
|
};
|
||||||
} else if (nextProps.topic != prevState.topic) {
|
} else if (nextProps.topic != prevState.topic) {
|
||||||
const topic = nextProps.tinode.getTopic(nextProps.topic);
|
const topic = nextProps.tinode.getTopic(nextProps.topic);
|
||||||
@@ -235,7 +255,8 @@ class MessagesView extends React.Component {
|
|||||||
imagePostview: null,
|
imagePostview: null,
|
||||||
typingIndicator: false,
|
typingIndicator: false,
|
||||||
scrollPosition: 0,
|
scrollPosition: 0,
|
||||||
fetchingMessages: false
|
fetchingMessages: false,
|
||||||
|
reply: null
|
||||||
};
|
};
|
||||||
|
|
||||||
if (topic) {
|
if (topic) {
|
||||||
@@ -784,6 +805,164 @@ class MessagesView extends React.Component {
|
|||||||
uploader.cancel();
|
uploader.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handlePickReply(m) {
|
||||||
|
this.setState({reply: null})
|
||||||
|
//instance.setState({typingIndicator: false});
|
||||||
|
if (m) {
|
||||||
|
let cont = m.content
|
||||||
|
if (cont) {
|
||||||
|
if (typeof cont == 'string') {
|
||||||
|
cont = Drafty.init(cont);
|
||||||
|
}
|
||||||
|
cont = Drafty.preview(cont, 30, (src, target) => {
|
||||||
|
if (src.data && src.tp && src.tp == 'IM') {
|
||||||
|
// For images, copy val and ref fields.
|
||||||
|
['val', 'ref'].forEach(key => {
|
||||||
|
const val = src.data[key];
|
||||||
|
if (val) {
|
||||||
|
target.data[key] = val;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let header = '';
|
||||||
|
const seq = m.seq;
|
||||||
|
const idx = this.msgIndex(seq);
|
||||||
|
if (idx < this.state.messages.length) {
|
||||||
|
const m0 = this.state.messages[idx];
|
||||||
|
|
||||||
|
let thisFrom = m0.from || 'chan';
|
||||||
|
if (thisFrom != this.props.myUserId) {
|
||||||
|
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||||
|
const user = topic.userDesc(thisFrom);
|
||||||
|
if (user && user.public) {
|
||||||
|
header = user.public.fn;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
header = this.props.myUserName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header = Drafty.init(header);
|
||||||
|
// Make small image previews.
|
||||||
|
let ents = [];
|
||||||
|
if (cont.ent) {
|
||||||
|
Drafty.entities(cont, (data, idx, tp) => {
|
||||||
|
if (tp == 'IM') {
|
||||||
|
ents.push({
|
||||||
|
tp: tp,
|
||||||
|
data: data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}, this);
|
||||||
|
}
|
||||||
|
this.convertIntoThumbnails(ents, 0, (success) => {
|
||||||
|
if (success) {
|
||||||
|
const msg = Drafty.createQuote(header, cont);
|
||||||
|
this.setState({reply: {content: msg, seq: m.seq}});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msgIndex(seq) {
|
||||||
|
let l = -1;
|
||||||
|
let r = this.state.messages.length;
|
||||||
|
while (r > l + 1) {
|
||||||
|
const m = Math.floor((l + r) / 2);
|
||||||
|
if (this.state.messages[m].seq < seq) {
|
||||||
|
l = m;
|
||||||
|
} else {
|
||||||
|
r = m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn images in the provided Drafty.ent entries into thumbnails.
|
||||||
|
convertIntoThumbnails(ents, idx, done) {
|
||||||
|
if (idx >= ents.length) {
|
||||||
|
done(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scale(origBlob) {
|
||||||
|
imageScaled(origBlob, IMAGE_THUMBNAIL_DIM, IMAGE_THUMBNAIL_DIM, -1, false,
|
||||||
|
// Success
|
||||||
|
(mime, blob, width, height, fname) => {
|
||||||
|
let ex = ents[idx];
|
||||||
|
ex.data.mime = mime;
|
||||||
|
ex.data.val = blob;
|
||||||
|
ex.data.width = width;
|
||||||
|
ex.data.height = height;
|
||||||
|
ex.data.name = fname;
|
||||||
|
ex.data.ref = undefined;
|
||||||
|
|
||||||
|
blobToBase64(blob, (blobMime, tinyBits64) => {
|
||||||
|
ex.data.val = tinyBits64;
|
||||||
|
this.convertIntoThumbnails(ents, idx + 1, done);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// Failure
|
||||||
|
(err) => {
|
||||||
|
done(false);
|
||||||
|
this.props.onError(err, 'err');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const ex = ents[idx];
|
||||||
|
if (ex.data.val) {
|
||||||
|
const b = base64ToBlob(ex.data.val, ex.data.mime);
|
||||||
|
if (b) {
|
||||||
|
scale.call(this, b);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let saveThis = this;
|
||||||
|
const from = this.props.tinode.authorizeURL(sanitizeImageUrl(ex.data.ref));
|
||||||
|
fetch(from)
|
||||||
|
.then(e => e.blob())
|
||||||
|
.then(function(b) {
|
||||||
|
scale.call(saveThis, b);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSendMessage(msg) {
|
||||||
|
if (this.state.reply) {
|
||||||
|
let quote = this.state.reply.content;
|
||||||
|
if (quote) {
|
||||||
|
if (typeof msg == 'string') {
|
||||||
|
msg = Drafty.init(msg);
|
||||||
|
}
|
||||||
|
msg = Drafty.attachQuote(msg, quote);
|
||||||
|
let head = {replyToSeq: this.state.reply.seq};
|
||||||
|
|
||||||
|
this.props.sendMessage(msg, undefined, undefined, head);
|
||||||
|
this.setState({reply: null})
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.props.sendMessage(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCancelReply() {
|
||||||
|
this.setState({reply: null})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleQuoteClick(replyToSeq) {
|
||||||
|
const element = document.getElementById("msg-" + replyToSeq);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({block: "center", behavior: "smooth"});
|
||||||
|
element.style.backgroundColor = 'rgb(0, 0, 0, 0.4)';
|
||||||
|
setTimeout(() => { element.style.backgroundColor = ''; } , 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {formatMessage} = this.props.intl;
|
const {formatMessage} = this.props.intl;
|
||||||
|
|
||||||
@@ -898,6 +1077,12 @@ class MessagesView extends React.Component {
|
|||||||
onFormResponse={this.handleFormResponse}
|
onFormResponse={this.handleFormResponse}
|
||||||
onError={this.props.onError}
|
onError={this.props.onError}
|
||||||
onCancelUpload={this.handleCancelUpload}
|
onCancelUpload={this.handleCancelUpload}
|
||||||
|
|
||||||
|
pickReply={this.handlePickReply}
|
||||||
|
replyToSeq={msg.head ? msg.head.replyToSeq : null}
|
||||||
|
onQuoteClick={this.handleQuoteClick}
|
||||||
|
onFormatQuote={quoteFormatter}
|
||||||
|
|
||||||
key={msg.seq} />
|
key={msg.seq} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -994,11 +1179,18 @@ class MessagesView extends React.Component {
|
|||||||
:
|
:
|
||||||
<SendMessage
|
<SendMessage
|
||||||
disabled={!this.state.isWriter}
|
disabled={!this.state.isWriter}
|
||||||
onSendMessage={this.props.sendMessage}
|
//onSendMessage={this.props.sendMessage}
|
||||||
onKeyPress={this.sendKeyPress}
|
onKeyPress={this.sendKeyPress}
|
||||||
onAttachFile={this.handleAttachFile}
|
onAttachFile={this.handleAttachFile}
|
||||||
onAttachImage={this.handleAttachImage}
|
onAttachImage={this.handleAttachImage}
|
||||||
onError={this.props.onError} />}
|
onError={this.props.onError}
|
||||||
|
|
||||||
|
tinode={this.props.tinode}
|
||||||
|
replyTo={this.state.reply}
|
||||||
|
onQuoteClick={this.handleQuoteClick}
|
||||||
|
onSendMessage={this.handleSendMessage}
|
||||||
|
onFormatQuote={quoteFormatter}
|
||||||
|
onCancelReply={this.handleCancelReply} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1009,4 +1201,95 @@ class MessagesView extends React.Component {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Converts Drafty object into a one-line reply quote.
|
||||||
|
function quoteFormatter(style, data, values, key) {
|
||||||
|
let el = Drafty.tagName(style);
|
||||||
|
let attr = { key: key };
|
||||||
|
if (el) {
|
||||||
|
const { formatMessage } = this.props.intl;
|
||||||
|
switch (style) {
|
||||||
|
case 'BR':
|
||||||
|
// Replace new line with a space.
|
||||||
|
el = React.Fragment;
|
||||||
|
values = [' '];
|
||||||
|
break;
|
||||||
|
case 'HL':
|
||||||
|
// Make highlight less prominent in preview.
|
||||||
|
attr.className = 'highlight preview';
|
||||||
|
break;
|
||||||
|
case 'LN':
|
||||||
|
// Disable links in previews.
|
||||||
|
el = 'span';
|
||||||
|
break;
|
||||||
|
case 'IM':
|
||||||
|
// We show image thumbnails stored inline.
|
||||||
|
if (data) {
|
||||||
|
attr = Drafty.attrValue(style, data) || {};
|
||||||
|
attr.key = key;
|
||||||
|
attr.className = 'inline-image';
|
||||||
|
const dim = fitImageSize(data.width, data.height,
|
||||||
|
Math.min(this.props.viewportWidth - REM_SIZE * 6.5, REM_SIZE * 34.5), REM_SIZE * 24, false) ||
|
||||||
|
{dstWidth: BROKEN_IMAGE_SIZE, dstHeight: BROKEN_IMAGE_SIZE};
|
||||||
|
attr.style = {
|
||||||
|
width: dim.dstWidth + 'px',
|
||||||
|
height: dim.dstHeight + 'px',
|
||||||
|
// Looks like a Chrome bug: broken image does not respect 'width' and 'height'.
|
||||||
|
minWidth: dim.dstWidth + 'px',
|
||||||
|
minHeight: dim.dstHeight + 'px'
|
||||||
|
};
|
||||||
|
attr.src = this.props.tinode.authorizeURL(sanitizeImageUrl(attr.src));
|
||||||
|
attr.alt = data.name;
|
||||||
|
if (!attr.src) {
|
||||||
|
attr.src = 'img/broken_image.png';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'BN':
|
||||||
|
el = 'span';
|
||||||
|
attr.className = 'flat-button faux';
|
||||||
|
break;
|
||||||
|
case 'FM':
|
||||||
|
el = React.Fragment;
|
||||||
|
values = [<i key="fm" className="material-icons">dashboard</i>,
|
||||||
|
//messages.drafty_form].concat(values || []);
|
||||||
|
formatMessage(messages.drafty_form)].concat(values || []);
|
||||||
|
break;
|
||||||
|
case 'RW':
|
||||||
|
el = React.Fragment;
|
||||||
|
break;
|
||||||
|
case 'EX':
|
||||||
|
if (data && data.mime == 'application/json') {
|
||||||
|
// Ignore JSON attachments: they are form response payloads.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
el = React.Fragment;
|
||||||
|
values = [<i key="ex" className="material-icons">attachment</i>,
|
||||||
|
//messages.drafty_attachment];
|
||||||
|
formatMessage(messages.drafty_attachment)];
|
||||||
|
break;
|
||||||
|
case 'QQ':
|
||||||
|
// Quote/citation.
|
||||||
|
attr.className = 'reply-quote';
|
||||||
|
attr.onClick = this.handleQuoteClick;
|
||||||
|
break;
|
||||||
|
case 'QH':
|
||||||
|
// Quote/citation.
|
||||||
|
attr.className = 'reply-quote-header';
|
||||||
|
break;
|
||||||
|
case 'QB':
|
||||||
|
// Nothing.
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (el == '_UNKN') {
|
||||||
|
el = React.Fragment;
|
||||||
|
values = [<i key="unkn" className="material-icons">extension</i>];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return React.createElement(el, attr, values);
|
||||||
|
} else {
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default injectIntl(MessagesView);
|
export default injectIntl(MessagesView);
|
||||||
|
|||||||
@@ -899,13 +899,22 @@ class TinodeWeb extends React.Component {
|
|||||||
// - msg - Drafty message with content
|
// - msg - Drafty message with content
|
||||||
// - promise - Promise to be resolved when the upload is completed
|
// - promise - Promise to be resolved when the upload is completed
|
||||||
// - uploader - for tracking progress
|
// - uploader - for tracking progress
|
||||||
handleSendMessage(msg, promise, uploader) {
|
handleSendMessage(msg, promise, uploader, head) {
|
||||||
const topic = this.tinode.getTopic(this.state.topicSelected);
|
const topic = this.tinode.getTopic(this.state.topicSelected);
|
||||||
|
|
||||||
msg = topic.createMessage(msg, false);
|
msg = topic.createMessage(msg, false);
|
||||||
// The uploader is used to show progress.
|
// The uploader is used to show progress.
|
||||||
msg._uploader = uploader;
|
msg._uploader = uploader;
|
||||||
|
|
||||||
|
if (head) {
|
||||||
|
if (!msg.hasOwnProperty('head')) {
|
||||||
|
msg.head = {};
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(head)) {
|
||||||
|
msg.head[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!topic.isSubscribed()) {
|
if (!topic.isSubscribed()) {
|
||||||
if (!promise) {
|
if (!promise) {
|
||||||
promise = Promise.resolve();
|
promise = Promise.resolve();
|
||||||
@@ -1627,6 +1636,8 @@ class TinodeWeb extends React.Component {
|
|||||||
(this.state.mobilePanel !== 'topic-view' || this.state.infoPanel)}
|
(this.state.mobilePanel !== 'topic-view' || this.state.infoPanel)}
|
||||||
topic={this.state.topicSelected}
|
topic={this.state.topicSelected}
|
||||||
myUserId={this.state.myUserId}
|
myUserId={this.state.myUserId}
|
||||||
|
// User public.fn.
|
||||||
|
myUserName={this.state.sidePanelTitle}
|
||||||
serverVersion={this.state.serverVersion}
|
serverVersion={this.state.serverVersion}
|
||||||
serverAddress={this.state.serverAddress}
|
serverAddress={this.state.serverAddress}
|
||||||
applicationVisible={this.state.applicationVisible}
|
applicationVisible={this.state.applicationVisible}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single message, sent or received.
|
// Single message, sent or received.
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage, injectIntl } from 'react-intl';
|
||||||
import { Drafty } from 'tinode-sdk';
|
import { Drafty } from 'tinode-sdk';
|
||||||
|
|
||||||
import Attachment from './attachment.jsx';
|
import Attachment from './attachment.jsx';
|
||||||
@@ -9,7 +9,7 @@ import ReceivedMarker from './received-marker.jsx'
|
|||||||
import UploadingImage from './uploading-image.jsx'
|
import UploadingImage from './uploading-image.jsx'
|
||||||
import { sanitizeImageUrl, sanitizeUrl } from '../lib/utils.js';
|
import { sanitizeImageUrl, sanitizeUrl } from '../lib/utils.js';
|
||||||
|
|
||||||
export default class ChatMessage extends React.Component {
|
class ChatMessage extends React.PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
@@ -25,6 +25,8 @@ export default class ChatMessage extends React.Component {
|
|||||||
this.handleFormButtonClick = this.handleFormButtonClick.bind(this);
|
this.handleFormButtonClick = this.handleFormButtonClick.bind(this);
|
||||||
this.handleContextClick = this.handleContextClick.bind(this);
|
this.handleContextClick = this.handleContextClick.bind(this);
|
||||||
this.handleCancelUpload = this.handleCancelUpload.bind(this);
|
this.handleCancelUpload = this.handleCancelUpload.bind(this);
|
||||||
|
|
||||||
|
this.handleQuoteClick = this.handleQuoteClick.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleImagePreview(e) {
|
handleImagePreview(e) {
|
||||||
@@ -60,8 +62,12 @@ export default class ChatMessage extends React.Component {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const menuItems = this.props.received == Tinode.MESSAGE_STATUS_FAILED ? ['menu_item_send_retry'] : [];
|
const menuItems = this.props.received == Tinode.MESSAGE_STATUS_FAILED ? ['menu_item_send_retry'] : [];
|
||||||
|
if (this.props.received >= Tinode.MESSAGE_STATUS_FAILED &&
|
||||||
|
this.props.received < Tinode.MESSAGE_STATUS_DEL_RANGE) {
|
||||||
|
menuItems.push('menu_item_reply');
|
||||||
|
}
|
||||||
this.props.showContextMenu({ seq: this.props.seq, content: this.props.content,
|
this.props.showContextMenu({ seq: this.props.seq, content: this.props.content,
|
||||||
y: e.pageY, x: e.pageX }, menuItems);
|
y: e.pageY, x: e.pageX, pickReply: this.props.pickReply }, menuItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleProgress(ratio) {
|
handleProgress(ratio) {
|
||||||
@@ -72,6 +78,13 @@ export default class ChatMessage extends React.Component {
|
|||||||
this.props.onCancelUpload(this.props.seq, this.props.uploader);
|
this.props.onCancelUpload(this.props.seq, this.props.uploader);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleQuoteClick(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const replyToSeq = this.props.replyToSeq;
|
||||||
|
this.props.onQuoteClick(replyToSeq);
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const sideClass = this.props.deleted ? 'center' :
|
const sideClass = this.props.deleted ? 'center' :
|
||||||
(this.props.sequence + ' ' + (this.props.response ? 'left' : 'right'));
|
(this.props.sequence + ' ' + (this.props.response ? 'left' : 'right'));
|
||||||
@@ -80,6 +93,7 @@ export default class ChatMessage extends React.Component {
|
|||||||
const fullDisplay = (this.props.userFrom && this.props.response && !this.props.deleted &&
|
const fullDisplay = (this.props.userFrom && this.props.response && !this.props.deleted &&
|
||||||
(this.props.sequence == 'single' || this.props.sequence == 'last'));
|
(this.props.sequence == 'single' || this.props.sequence == 'last'));
|
||||||
|
|
||||||
|
const msgId = 'msg-' + this.props.seq;
|
||||||
let content = this.props.content;
|
let content = this.props.content;
|
||||||
const attachments = [];
|
const attachments = [];
|
||||||
if (this.props.mimeType == Drafty.getContentType() && Drafty.isValid(content)) {
|
if (this.props.mimeType == Drafty.getContentType() && Drafty.isValid(content)) {
|
||||||
@@ -99,7 +113,17 @@ export default class ChatMessage extends React.Component {
|
|||||||
onError={this.props.onError}
|
onError={this.props.onError}
|
||||||
key={i} />);
|
key={i} />);
|
||||||
}, this);
|
}, this);
|
||||||
content = React.createElement(React.Fragment, null, Drafty.format(content, draftyFormatter, this));
|
|
||||||
|
let qte = [];
|
||||||
|
if (content.qte) {
|
||||||
|
qte = Drafty.format(content.qte, this.props.onFormatQuote, this);
|
||||||
|
// Append a line break.
|
||||||
|
const lf = Drafty.appendLineBreak(null);
|
||||||
|
qte = qte.concat(Drafty.format(lf, draftyFormatter, this, qte.length));
|
||||||
|
}
|
||||||
|
let tree = Drafty.format(content, draftyFormatter, this, qte.length);
|
||||||
|
tree = qte.concat(tree);
|
||||||
|
content = React.createElement(React.Fragment, null, tree);
|
||||||
} else if (this.props.deleted) {
|
} else if (this.props.deleted) {
|
||||||
// Message represents a range of deleted messages.
|
// Message represents a range of deleted messages.
|
||||||
content = <><i className="material-icons gray">block</i> <i className="gray">
|
content = <><i className="material-icons gray">block</i> <i className="gray">
|
||||||
@@ -115,7 +139,7 @@ export default class ChatMessage extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={sideClass}>
|
<li id={msgId} className={sideClass}>
|
||||||
{this.props.userFrom && this.props.response ?
|
{this.props.userFrom && this.props.response ?
|
||||||
<div className="avatar-box">
|
<div className="avatar-box">
|
||||||
{fullDisplay ?
|
{fullDisplay ?
|
||||||
@@ -234,6 +258,18 @@ function draftyFormatter(style, data, values, key) {
|
|||||||
case 'RW':
|
case 'RW':
|
||||||
// Form element formatting is dependent on element content.
|
// Form element formatting is dependent on element content.
|
||||||
break;
|
break;
|
||||||
|
case 'QQ':
|
||||||
|
// Quote/citation.
|
||||||
|
attr.className = 'reply-quote'
|
||||||
|
attr.onClick = this.handleQuoteClick;
|
||||||
|
break;
|
||||||
|
case 'QH':
|
||||||
|
// Quote header.
|
||||||
|
attr.className = 'reply-quote-header';
|
||||||
|
break;
|
||||||
|
case 'QB':
|
||||||
|
// Quote body.
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
if (el == '_UNKN') {
|
if (el == '_UNKN') {
|
||||||
// Unknown element.
|
// Unknown element.
|
||||||
@@ -247,3 +283,5 @@ function draftyFormatter(style, data, values, key) {
|
|||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default injectIntl(ChatMessage);
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Unmute',
|
defaultMessage: 'Unmute',
|
||||||
description: 'Turn notifications on'
|
description: 'Turn notifications on'
|
||||||
},
|
},
|
||||||
|
reply: {
|
||||||
|
id: 'menu_item_reply',
|
||||||
|
defaultMessage: 'Reply',
|
||||||
|
description: 'Reply to message'
|
||||||
|
},
|
||||||
topic_delete: {
|
topic_delete: {
|
||||||
id: 'menu_item_delete_topic',
|
id: 'menu_item_delete_topic',
|
||||||
defaultMessage: 'Delete',
|
defaultMessage: 'Delete',
|
||||||
@@ -171,6 +176,13 @@ class ContextMenu extends React.Component {
|
|||||||
return this.retryMessage(params, errorHandler);
|
return this.retryMessage(params, errorHandler);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
'menu_item_reply': {
|
||||||
|
id: 'menu_item_reply',
|
||||||
|
title: formatMessage(messages.reply),
|
||||||
|
handler: (params, errorHandler) => {
|
||||||
|
return this.replyToMessage(params, errorHandler);
|
||||||
|
}
|
||||||
|
},
|
||||||
'topic_unmute': {
|
'topic_unmute': {
|
||||||
id: 'topic_unmute',
|
id: 'topic_unmute',
|
||||||
title: formatMessage(messages.unmute),
|
title: formatMessage(messages.unmute),
|
||||||
@@ -411,6 +423,11 @@ class ContextMenu extends React.Component {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
replyToMessage(params, errorHandler) {
|
||||||
|
console.log("Reply to message", params);
|
||||||
|
params.pickReply({ seq: params.seq, content: params.content })
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
let menu = [];
|
let menu = [];
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ class SendMessage extends React.PureComponent {
|
|||||||
this.handleSend = this.handleSend.bind(this);
|
this.handleSend = this.handleSend.bind(this);
|
||||||
this.handleKeyPress = this.handleKeyPress.bind(this);
|
this.handleKeyPress = this.handleKeyPress.bind(this);
|
||||||
this.handleMessageTyping = this.handleMessageTyping.bind(this);
|
this.handleMessageTyping = this.handleMessageTyping.bind(this);
|
||||||
|
|
||||||
|
this.handleQuoteClick = this.handleQuoteClick.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@@ -141,6 +143,17 @@ class SendMessage extends React.PureComponent {
|
|||||||
this.setState(newState);
|
this.setState(newState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleQuoteClick(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (this.props.replyTo) {
|
||||||
|
//quote = Drafty.format(this.props.replyTo.content, quoteFormatter, this);
|
||||||
|
//quote = Drafty.format(this.props.replyTo.content, this.props.onFormatQuote, this);
|
||||||
|
const replyToSeq = this.props.replyTo.seq;
|
||||||
|
this.props.onQuoteClick(replyToSeq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {formatMessage} = this.props.intl;
|
const {formatMessage} = this.props.intl;
|
||||||
const prompt = this.props.disabled ?
|
const prompt = this.props.disabled ?
|
||||||
@@ -148,7 +161,22 @@ class SendMessage extends React.PureComponent {
|
|||||||
(this.props.messagePrompt ?
|
(this.props.messagePrompt ?
|
||||||
formatMessage(messages[this.props.messagePrompt]) :
|
formatMessage(messages[this.props.messagePrompt]) :
|
||||||
formatMessage(messages.type_new_message));
|
formatMessage(messages.type_new_message));
|
||||||
|
|
||||||
|
let quote = null;
|
||||||
|
if (this.props.replyTo) {
|
||||||
|
quote = Drafty.format(this.props.replyTo.content, this.props.onFormatQuote, this);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div id="send-message-wrapper">
|
||||||
|
{quote ?
|
||||||
|
<div className="reply-quote-preview">
|
||||||
|
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onCancelReply();}}><i className="material-icons gray">close</i></a>
|
||||||
|
{quote}
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
null}
|
||||||
|
|
||||||
<div id="send-message-panel">
|
<div id="send-message-panel">
|
||||||
{!this.props.disabled ?
|
{!this.props.disabled ?
|
||||||
<>
|
<>
|
||||||
@@ -182,6 +210,7 @@ class SendMessage extends React.PureComponent {
|
|||||||
<div id="writing-disabled">{prompt}</div>
|
<div id="writing-disabled">{prompt}</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user