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);
}