image reply & invalid content fails, otherwise OK

This commit is contained in:
or-else
2021-09-13 19:44:43 -07:00
parent f57c5736e6
commit 68e1287fb8
16 changed files with 597 additions and 960 deletions
+1 -1
View File
@@ -58,7 +58,7 @@ export function letterTileColorId(userId) {
// params:
// - {string} id: user or topic ID
// - {boolean} light: light or dark version of the color.
// - {boolean} fg: foreground or background color.
// - {boolean} fg: foreground (text) or background color.
export function idToColorClass(id, light, fg) {
return (light ? 'lt-' : 'dk-') + (fg ? 'fg-' : 'bg-') + letterTileColorId(id);
}
+110 -285
View File
@@ -19,9 +19,9 @@ import UploadingImage from '../widgets/uploading-image.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,
IMAGE_THUMBNAIL_DIM, BROKEN_IMAGE_SIZE, REM_SIZE } from '../config.js';
IMAGE_THUMBNAIL_DIM } from '../config.js';
import { SUPPORTED_IMAGE_FORMATS, blobToBase64, base64ToBlob, filePasted, fileToBase64,
imageScaled, makeImageUrl, fitImageSize } from '../lib/blob-helpers.js';
imageScaled, makeImageUrl } from '../lib/blob-helpers.js';
import HashNavigation from '../lib/navigation.js';
import { bytesToHumanSize, shortDateFormat, letterTileColorId } from '../lib/strformat.js';
import { sanitizeImageUrl } from '../lib/utils.js';
@@ -66,6 +66,11 @@ const messages = defineMessages({
defaultMessage: 'Attachment',
description: 'Comment for attachment in drafty preview'
},
drafty_image: {
id: 'drafty_image',
defaultMessage: 'Picture',
description: 'Comment for embedded images in drafty preview'
},
});
// Checks if the access permissions are granted but not yet accepted.
@@ -679,7 +684,7 @@ class MessagesView extends React.Component {
if (typeof msg == 'string') {
msg = Drafty.parse(msg);
}
msg = Drafty.attachQuote(msg, this.state.reply.content);
msg = Drafty.append(this.state.reply.content, msg);
this.setState({reply: null});
}
this.props.sendMessage(msg, uploadCompletionPromise, uploader, head);
@@ -841,122 +846,114 @@ class MessagesView extends React.Component {
uploader.cancel();
}
handlePickReply(m) {
handlePickReply(seq, content) {
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, quotePreviewFmt);
if (!seq || !content) {
return;
}
// Get the author.
let header;
const seq = m.seq;
if (typeof content == 'string') {
content = Drafty.init(content);
}
content = Drafty.preview(content, 30);
// Get the author.
const topic = this.props.tinode.getTopic(this.state.topic);
const msg = topic.findMessage(seq);
let senderName, senderId;
if (msg) {
senderId = msg.from || 'chan';
if (senderId != this.props.myUserId) {
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);
if (user && user.public) {
header = user.public.fn;
}
} else {
header = this.props.myUserName;
}
const user = topic.userDesc(senderId);
if (user && user.public) {
senderName = user.public.fn;
}
header = Drafty.init(header || '');
// Make small image previews.
const ents = [];
if (cont.ent) {
Drafty.entities(cont, (data, idx, tp) => {
if (tp == 'IM') {
ents.push({
tp: tp,
data: data
});
}
}, this);
}
// Turn all images into thumbnails.
const promises = [];
ents.map((ex) => {
let p = new Promise((resolve, reject) => {
const handleFailure = () => {
ex.data.val = '';
ex.data.name = '';
ex.data.width = IMAGE_THUMBNAIL_DIM;
ex.data.height = IMAGE_THUMBNAIL_DIM;
};
const scale = (origBlob) => {
imageScaled(origBlob, IMAGE_THUMBNAIL_DIM, IMAGE_THUMBNAIL_DIM, -1, false,
// Success
(mime, blob, width, height, fname) => {
ex.data.mime = mime;
ex.data.size = blob.size;
ex.data.width = width;
ex.data.height = height;
ex.data.name = fname;
ex.data.ref = undefined;
blobToBase64(blob, (blobMime, tinyBits64) => {
ex.data.val = tinyBits64;
resolve(true);
});
},
// Failure
(err) => {
handleFailure();
reject(`Could not scale image: ${err}`);
});
}
if (ex.data.val) {
const b = base64ToBlob(ex.data.val, ex.data.mime);
if (b) {
scale(b);
} else {
handleFailure();
}
} else {
const from = this.props.tinode.authorizeURL(sanitizeImageUrl(ex.data.ref));
fetch(from)
.then(e => {
if (e.ok) {
return e.blob();
} else {
handleFailure();
reject(`Image fetch unsuccessful: ${e.status} - ${e.statusText}`);
}
})
.then((b) => scale(b))
.catch((err) => reject(`Error fetching image data: ${err}`));
return;
}
});
promises.push(p);
});
Promise.all(promises)
.catch((err) => {
this.props.onError(err, 'err');
})
.finally(() => {
// All done. Create a reply quote.
const msg = Drafty.createQuote(header, cont, letterTileColorId(thisFrom));
this.setState({reply: {content: msg, seq: m.seq}});
});
return;
} else {
senderName = this.props.myUserName;
}
}
// Make small image previews.
const ents = [];
Drafty.entities(content, (data, idx, tp) => {
if (tp == 'IM') {
ents.push({
tp: tp,
data: data
});
}
});
// Turn all images into thumbnails.
const promises = ents.map((ex) => {
return new Promise((resolve, reject) => {
const handleFailure = () => {
ex.data.val = '';
ex.data.name = '';
ex.data.width = IMAGE_THUMBNAIL_DIM;
ex.data.height = IMAGE_THUMBNAIL_DIM;
};
const scale = (origBlob) => {
imageScaled(origBlob, IMAGE_THUMBNAIL_DIM, IMAGE_THUMBNAIL_DIM, -1, false,
// Success
(mime, blob, width, height, fname) => {
ex.data.mime = mime;
ex.data.size = blob.size;
ex.data.width = width;
ex.data.height = height;
ex.data.name = fname;
ex.data.ref = undefined;
blobToBase64(blob, (blobMime, tinyBits64) => {
ex.data.val = tinyBits64;
resolve(true);
});
},
// Failure
(err) => {
handleFailure();
reject(`Could not scale image: ${err}`);
});
}
if (ex.data.val) {
const b = base64ToBlob(ex.data.val, ex.data.mime);
if (b) {
scale(b);
} else {
handleFailure();
}
} else {
const from = this.props.tinode.authorizeURL(sanitizeImageUrl(ex.data.ref));
fetch(from)
.then(e => {
if (e.ok) {
return e.blob();
} else {
handleFailure();
reject(`Image fetch unsuccessful: ${e.status} - ${e.statusText}`);
}
})
.then((b) => scale(b))
.catch((err) => reject(`Error fetching image data: ${err}`));
return;
}
});
});
Promise.all(promises)
.catch((err) => {
this.props.onError(err, 'err');
})
.finally(() => {
// All done. Create a reply quote.
this.setState({
reply: {
content: Drafty.createQuote(senderName, senderId, content),
seq: seq
}
});
});
}
handleCancelReply() {
@@ -999,7 +996,6 @@ class MessagesView extends React.Component {
content={this.state.imagePreview}
tinode={this.props.tinode}
replyTo={this.state.reply}
formatter={draftyFormatter}
onCancelReply={this.handleCancelReply}
onClose={this.handleClosePreview}
onSendMessage={this.sendImageAttachment} />
@@ -1018,7 +1014,6 @@ class MessagesView extends React.Component {
content={this.state.docPreview}
tinode={this.props.tinode}
replyTo={this.state.reply}
formatter={draftyFormatter}
onCancelReply={this.handleCancelReply}
onClose={this.handleClosePreview}
onSendMessage={this.sendFileAttachment} />
@@ -1105,7 +1100,6 @@ class MessagesView extends React.Component {
pickReply={this.handlePickReply}
replyToSeq={replyToSeq}
onQuoteClick={this.handleQuoteClick}
formatter={draftyFormatter}
ref={ref}
userIsWriter={this.state.isWriter}
key={msg.seq} />
@@ -1212,7 +1206,6 @@ class MessagesView extends React.Component {
onError={this.props.onError}
replyTo={this.state.reply}
onQuoteClick={this.handleQuoteClick}
formatter={draftyFormatter}
onCancelReply={this.handleCancelReply} />}
</>
);
@@ -1224,172 +1217,4 @@ class MessagesView extends React.Component {
}
};
// Transforms styles (and corresponding entities) for formatting reply quotes.
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', name: 'button'}}];
case 'FM':
new_fmt.tp = null;
return [new_fmt, {tp: 'IC', data: { orig: 'FM', name: 'form'}}];
case 'RW':
return [null, null];
case 'EX':
// Make it an icon.
new_fmt.tp = null;
if (new_fmt.at == -1) {
// Render it normally.
new_fmt.at = 0;
}
return [new_fmt, {tp: 'IC', data: { orig: 'EX', name: '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);
if (el) {
const { formatMessage } = this.props.intl;
let 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,
this.props.hasOwnProperty('viewportWidth') ? Math.min(this.props.viewportWidth - REM_SIZE * 6.5, REM_SIZE * 34.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) {
if (Math.max(data.width || 0, data.height || 0) > IMAGE_THUMBNAIL_DIM) {
// Allow previews for large enough images.
attr.onClick = this.handleImagePreview;
attr.className += ' image-clickable';
}
attr.loading = 'lazy';
} else {
attr.src = 'img/broken_image.png';
}
} else {
// Use custom element instead of <img>.
el = UploadingImage;
}
}
// Image element cannot have content.
values = null;
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 'MN':
// Mention
if (data && data.hasOwnProperty('colorId')) {
attr.className = 'mn-dark-color' + data.colorId;
}
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 'IC':
// Icon.
if (data.name == 'button') {
attr.className = 'flat-button faux';
} else {
const icons = {
form: {
name: 'dashboard', title: 'drafty_form'
},
attachment: {
name: 'attachment', title: 'drafty_attachment'
}
};
const icon = icons[data.name];
el = React.Fragment;
if (icon) {
const key = data.orig.toLowerCase();
values = [<i key={key} className="material-icons">{icon.name}</i>,
formatMessage(messages[icon.title])].concat(' ', values || []);
} else {
values = [];
}
}
break;
default:
if (el == '_UNKN') {
// Unknown element.
el = React.Fragment;
values = [<i className="material-icons gray">extension</i>, ' '].concat(values || []);
}
break;
}
return React.createElement(el, attr, values);
} else {
return values;
}
};
export default injectIntl(MessagesView);
+11 -1
View File
@@ -7,6 +7,7 @@ import Attachment from './attachment.jsx';
import LetterTile from './letter-tile.jsx';
import ReceivedMarker from './received-marker.jsx'
import { fullFormatter, quoteFormatter } from '../lib/formatters.js';
import { sanitizeImageUrl, sanitizeUrl } from '../lib/utils.js';
class BaseChatMessage extends React.PureComponent {
@@ -27,6 +28,15 @@ class BaseChatMessage extends React.PureComponent {
this.handleCancelUpload = this.handleCancelUpload.bind(this);
this.handleQuoteClick = this.handleQuoteClick.bind(this);
this.formatterContext = {
getFormatter: (tp) => { return tp == 'QQ' ? quoteFormatter : null; },
viewportWidth: this.props.viewportWidth,
authorizeURL: this.props.tinode.authorizeURL,
onImagePreview: this.handleImagePreview,
onFormButtonClick: this.handleFormButtonClick,
onQuoteClick: this.handleQuoteClick
};
}
handleImagePreview(e) {
@@ -117,7 +127,7 @@ class BaseChatMessage extends React.PureComponent {
onError={this.props.onError}
key={i} />);
}, this);
const tree = Drafty.format(content, this.props.formatter, this);
const tree = Drafty.format(content, fullFormatter, this.formatterContext);
content = React.createElement(React.Fragment, null, tree);
} else if (this.props.deleted) {
// Message represents a range of deleted messages.
+4 -80
View File
@@ -1,6 +1,6 @@
// A single topic or user.
import React from 'react';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import { FormattedMessage, injectIntl } from 'react-intl';
import ContactBadges from './contact-badges.jsx';
import LetterTile from './letter-tile.jsx';
@@ -9,26 +9,9 @@ import UnreadBadge from './unread-badge.jsx';
import { Drafty } from 'tinode-sdk';
import { previewFormatter } from '../lib/formatters.js';
import { deliveryMarker } from '../lib/utils.js';
const messages = defineMessages({
drafty_image: {
id: 'drafty_image',
defaultMessage: 'Picture',
description: 'Comment for embedded images in drafty preview'
},
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'
},
});
class Contact extends React.Component {
constructor(props) {
super(props);
@@ -89,7 +72,8 @@ class Contact extends React.Component {
const subtitle = this.props.preview ?
(typeof this.props.preview == 'string' ? this.props.preview :
Drafty.isValid(this.props.preview) ?
React.createElement(React.Fragment, null, Drafty.format(this.props.preview, draftyFormatter, this)) :
React.createElement(React.Fragment, null, Drafty.format(this.props.preview, previewFormatter,
{formatMessage: this.props.intl.formatMessage})) :
<><i className="material-icons gray">error_outline</i> <i className="gray">
<FormattedMessage id="invalid_content"
defaultMessage="invalid content" description="Shown when message is unreadable" /></i>
@@ -134,64 +118,4 @@ class Contact extends React.Component {
}
};
// Converts Drafty object into a one-line preview.
function draftyFormatter(style, data, values, key) {
let el = Drafty.tagName(style);
const 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':
case 'MN':
// Disable links in previews.
el = 'span';
break;
case 'IM':
// Replace image with '[icon] Image'.
el = React.Fragment;
values = [<i key="im" className="material-icons">photo</i>, formatMessage(messages.drafty_image)];
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>,
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>, formatMessage(messages.drafty_attachment)];
break;
default:
if (el == '_UNKN') {
el = React.Fragment;
values = [<i key="unkn" className="material-icons">extension</i>, ' '].concat(values || []);
}
break;
}
return React.createElement(el, attr, values);
} else {
return values;
}
};
export default injectIntl(Contact);
+1 -1
View File
@@ -424,7 +424,7 @@ class ContextMenu extends React.Component {
}
replyToMessage(params, errorHandler) {
params.pickReply({ seq: params.seq, content: params.content });
params.pickReply(params.seq, params.content, errorHandler);
}
render() {
-1
View File
@@ -47,7 +47,6 @@ export default class DocPreview extends React.PureComponent {
noInput={true}
tinode={this.props.tinode}
replyTo={this.props.replyTo}
formatter={this.props.formatter}
onCancelReply={this.props.onCancelReply}
onSendMessage={this.handleSendDoc}
onError={this.props.onError} />
-1
View File
@@ -75,7 +75,6 @@ export default class ImagePreview extends React.PureComponent {
acceptBlank={true}
tinode={this.props.tinode}
replyTo={this.props.replyTo}
formatter={this.props.formatter}
onCancelReply={this.props.onCancelReply}
onSendMessage={this.handleSendImage}
onError={this.props.onError} />
+8 -5
View File
@@ -5,6 +5,7 @@ import { Drafty } from 'tinode-sdk';
import { KEYPRESS_DELAY } from '../config.js';
import { filePasted } from '../lib/blob-helpers.js';
import { quoteFormatter } from '../lib/formatters.js';
const messages = defineMessages({
messaging_disabled: {
@@ -153,17 +154,19 @@ class SendMessage extends React.PureComponent {
}
render() {
const {formatMessage} = this.props.intl;
const { formatMessage } = this.props.intl;
const prompt = this.props.disabled ?
formatMessage(messages.messaging_disabled) :
(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.formatter, this);
}
const quote = this.props.replyTo ?
Drafty.format(this.props.replyTo.content, quoteFormatter, {
formatMessage: formatMessage,
authorizeURL: this.props.tinode.authorizeURL
}) :
null;
return (
<div id="send-message-wrapper">