diff --git a/css/base.css b/css/base.css index 0a09839d..6b99c721 100644 --- a/css/base.css +++ b/css/base.css @@ -1377,7 +1377,6 @@ 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 */ z-index: 2; height: 4rem; } @@ -2154,6 +2153,106 @@ ul.tabbar li.active > a { color: #D84315!important; } +/* Colors of user mentions */ +.mn-light-color0 { + color: #ef9a9a; +} +.mn-light-color1 { + color: #90caf9; +} +.mn-light-color2 { + color: #b0bec5; +} +.mn-light-color3 { + color: #b39ddb; +} +.mn-light-color4 { + color: #ffab91; +} +.mn-light-color5 { + color: #a5d6a7; +} +.mn-light-color6 { + color: #eeeeee; +} +.mn-light-color7 { + color: #e6ee9c; +} +.mn-light-color8 { + color: #c5e1a5; +} +.mn-light-color9 { + color: #f0e57d; +} +.mn-light-color10 { + color: #f48fb1; +} +.mn-light-color11 { + color: #9fa8da; +} +.mn-light-color12 { + color: #ffe082; +} +.mn-light-color13 { + color: #bcaaa4; +} +.mn-light-color14 { + color: #80deea; +} +.mn-light-color15 { + color: #ce93d8; +} + +/* Darker version of user mention colors */ +.mn-dark-color0 { + color: #C62828; +} +.mn-dark-color1 { + color: #AD1457; +} +.mn-dark-color2 { + color: #6A1B9A; +} +.mn-dark-color3 { + color: #4527A0; +} +.mn-dark-color4 { + color: #283593; +} +.mn-dark-color5 { + color: #1565C0; +} +.mn-dark-color6 { + color: #0277BD; +} +.mn-dark-color7 { + color: #00838F; +} +.mn-dark-color8 { + color: #00695C; +} +.mn-dark-color9 { + color: #2E7D32; +} +.mn-dark-color10 { + color: #558B2F; +} +.mn-dark-color11 { + color: #9E9D24; +} +.mn-dark-color12 { + color: #F9A825; +} +.mn-dark-color13 { + color: #FF8F00; +} +.mn-dark-color14 { + color: #EF6C00; +} +.mn-dark-color15 { + color: #D84315; +} + /* Material icons resized from the default 24pt */ .material-icons { font-size: 1.4rem; @@ -2463,8 +2562,6 @@ ul.tabbar li.active > a { line-height: 1.5em; font-weight: 100; margin-left: 0.2em; - display: flex; - flex-direction: column; } .reply-quote-header { @@ -2475,4 +2572,3 @@ ul.tabbar li.active > a { display: flex; align-items: center; } - diff --git a/src/lib/utils.js b/src/lib/utils.js index 168ad323..54a63d61 100644 --- a/src/lib/utils.js +++ b/src/lib/utils.js @@ -2,6 +2,13 @@ import Tinode from 'tinode-sdk'; +import { stringHash } from './strformat.js'; + +// User avatar letter color id. +export function letterTileColor(userId) { + return Math.abs(stringHash(userId)) % 16; +} + // Make shortcut icon appear with a green dot + show unread count in title. export function updateFavicon(count) { const oldIcon = document.getElementById('shortcut-icon'); diff --git a/src/views/messages-view.jsx b/src/views/messages-view.jsx index 20c202a1..50c5c155 100644 --- a/src/views/messages-view.jsx +++ b/src/views/messages-view.jsx @@ -23,7 +23,7 @@ import { SUPPORTED_IMAGE_FORMATS, blobToBase64, base64ToBlob, filePasted, fileTo 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'; +import { sanitizeImageUrl, letterTileColor } from '../lib/utils.js'; // Run timer with this frequency (ms) for checking notification queue. const NOTIFICATION_EXEC_INTERVAL = 300; @@ -114,6 +114,7 @@ class MessagesView extends React.Component { this.postReadNotification = this.postReadNotification.bind(this); this.clearNotificationQueue = this.clearNotificationQueue.bind(this); + this.doSendMessage = this.doSendMessage.bind(this); this.handleSendMessage = this.handleSendMessage.bind(this); this.handlePickReply = this.handlePickReply.bind(this); this.handleCancelReply = this.handleCancelReply.bind(this); @@ -652,6 +653,17 @@ class MessagesView extends React.Component { } } + doSendMessage(msg, uploadCompletionPromise, uploader) { + let head = null; + if (this.state.reply && this.state.reply.content) { + head = {replyToSeq: this.state.reply.seq}; + let quote = this.state.reply.content; + msg = Drafty.attachQuote(msg, quote); + } + this.props.sendMessage(msg, uploadCompletionPromise, uploader, head); + this.setState({reply: null}); + } + // Send attachment as Drafty message: // - if file is too large, upload it and send a s link. // - if file is small enough, just send it in-band. @@ -671,12 +683,13 @@ class MessagesView extends React.Component { urlPromise: uploadCompletionPromise }); // Pass data and the uploader to the TinodeWeb. - this.props.sendMessage(msg, uploadCompletionPromise, uploader); + this.doSendMessage(msg, uploadCompletionPromise, uploader); } else { // Small enough to send inband. fileToBase64(file, (mime, bits, fname) => { - this.props.sendMessage(Drafty.attachFile(null, {mime: mime, data: bits, filename: fname})); + const msg = Drafty.attachFile(null, {mime: mime, data: bits, filename: fname}); + this.doSendMessage(msg, undefined, undefined); }, this.props.onError ); @@ -737,10 +750,10 @@ class MessagesView extends React.Component { }); if (caption) { msg = Drafty.appendLineBreak(msg); - msg = Drafty.append(msg, Drafty.init(caption)); + msg = Drafty.append(msg, Drafty.parse(caption)); } // Pass data and the uploader to the TinodeWeb. - this.props.sendMessage(msg, uploadCompletionPromise, uploader); + this.doSendMessage(msg, uploadCompletionPromise, uploader); } )}, // Failure @@ -763,9 +776,9 @@ class MessagesView extends React.Component { }); if (caption) { msg = Drafty.appendLineBreak(msg); - msg = Drafty.append(msg, Drafty.init(caption)); + msg = Drafty.append(msg, Drafty.parse(caption)); } - this.props.sendMessage(msg); + this.doSendMessage(msg, undefined, undefined); }); } @@ -804,33 +817,23 @@ class MessagesView extends React.Component { } handlePickReply(m) { - this.setState({reply: null}) - //instance.setState({typingIndicator: false}); + this.setState({reply: null}); 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; - } - }); - } - }); + cont = Drafty.replyPreview(cont, 30, quotePreviewFmt); + // Get the author. 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'; + const topic = this.props.tinode.getTopic(this.state.topic); + const m0 = topic.findMessage(seq); + let thisFrom; + if (m0) { + thisFrom = m0.from || 'chan'; if (thisFrom != this.props.myUserId) { const topic = this.props.tinode.getTopic(this.state.topic); const user = topic.userDesc(thisFrom); @@ -844,7 +847,7 @@ class MessagesView extends React.Component { header = Drafty.init(header); // Make small image previews. - let ents = []; + const ents = []; if (cont.ent) { Drafty.entities(cont, (data, idx, tp) => { if (tp == 'IM') { @@ -858,7 +861,7 @@ class MessagesView extends React.Component { } this.convertIntoThumbnails(ents, 0, (success) => { if (success) { - const msg = Drafty.createQuote(header, cont); + const msg = Drafty.createQuote(header, cont, letterTileColor(thisFrom)); this.setState({reply: {content: msg, seq: m.seq}}); } }); @@ -868,20 +871,6 @@ class MessagesView extends React.Component { } } - 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) { @@ -889,13 +878,14 @@ class MessagesView extends React.Component { return; } - function scale(origBlob) { + const scale = (origBlob) => { imageScaled(origBlob, IMAGE_THUMBNAIL_DIM, IMAGE_THUMBNAIL_DIM, -1, false, // Success (mime, blob, width, height, fname) => { - let ex = ents[idx]; + const ex = ents[idx]; + ex.data.mime = mime; - ex.data.val = blob; + ex.data.size = blob.size; ex.data.width = width; ex.data.height = height; ex.data.name = fname; @@ -919,11 +909,11 @@ class MessagesView extends React.Component { scale.call(this, b); } } else { - let saveThis = this; + //let saveThis = this; const from = this.props.tinode.authorizeURL(sanitizeImageUrl(ex.data.ref)); fetch(from) .then(e => e.blob()) - .then(function(b) { + .then((b) => { scale.call(saveThis, b); }); return; @@ -931,21 +921,11 @@ class MessagesView extends React.Component { } 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; - } + if (this.state.reply && this.state.reply.content && typeof msg == 'string') { + // Turn it into Drafty so we can make a quoted Drafty object later. + msg = Drafty.parse(msg); } - this.props.sendMessage(msg) + this.doSendMessage(msg, undefined, undefined); } handleCancelReply() { @@ -980,6 +960,10 @@ class MessagesView extends React.Component { component2 = ( ); @@ -995,6 +979,10 @@ class MessagesView extends React.Component { component2 = ( ); @@ -1074,7 +1062,7 @@ class MessagesView extends React.Component { pickReply={this.handlePickReply} replyToSeq={msg.head ? msg.head.replyToSeq : null} onQuoteClick={this.handleQuoteClick} - onFormatQuote={quoteFormatter} + onFormatQuote={draftyFormatter} key={msg.seq} /> ); @@ -1172,7 +1160,6 @@ class MessagesView extends React.Component { : } ); @@ -1194,31 +1181,69 @@ class MessagesView extends React.Component { } }; -// Converts Drafty object into a one-line reply quote. -function quoteFormatter(style, data, values, key) { +function quotePreviewFmt(fmt, ent) { + let tp = fmt.tp; + if (!tp) { + if (!ent || !ent.tp) { + return [null, null]; + } + tp = ent.tp; + } + const new_fmt = {at: fmt.at, len: fmt.len, tp: fmt.tp}; + switch (tp) { + case 'BR': + // Replace new line with a space. + return [null, null]; + case 'HL': + return [new_fmt, ent]; + case 'LN': + // Disable links in previews. + return [null, null]; + case 'IM': + // Keep images as is. + return [new_fmt, ent]; + case 'BN': + new_fmt.tp = null; + return [new_fmt, { tp: 'IC', data: { orig: 'BN', iconName: 'dashboard', iconTitle: 'drafty_form'} }]; + case 'FM': + new_fmt.tp = null; + new_fmt.len = 0; + return [new_fmt, {tp: 'IC', data: { orig: 'FM', iconName: 'dashboard', iconTitle: 'drafty_form'} }]; + case 'RW': + return [null, null]; + case 'EX': + // Make it an icon. + new_fmt.tp = null; + return [new_fmt, {tp: 'IC', data: { orig: 'EX', iconName: 'attachment', iconTitle: 'drafty_attachment'} }]; + case 'QQ': + // Quote/citation. + return [null,null]; + default: + return [new_fmt, ent]; + } +} + +// Converts Drafty elements into React classes. +// 'this' is set by the caller. +function draftyFormatter(style, data, values, key) { + if (style == 'EX') { + // attachments are handled elsewhere. + return null; + } + let el = Drafty.tagName(style); - let attr = { key: key }; if (el) { const { formatMessage } = this.props.intl; + let attr = Drafty.attrValue(style, data) || {}; + attr.key = key; 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'; + // Highlighted text. Assign class name. + attr.className = 'highlight'; break; case 'IM': - // We show image thumbnails stored inline. + // Additional processing for images 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) || @@ -1230,52 +1255,69 @@ function quoteFormatter(style, data, values, key) { 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'; + if (!Drafty.isProcessing(data)) { + attr.src = this.props.tinode.authorizeURL(sanitizeImageUrl(attr.src)); + attr.alt = data.name; + if (attr.src) { + attr.onClick = this.handleImagePreview; + attr.className += ' image-clickable'; + attr.loading = 'lazy'; + } else { + attr.src = 'img/broken_image.png'; + } + } else { + // Use custom element instead of . + el = UploadingImage; } } break; case 'BN': - el = 'span'; - attr.className = 'flat-button faux'; + // Button + attr.onClick = this.handleFormButtonClick; + let inner = React.Children.map(values, (child) => { + return typeof child == 'string' ? child : undefined; + }); + if (!inner || inner.length == 0) { + inner = [attr.name] + } + // Get text which will be sent back when the button is clicked. + attr['data-title'] = inner.join(''); + break; + case 'MN': + // Mention + if (data && data.hasOwnProperty('color')) { + attr.className = 'mn-dark-color' + data.color; + } break; case 'FM': - el = React.Fragment; - values = [dashboard, - //messages.drafty_form].concat(values || []); - formatMessage(messages.drafty_form)].concat(values || []); + // Form + attr.className = 'bot-form'; 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)]; + // Form element formatting is dependent on element content. break; case 'QQ': // Quote/citation. - attr.className = 'reply-quote'; + attr.className = 'reply-quote' attr.onClick = this.handleQuoteClick; break; - case 'QH': - // Quote/citation. - attr.className = 'reply-quote-header'; - break; - case 'QB': - // Nothing. + case 'IC': + // Icon. + if (data.orig == 'BN') { + attr.className = 'flat-button faux'; + } else { + el = React.Fragment; + //values = [] + const iconKey = data.orig.toLowerCase(); + values = [{data.iconName}, + formatMessage(messages[data.iconTitle])].concat(values || []); + } break; default: if (el == '_UNKN') { - el = React.Fragment; - values = [extension]; + // Unknown element. + // TODO: make it prettier. + el = <>extension; } break; } diff --git a/src/views/tinode-web.jsx b/src/views/tinode-web.jsx index ca0ab751..016180ec 100644 --- a/src/views/tinode-web.jsx +++ b/src/views/tinode-web.jsx @@ -904,6 +904,7 @@ class TinodeWeb extends React.Component { // - msg - Drafty message with content // - promise - Promise to be resolved when the upload is completed // - uploader - for tracking progress + // - head - head dictionary to be attached to the message handleSendMessage(msg, promise, uploader, head) { const topic = this.tinode.getTopic(this.state.topicSelected); @@ -912,12 +913,7 @@ class TinodeWeb extends React.Component { msg._uploader = uploader; if (head) { - if (!msg.hasOwnProperty('head')) { - msg.head = {}; - } - for (const [key, value] of Object.entries(head)) { - msg.head[key] = value; - } + msg.head = Object.assign(msg.head || {}, head); } if (!topic.isSubscribed()) { diff --git a/src/widgets/chat-message.jsx b/src/widgets/chat-message.jsx index 97451a93..4014d025 100644 --- a/src/widgets/chat-message.jsx +++ b/src/widgets/chat-message.jsx @@ -116,16 +116,7 @@ class ChatMessage extends React.PureComponent { onError={this.props.onError} key={i} />); }, 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); + const tree = Drafty.format(content, this.props.formatter, this); content = React.createElement(React.Fragment, null, tree); } else if (this.props.deleted) { // Message represents a range of deleted messages. @@ -191,100 +182,4 @@ class ChatMessage extends React.PureComponent { } }; -// Convert Drafty object to a tree of React elements. -import { BROKEN_IMAGE_SIZE, REM_SIZE } from '../config.js'; -import { fitImageSize } from '../lib/blob-helpers.js'; - -// Converts Drafty elements into React classes. -// 'this' is set by the caller. -function draftyFormatter(style, data, values, key) { - if (style == 'EX') { - // attachments are handled elsewhere. - return null; - } - - let el = Drafty.tagName(style); - if (el) { - const attr = Drafty.attrValue(style, data) || {}; - attr.key = key; - switch (style) { - case 'HL': - // Highlighted text. Assign class name. - attr.className = 'highlight'; - break; - case 'IM': - // Additional processing for images - if (data) { - 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' - }; - if (!Drafty.isProcessing(data)) { - attr.src = this.props.tinode.authorizeURL(sanitizeImageUrl(attr.src)); - attr.alt = data.name; - if (attr.src) { - attr.onClick = this.handleImagePreview; - attr.className += ' image-clickable'; - attr.loading = 'lazy'; - } else { - attr.src = 'img/broken_image.png'; - } - } else { - // Use custom element instead of . - el = UploadingImage; - } - } - break; - case 'BN': - // Button - attr.onClick = this.handleFormButtonClick; - let inner = React.Children.map(values, (child) => { - return typeof child == 'string' ? child : undefined; - }); - if (!inner || inner.length == 0) { - inner = [attr.name] - } - // Get text which will be sent back when the button is clicked. - attr['data-title'] = inner.join(''); - break; - case 'FM': - // Form - attr.className = 'bot-form'; - break; - 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. - // TODO: make it prettier. - el = <>extension; - } - break; - } - return React.createElement(el, attr, values); - } else { - return values; - } -}; - export default injectIntl(ChatMessage); diff --git a/src/widgets/doc-preview.jsx b/src/widgets/doc-preview.jsx index ca9a3af7..6ba01982 100644 --- a/src/widgets/doc-preview.jsx +++ b/src/widgets/doc-preview.jsx @@ -45,6 +45,10 @@ export default class DocPreview extends React.PureComponent { diff --git a/src/widgets/image-preview.jsx b/src/widgets/image-preview.jsx index 15b20405..c0018ce3 100644 --- a/src/widgets/image-preview.jsx +++ b/src/widgets/image-preview.jsx @@ -73,6 +73,10 @@ export default class ImagePreview extends React.PureComponent { : diff --git a/src/widgets/send-message.jsx b/src/widgets/send-message.jsx index d02ca082..b9cd6ab0 100644 --- a/src/widgets/send-message.jsx +++ b/src/widgets/send-message.jsx @@ -147,8 +147,6 @@ class SendMessage extends React.PureComponent { 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); }