Merge branch 'next' into features

This commit is contained in:
aforge
2021-09-20 16:56:40 +03:00
17 changed files with 435 additions and 266 deletions
+3
View File
@@ -93,6 +93,9 @@ export const MAX_TOPIC_DESCRIPTION_LENGTH = 360;
// Length of message previews in chat list, in characters.
export const MESSAGE_PREVIEW_LENGTH = 80;
// Length of a quote in quoted reply (for outgoing messages).
export const QUOTED_REPLY_LENGTH = 30;
// Link for "Contact Us".
export const LINK_CONTACT_US = 'email:support@tinode.co';
+83 -16
View File
@@ -3,11 +3,12 @@ import { defineMessages } from 'react-intl';
import { Drafty } from 'tinode-sdk';
import LazyImage from '../widgets/lazy-image.jsx'
import UploadingImage from '../widgets/uploading-image.jsx'
import { IMAGE_THUMBNAIL_DIM, BROKEN_IMAGE_SIZE, REM_SIZE } from '../config.js';
import { fitImageSize } from './blob-helpers.js';
import { idToColorClass } from './strformat.js';
import { base64ToBlob, blobToBase64, fitImageSize, imageScaled } from './blob-helpers.js';
import { idToColorClass, shortenFileName } from './strformat.js';
import { sanitizeImageUrl } from './utils.js';
const messages = defineMessages({
@@ -35,9 +36,6 @@ function handleImageData(el, data, attr) {
attr.style = {
width: IMAGE_THUMBNAIL_DIM + 'px',
height: IMAGE_THUMBNAIL_DIM + 'px',
// Looks like a Chrome bug: broken image does not respect 'width' and 'height'.
minWidth: IMAGE_THUMBNAIL_DIM + 'px',
minHeight: IMAGE_THUMBNAIL_DIM + 'px'
};
return el;
}
@@ -94,7 +92,7 @@ function quotedImage(data) {
// Scale the blob.
return promise
.then(blob => {
return imageScaled(blob, IMAGE_THUMBNAIL_DIM, IMAGE_THUMBNAIL_DIM, -1, false)
return imageScaled(blob, IMAGE_THUMBNAIL_DIM, IMAGE_THUMBNAIL_DIM, -1, true)
}).then(scaled => {
data.mime = scaled.mime;
data.size = scaled.blob.size;
@@ -119,10 +117,6 @@ function quotedImage(data) {
});
}
function shortenFileName(filename) {
return filename.length > 16 ? filename.slice(0, 7) + '…' + filename.slice(-7) : filename;
}
// The main Drafty formatter: converts Drafty elements into React classes. 'this' is set by the caller.
// 'this' must contain:
// viewportWidth: this.props.viewportWidth;
@@ -169,8 +163,9 @@ export function fullFormatter(style, data, values, key) {
break;
case 'MN':
// Mention
if (data && data.hasOwnProperty('colorId')) {
attr.className = 'mn-dark-color' + data.colorId;
attr.className = 'mention'
if (data) {
attr.className += ' ' + idToColorClass(data.val, false, true);
}
break;
case 'FM':
@@ -274,6 +269,23 @@ export function previewFormatter(style, data, values, key) {
return React.createElement(el, attr, values);
};
// Converts Drafty object into a quoted reply. 'this' is set by the caller.
function inlineImageAttr(attr, data) {
attr.style = {
width: IMAGE_THUMBNAIL_DIM + 'px',
height: IMAGE_THUMBNAIL_DIM + 'px',
maxWidth: IMAGE_THUMBNAIL_DIM + 'px',
maxHeight: IMAGE_THUMBNAIL_DIM + 'px',
}
attr.className = 'inline-image';
attr.alt = shortenFileName(data && data.name, 16) || this.formatMessage(messages.drafty_image);
if (!data) {
attr.src = 'img/broken_image.png';
}
attr.title = attr.alt;
return attr;
}
// Displays a portion of Drafty within 'QQ' quotes. 'this' is set by the caller.
// 'this' must contain:
// formatMessage: this.props.intl.formatMessage
@@ -287,9 +299,8 @@ export function quoteFormatter(style, data, values, key) {
attr.key = key;
switch(style) {
case 'IM':
const img = handleImageData.call(this, el, data, attr);
values = [React.createElement(img, attr, null), ' ',
shortenFileName((data && data.name) || this.formatMessage(messages.drafty_image))];
attr = inlineImageAttr.call(this, attr, data);
values = [React.createElement('img', attr, null), ' ', attr.alt];
el = React.Fragment;
// Fragment attributes.
attr = {key: key};
@@ -319,10 +330,66 @@ export function quoteFormatter(style, data, values, key) {
}
el = React.Fragment;
values = [<i key="ex" className="material-icons">attachment</i>,
shortenFileName(fname || this.formatMessage(messages.drafty_attachment))];
shortenFileName(fname, 16) || this.formatMessage(messages.drafty_attachment)];
break;
}
return React.createElement(el, attr, values);
}
return previewFormatter.call(this, style, data, values, key);
}
// Create image thumbnail suitable for inclusion in a quote.
function quoteImage(data) {
let promise;
// Get the blob from the image data.
if (data.val) {
const blob = base64ToBlob(data.val, data.mime);
promise = blob ? Promise.resolve(blob) : Promise.reject(new Error("Invalid image"));
} else {
promise = fetch(this.authorizeURL(sanitizeImageUrl(data.ref))).then(evt => {
if (evt.ok) {
return evt.blob();
} else {
throw new Error(`Image fetch unsuccessful: ${evt.status} ${evt.statusText}`);
}
});
}
// Scale the blob.
return promise
.then(blob => {
// Cut the square from the center of the image and shrink it.
return imageScaled(blob, IMAGE_THUMBNAIL_DIM, IMAGE_THUMBNAIL_DIM, -1, true)
}).then(scaled => {
data.mime = scaled.mime;
data.size = scaled.blob.size;
data.width = scaled.width;
data.height = scaled.height;
delete data.ref;
// Keeping the original file name, if provided: ex.data.name;
data.src = URL.createObjectURL(scaled.blob);
return blobToBase64(scaled.blob);
}).then(b64 => {
data.val = b64.bits;
return data;
}).catch(err => {
delete data.val;
delete data.src;
data.width = IMAGE_THUMBNAIL_DIM;
data.height = IMAGE_THUMBNAIL_DIM;
// Rethrow.
throw err;
});
}
// Create a preview of a reply.
export function replyFormatter(style, data, values, key) {
if (style != 'IM') {
return quoteFormatter.call(this, style, data, values, key);
}
const attr = inlineImageAttr.call(this, {key: key}, data);
attr.whenDone = quoteImage.call(this, data);
values = [React.createElement(LazyImage, attr, null), ' ', attr.alt];
return React.createElement(React.Fragment, {key: key}, values);
}
+9
View File
@@ -37,6 +37,15 @@ export function bytesToHumanSize(bytes) {
return count.toFixed(round) + ' ' + sizes[bucket];
}
// Shorten a file name to be under maxLength by clipping out the middle.
export function shortenFileName(filename, maxLength) {
if (typeof filename != 'string') {
return filename;
}
return filename.length > maxLength ?
filename.slice(0, maxLength/2 - 1) + '…' + filename.slice(1 - maxLength/2) : filename;
}
// Get 32 bit integer hash value for a string. Ideally it should produce the same value
// as Java's String#hash().
function stringToColorHash(value) {
+20 -81
View File
@@ -16,9 +16,9 @@ import LoadSpinner from '../widgets/load-spinner.jsx';
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,
IMAGE_THUMBNAIL_DIM } from '../config.js';
import { DEFAULT_P2P_ACCESS_MODE, IMAGE_PREVIEW_DIM, IMAGE_THUMBNAIL_DIM, KEYPRESS_DELAY,
MESSAGES_PAGE, MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE,
READ_DELAY, QUOTED_REPLY_LENGTH } from '../config.js';
import { SUPPORTED_IMAGE_FORMATS, blobToBase64, base64ToBlob, filePasted, fileToBase64,
imageScaled, makeImageUrl } from '../lib/blob-helpers.js';
import HashNavigation from '../lib/navigation.js';
@@ -719,21 +719,23 @@ class MessagesView extends React.Component {
this.props.onError(this.props.intl.formatMessage(messages.file_attachment_too_large,
{size: bytesToHumanSize(file.size), limit: bytesToHumanSize(maxExternAttachmentSize)}), 'err');
} else {
this.setState({ docPreview: {
file: file,
filename: file.name,
size: file.size,
type: file.type
}});
this.setState({
docPreview: {
file: file,
name: file.name,
size: file.size,
type: file.type
}
});
}
}
// sendImageAttachment sends the image bits inband as Drafty message.
sendImageAttachment(caption, blob) {
const mime = this.state.imagePreview.type;
const mime = this.state.imagePreview.mime;
const width = this.state.imagePreview.width;
const height = this.state.imagePreview.height;
const fname = this.state.imagePreview.filename;
const fname = this.state.imagePreview.name;
// Server-provided limit reduced for base64 encoding and overhead.
const maxInbandAttachmentSize = (this.props.tinode.getServerLimit('maxMessageSize',
@@ -803,11 +805,11 @@ class MessagesView extends React.Component {
this.setState({imagePreview: {
url: URL.createObjectURL(scaled.blob),
blob: scaled.blob,
filename: scaled.fname,
name: scaled.name,
width: scaled.width,
height: scaled.height,
size: scaled.blob.size,
type: scaled.mime
mime: scaled.mime
}});
}).catch(err => {
this.props.onError(err, 'err');
@@ -834,7 +836,7 @@ class MessagesView extends React.Component {
content = Drafty.init(content);
}
if (Drafty.isValid(content)) {
content = Drafty.preview(content, 30);
content = Drafty.preview(content, QUOTED_REPLY_LENGTH);
} else {
// /!\ invalid content.
content = Drafty.append(Drafty.init('\u26A0 '),
@@ -858,75 +860,12 @@ class MessagesView extends React.Component {
}
}
// Make small image previews.
const images = [];
Drafty.entities(content, (data, idx, tp) => {
if (tp == 'IM') {
images.push({
tp: tp,
data: data
});
this.setState({
reply: {
content: Drafty.quote(senderName, senderId, content),
seq: seq
}
});
const quotedImage = (data) => {
let promise;
// Get the blob from the image data.
if (data.val) {
const blob = base64ToBlob(data.val, data.mime);
promise = blob ? Promise.resolve(blob) : Prmise.reject(new Error("Invalid image"));
} else {
promise = fetch(this.props.tinode.authorizeURL(sanitizeImageUrl(data.ref))).then(evt => {
if (evt.ok) {
return evt.blob();
} else {
throw new Error(`Image fetch unsuccessful: ${evt.status} ${evt.statusText}`);
}
});
}
// Scale the blob.
return promise
.then(blob => {
return imageScaled(blob, IMAGE_THUMBNAIL_DIM, IMAGE_THUMBNAIL_DIM, -1, false)
}).then(scaled => {
data.mime = scaled.mime;
data.size = scaled.blob.size;
data.width = scaled.width;
data.height = scaled.height;
delete data.ref;
// Keeping the original file name, if provided: ex.data.name;
return blobToBase64(scaled.blob);
}).then(b64 => {
data.val = b64.bits;
return data;
}).catch(err => {
delete data.val;
delete data.name;
data.width = IMAGE_THUMBNAIL_DIM;
data.height = IMAGE_THUMBNAIL_DIM;
data.maxWidth = IMAGE_THUMBNAIL_DIM;
data.maxHeight = IMAGE_THUMBNAIL_DIM;
// Rethrow.
throw err;
});
}
// Turn all images into thumbnails.
Promise.all(images.map(ex => quotedImage(ex.data)))
.catch((err) => {
this.props.onError(err, 'err');
})
.finally(() => {
// All done. Create a reply quote.
this.setState({
reply: {
content: Drafty.quote(senderName, senderId, content),
seq: seq
}
});
});
}
handleCancelReply() {
+4 -2
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { FormattedMessage } from 'react-intl';
import SendMessage from '../widgets/send-message.jsx';
import { bytesToHumanSize } from '../lib/strformat.js';
import { bytesToHumanSize, shortenFileName } from '../lib/strformat.js';
// Get material icon name from mime type.
function iconFromMime(mime) {
@@ -31,12 +31,14 @@ export default class DocPreview extends React.PureComponent {
return (
<div id="image-preview">
<div id="image-preview-caption-panel">
<span>{this.props.content.filename}</span>
<span>{this.props.content.name}</span>
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a>
</div>
<div id="image-preview-container">
<div className="flex-column narrow">
<i className="material-icons gray">{iconFromMime(this.props.content.type)}</i>
<div><b><FormattedMessage id="label_file_name" defaultMessage="File name:"
description="Label for a file name" /></b> {shortenFileName(this.props.content.name, 24) || '-'}</div>
<div><b><FormattedMessage id="label_content_type" defaultMessage="Content type:"
description="Label for file content type (mime)" /></b> {this.props.content.type || 'application/octet-stream'}</div>
<div><b><FormattedMessage id="label_size" defaultMessage="Size:"
+7 -9
View File
@@ -4,7 +4,7 @@ import SendMessage from '../widgets/send-message.jsx';
import { REM_SIZE } from '../config.js';
import { fitImageSize } from '../lib/blob-helpers.js';
import { bytesToHumanSize } from '../lib/strformat.js';
import { bytesToHumanSize, shortenFileName } from '../lib/strformat.js';
export default class ImagePreview extends React.PureComponent {
constructor(props) {
@@ -45,29 +45,27 @@ export default class ImagePreview extends React.PureComponent {
size.maxWidth = '100%';
size.maxHeight = '100%';
let filename = this.props.content.filename;
// Average font aspect ratio is ~0.5; File name takes 1/3 of the viewport width.
const maxlength = Math.max(((this.state.width / REM_SIZE / 1.5) | 0) - 2, 12);
if (filename.length > maxlength) {
filename = filename.slice(0, maxlength/2 - 1) + '…' + filename.slice(1 - maxlength/2);
}
const fname = shortenFileName(this.props.content.name, maxlength) || '-';
const width = this.props.content.width || '-';
const height = this.props.content.height || '-';
return (
<div id="image-preview">
<div id="image-preview-caption-panel">
{!this.props.onSendMessage ?
<a href={this.props.content.url} download={this.props.content.filename}>
<a href={this.props.content.url} download={this.props.content.name}>
<i className="material-icons">file_download</i> <FormattedMessage
id="download_action" defaultMessage="download" description="Call to action [download]" />
</a>
:
<span>{this.props.content.filename}</span>
<span>{fname}</span>
}
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a>
</div>
<div id="image-preview-container" ref={(node) => this.assignWidth(node)}>
<img src={this.props.content.url} style={size} className="image-preview" alt={this.props.content.filename} />
<img src={this.props.content.url} style={size} className="image-preview" alt={this.props.content.name} />
</div>
{this.props.onSendMessage ?
<SendMessage
@@ -83,7 +81,7 @@ export default class ImagePreview extends React.PureComponent {
<div>
<div><b><FormattedMessage id="label_file_name" defaultMessage="File name:"
description="Label for a file name" /></b></div>
<div><span title={this.props.content.filename}>{filename ? filename : '-'}</span></div>
<div><span title={this.props.content.name}>{fname}</span></div>
</div>
<div>
<div><b><FormattedMessage id="label_content_type" defaultMessage="Content type:"
+36
View File
@@ -0,0 +1,36 @@
// Image with a placeholder which is replaced when the promise is resolved.
import React from 'react';
import { FormattedMessage } from 'react-intl';
export default class LazyImage extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
src: 'img/placeholder.png',
style: Object.assign({padding: '4px'}, this.props.style),
className: this.props.className,
alt: this.props.alt,
onClick: this.props.onClick,
};
}
componentDidMount() {
this.props.whenDone
.then(data => this.setState({src: data.src, style: {...this.state.style, padding: 0}}))
.catch(() => this.setState({src: 'img/broken_image.png'}));
}
componentDidUpdate(prevProps) {
if (prevProps.whenDone != this.props.whenDone) {
this.setState({src: 'img/placeholder.png', style: {...this.state.style, padding: '4px'}});
this.props.whenDone
.then(data => this.setState({src: data.src, style: {...this.state.style, padding: 0}}))
.catch(() => this.setState({src: 'img/broken_image.png'}));
}
}
render() {
return React.createElement('img', this.state);
}
};
+20 -11
View File
@@ -5,7 +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';
import { quoteFormatter, replyFormatter } from '../lib/formatters.js';
const messages = defineMessages({
messaging_disabled: {
@@ -59,6 +59,13 @@ class SendMessage extends React.PureComponent {
if (this.messageEditArea) {
this.messageEditArea.addEventListener('paste', this.handlePasteEvent, false);
}
this.setState({quote: this.props.replyTo ?
Drafty.format(this.props.replyTo.content, replyFormatter, {
formatMessage: this.props.intl.formatMessage.bind(this.props.intl),
authorizeURL: this.props.tinode.authorizeURL.bind(this.props.tinode)
}) :
null});
}
componentWillUnmount() {
@@ -67,10 +74,19 @@ class SendMessage extends React.PureComponent {
}
}
componentDidUpdate() {
componentDidUpdate(prevProps) {
if (this.messageEditArea) {
this.messageEditArea.focus();
}
if (prevProps.replyTo != this.props.replyTo) {
this.setState({quote: this.props.replyTo ?
Drafty.format(this.props.replyTo.content, replyFormatter, {
formatMessage: this.props.intl.formatMessage.bind(this.props.intl),
authorizeURL: this.props.tinode.authorizeURL.bind(this.props.tinode)
}) :
null});
}
}
handlePasteEvent(e) {
@@ -161,21 +177,14 @@ class SendMessage extends React.PureComponent {
formatMessage(messages[this.props.messagePrompt]) :
formatMessage(messages.type_new_message));
const quote = this.props.replyTo ?
Drafty.format(this.props.replyTo.content, quoteFormatter, {
formatMessage: formatMessage.bind(this.props.intl),
authorizeURL: this.props.tinode.authorizeURL.bind(this.props.tinode)
}) :
null;
return (
<div id="send-message-wrapper">
{quote ?
{this.state.quote ?
<div id="reply-quote-preview">
<div className="cancel">
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onCancelReply();}}><i className="material-icons gray">close</i></a>
</div>
{quote}
{this.state.quote}
</div>
:
null}