diff --git a/css/base.css b/css/base.css index 0a9fcb38..3d6be708 100644 --- a/css/base.css +++ b/css/base.css @@ -1361,7 +1361,7 @@ img.channel { flex: none; padding: 0.4rem 0.4rem 0.4rem 0.6rem; 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; height: 4rem; } @@ -1372,6 +1372,12 @@ img.channel { 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 { overflow: hidden; overflow-wrap: break-word; @@ -1509,6 +1515,7 @@ img.channel { margin: 0; padding: 0; align-items: flex-end; + transition: background-color 1s ease-out; } .chat-box .right { @@ -2327,3 +2334,26 @@ ul.tabbar li.active > a { .cropper .zoom:focus { 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; +} + diff --git a/src/config.js b/src/config.js index ff0e2db4..ac381ddc 100644 --- a/src/config.js +++ b/src/config.js @@ -78,6 +78,9 @@ export const MAX_IMAGE_DIM = 1024; // Linear dimensions of image preview: shrink image under this size for previews. 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 // hidden under "+X more" export const MAX_ONLINE_IN_TOPIC = 4; diff --git a/src/i18n/en.json b/src/i18n/en.json index 32a61b3d..f3e2fd40 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -760,6 +760,13 @@ "missing": false, "obsolete": false }, + "menu_item_reply": { + "translation": "Reply", + "defaultMessage": "Reply", + "description": "Reply to message", + "missing": false, + "obsolete": false + }, "menu_item_restore_topic": { "translation": "Restore", "defaultMessage": "Restore", diff --git a/src/lib/blob-helpers.js b/src/lib/blob-helpers.js index 92634661..c598ea9b 100644 --- a/src/lib/blob-helpers.js +++ b/src/lib/blob-helpers.js @@ -244,3 +244,27 @@ export function base64ReEncode(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; +} diff --git a/src/views/messages-view.jsx b/src/views/messages-view.jsx index 203bb7b8..b7c795e1 100644 --- a/src/views/messages-view.jsx +++ b/src/views/messages-view.jsx @@ -17,11 +17,13 @@ import LogoView from './logo-view.jsx'; import SendMessage from '../widgets/send-message.jsx'; 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'; -import { SUPPORTED_IMAGE_FORMATS, blobToBase64, filePasted, fileToBase64, - imageScaled, makeImageUrl } from '../lib/blob-helpers.js'; + MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE, READ_DELAY, + IMAGE_THUMBNAIL_DIM, BROKEN_IMAGE_SIZE, REM_SIZE } from '../config.js'; +import { SUPPORTED_IMAGE_FORMATS, blobToBase64, base64ToBlob, filePasted, fileToBase64, + imageScaled, makeImageUrl, fitImageSize } from '../lib/blob-helpers.js'; import HashNavigation from '../lib/navigation.js'; import { bytesToHumanSize, shortDateFormat } from '../lib/strformat.js'; +import { sanitizeImageUrl } from '../lib/utils.js'; // Run timer with this frequency (ms) for checking notification queue. const NOTIFICATION_EXEC_INTERVAL = 300; @@ -51,7 +53,18 @@ const messages = defineMessages({ id: 'file_attachment_too_large', defaultMessage: 'The file size {size} exceeds the {limit} limit.', 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. @@ -101,6 +114,12 @@ class MessagesView extends React.Component { this.postReadNotification = this.postReadNotification.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.readNotificationTimer = null; } @@ -224,7 +243,8 @@ class MessagesView extends React.Component { scrollPosition: 0, fetchingMessages: false, peerMessagingDisabled: false, - channel: false + channel: false, + reply: null }; } else if (nextProps.topic != prevState.topic) { const topic = nextProps.tinode.getTopic(nextProps.topic); @@ -235,7 +255,8 @@ class MessagesView extends React.Component { imagePostview: null, typingIndicator: false, scrollPosition: 0, - fetchingMessages: false + fetchingMessages: false, + reply: null }; if (topic) { @@ -784,6 +805,164 @@ class MessagesView extends React.Component { 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() { const {formatMessage} = this.props.intl; @@ -898,6 +1077,12 @@ class MessagesView extends React.Component { onFormResponse={this.handleFormResponse} onError={this.props.onError} onCancelUpload={this.handleCancelUpload} + + pickReply={this.handlePickReply} + replyToSeq={msg.head ? msg.head.replyToSeq : null} + onQuoteClick={this.handleQuoteClick} + onFormatQuote={quoteFormatter} + key={msg.seq} /> ); } @@ -994,11 +1179,18 @@ class MessagesView extends React.Component { : } + 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 = [dashboard, + //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 = [attachment, + //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 = [extension]; + } + break; + } + return React.createElement(el, attr, values); + } else { + return values; + } +}; + export default injectIntl(MessagesView); diff --git a/src/views/tinode-web.jsx b/src/views/tinode-web.jsx index aef8710c..7b7ec84f 100644 --- a/src/views/tinode-web.jsx +++ b/src/views/tinode-web.jsx @@ -899,13 +899,22 @@ class TinodeWeb extends React.Component { // - msg - Drafty message with content // - promise - Promise to be resolved when the upload is completed // - uploader - for tracking progress - handleSendMessage(msg, promise, uploader) { + handleSendMessage(msg, promise, uploader, head) { const topic = this.tinode.getTopic(this.state.topicSelected); msg = topic.createMessage(msg, false); // The uploader is used to show progress. 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 (!promise) { promise = Promise.resolve(); @@ -1627,6 +1636,8 @@ class TinodeWeb extends React.Component { (this.state.mobilePanel !== 'topic-view' || this.state.infoPanel)} topic={this.state.topicSelected} myUserId={this.state.myUserId} + // User public.fn. + myUserName={this.state.sidePanelTitle} serverVersion={this.state.serverVersion} serverAddress={this.state.serverAddress} applicationVisible={this.state.applicationVisible} diff --git a/src/widgets/chat-message.jsx b/src/widgets/chat-message.jsx index b49445fc..ce599c7a 100644 --- a/src/widgets/chat-message.jsx +++ b/src/widgets/chat-message.jsx @@ -1,6 +1,6 @@ // Single message, sent or received. import React from 'react'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, injectIntl } from 'react-intl'; import { Drafty } from 'tinode-sdk'; import Attachment from './attachment.jsx'; @@ -9,7 +9,7 @@ import ReceivedMarker from './received-marker.jsx' import UploadingImage from './uploading-image.jsx' import { sanitizeImageUrl, sanitizeUrl } from '../lib/utils.js'; -export default class ChatMessage extends React.Component { +class ChatMessage extends React.PureComponent { constructor(props) { super(props); @@ -25,6 +25,8 @@ export default class ChatMessage extends React.Component { this.handleFormButtonClick = this.handleFormButtonClick.bind(this); this.handleContextClick = this.handleContextClick.bind(this); this.handleCancelUpload = this.handleCancelUpload.bind(this); + + this.handleQuoteClick = this.handleQuoteClick.bind(this); } handleImagePreview(e) { @@ -60,8 +62,12 @@ export default class ChatMessage extends React.Component { e.preventDefault(); e.stopPropagation(); 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, - y: e.pageY, x: e.pageX }, menuItems); + y: e.pageY, x: e.pageX, pickReply: this.props.pickReply }, menuItems); } handleProgress(ratio) { @@ -72,6 +78,13 @@ export default class ChatMessage extends React.Component { 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() { const sideClass = this.props.deleted ? 'center' : (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 && (this.props.sequence == 'single' || this.props.sequence == 'last')); + const msgId = 'msg-' + this.props.seq; let content = this.props.content; const attachments = []; if (this.props.mimeType == Drafty.getContentType() && Drafty.isValid(content)) { @@ -99,7 +113,17 @@ export default class ChatMessage extends React.Component { onError={this.props.onError} key={i} />); }, 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) { // Message represents a range of deleted messages. content = <>block @@ -115,7 +139,7 @@ export default class ChatMessage extends React.Component { } return ( -
  • +
  • {this.props.userFrom && this.props.response ?
    {fullDisplay ? @@ -234,6 +258,18 @@ function draftyFormatter(style, data, values, key) { case 'RW': // Form element formatting is dependent on element content. 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: if (el == '_UNKN') { // Unknown element. @@ -247,3 +283,5 @@ function draftyFormatter(style, data, values, key) { return values; } }; + +export default injectIntl(ChatMessage); diff --git a/src/widgets/context-menu.jsx b/src/widgets/context-menu.jsx index 6777c4a8..83bd9701 100644 --- a/src/widgets/context-menu.jsx +++ b/src/widgets/context-menu.jsx @@ -45,6 +45,11 @@ const messages = defineMessages({ defaultMessage: 'Unmute', description: 'Turn notifications on' }, + reply: { + id: 'menu_item_reply', + defaultMessage: 'Reply', + description: 'Reply to message' + }, topic_delete: { id: 'menu_item_delete_topic', defaultMessage: 'Delete', @@ -171,6 +176,13 @@ class ContextMenu extends React.Component { 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': { id: 'topic_unmute', title: formatMessage(messages.unmute), @@ -411,6 +423,11 @@ class ContextMenu extends React.Component { return result; } + replyToMessage(params, errorHandler) { + console.log("Reply to message", params); + params.pickReply({ seq: params.seq, content: params.content }) + } + render() { let count = 0; let menu = []; diff --git a/src/widgets/send-message.jsx b/src/widgets/send-message.jsx index bf268d50..d02ca082 100644 --- a/src/widgets/send-message.jsx +++ b/src/widgets/send-message.jsx @@ -50,6 +50,8 @@ class SendMessage extends React.PureComponent { this.handleSend = this.handleSend.bind(this); this.handleKeyPress = this.handleKeyPress.bind(this); this.handleMessageTyping = this.handleMessageTyping.bind(this); + + this.handleQuoteClick = this.handleQuoteClick.bind(this); } componentDidMount() { @@ -141,6 +143,17 @@ class SendMessage extends React.PureComponent { 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() { const {formatMessage} = this.props.intl; const prompt = this.props.disabled ? @@ -148,7 +161,22 @@ class SendMessage extends React.PureComponent { (this.props.messagePrompt ? formatMessage(messages[this.props.messagePrompt]) : formatMessage(messages.type_new_message)); + + let quote = null; + if (this.props.replyTo) { + quote = Drafty.format(this.props.replyTo.content, this.props.onFormatQuote, this); + } + return ( +
    + {quote ? + + : + null} +
    {!this.props.disabled ? <> @@ -182,6 +210,7 @@ class SendMessage extends React.PureComponent {
    {prompt}
    }
    +
    ); } };