merge next

This commit is contained in:
or-else
2022-05-12 10:12:50 -07:00
55 changed files with 15836 additions and 8407 deletions
+7 -2
View File
@@ -16,7 +16,7 @@ export const DEFAULT_HOST = KNOWN_HOSTS.hosted;
export const LOGGING_ENABLED = true;
// Minimum time between two keypress notifications, milliseconds.
export const KEYPRESS_DELAY = 3*1000;
export const KEYPRESS_DELAY = 3_000;
// Delay before sending a {note} for reciving a message, milliseconds.
export const RECEIVED_DELAY = 500;
// Delay before sending a read notification, milliseconds.
@@ -64,7 +64,7 @@ export const MESSAGES_PAGE = 24;
// for instance 1024 bytes.
// Increase this limit to a greater value in production, if desired. Also increase
// max_message_size in the server config.
export const MAX_INBAND_ATTACHMENT_SIZE = 262144;
export const MAX_INBAND_ATTACHMENT_SIZE = 262_144;
// Default absolute maximum attachment size to be used with the server = 8MB.
// Used when the server-provided value is unavailable. Increase to something like 100MB in production.
@@ -98,6 +98,11 @@ export const QUOTED_REPLY_LENGTH = 30;
// Length of a preview of a forwarded message.
export const FORWARDED_PREVIEW_LENGTH = 84;
// Minimum duration of an audio recording in milliseconds (2 sec).
export const MIN_DURATION = 2_000;
// Maximum duration of an audio recording in milliseconds (10 min).
export const MAX_DURATION = 600_000;
// Link for "Contact Us".
export const LINK_CONTACT_US = 'mailto:support@tinode.co';
+10 -3
View File
@@ -324,21 +324,21 @@
"drafty_attachment": {
"translation": "Dateianhang",
"defaultMessage": "Attachment",
"description": "Comment for attachment in drafty preview",
"description": "Comment for attachment in drafty",
"missing": false,
"obsolete": false
},
"drafty_form": {
"translation": "Formular:",
"defaultMessage": "Form:",
"description": "Comment for form in drafty preview",
"description": "Comment for form in drafty",
"missing": false,
"obsolete": false
},
"drafty_image": {
"translation": "Bild",
"defaultMessage": "Picture",
"description": "Comment for embedded images in drafty preview",
"description": "Comment for embedded images in drafty",
"missing": false,
"obsolete": false
},
@@ -1360,5 +1360,12 @@
"description": "Label for entering a new password",
"missing": false,
"obsolete": false
},
"drafty_unknown": {
"translation": "Nicht unterstützt",
"defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty",
"missing": false,
"obsolete": false
}
}
+10 -3
View File
@@ -324,21 +324,21 @@
"drafty_attachment": {
"translation": "Attachment",
"defaultMessage": "Attachment",
"description": "Comment for attachment in drafty preview",
"description": "Comment for attachment in drafty",
"missing": false,
"obsolete": false
},
"drafty_form": {
"translation": "Form:",
"defaultMessage": "Form:",
"description": "Comment for form in drafty preview",
"description": "Comment for form in drafty",
"missing": false,
"obsolete": false
},
"drafty_image": {
"translation": "Picture",
"defaultMessage": "Picture",
"description": "Comment for embedded images in drafty preview",
"description": "Comment for embedded images in drafty",
"missing": false,
"obsolete": false
},
@@ -1360,5 +1360,12 @@
"description": "Label for entering a new password",
"missing": false,
"obsolete": false
},
"drafty_unknown": {
"translation": "Unsupported",
"defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty",
"missing": false,
"obsolete": false
}
}
+10 -3
View File
@@ -324,21 +324,21 @@
"drafty_attachment": {
"translation": "Archivo",
"defaultMessage": "Attachment",
"description": "Comment for attachment in drafty preview",
"description": "Comment for attachment in drafty",
"missing": false,
"obsolete": false
},
"drafty_form": {
"translation": "Formulario:",
"defaultMessage": "Form:",
"description": "Comment for form in drafty preview",
"description": "Comment for form in drafty",
"missing": false,
"obsolete": false
},
"drafty_image": {
"translation": "Imagen",
"defaultMessage": "Picture",
"description": "Comment for embedded images in drafty preview",
"description": "Comment for embedded images in drafty",
"missing": false,
"obsolete": false
},
@@ -1360,5 +1360,12 @@
"description": "Label for entering a new password",
"missing": false,
"obsolete": false
},
"drafty_unknown": {
"translation": "No soportado",
"defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty",
"missing": false,
"obsolete": false
}
}
+7 -3
View File
@@ -193,15 +193,19 @@
},
"drafty_attachment": {
"defaultMessage": "Attachment",
"description": "Comment for attachment in drafty preview"
"description": "Comment for attachment in drafty"
},
"drafty_form": {
"defaultMessage": "Form:",
"description": "Comment for form in drafty preview"
"description": "Comment for form in drafty"
},
"drafty_image": {
"defaultMessage": "Picture",
"description": "Comment for embedded images in drafty preview"
"description": "Comment for embedded images in drafty"
},
"drafty_unknown": {
"defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty"
},
"email_dative": {
"defaultMessage": "email",
+10 -3
View File
@@ -324,21 +324,21 @@
"drafty_attachment": {
"translation": "부착",
"defaultMessage": "Attachment",
"description": "Comment for attachment in drafty preview",
"description": "Comment for attachment in drafty",
"missing": false,
"obsolete": false
},
"drafty_form": {
"translation": "형태:",
"defaultMessage": "Form:",
"description": "Comment for form in drafty preview",
"description": "Comment for form in drafty",
"missing": false,
"obsolete": false
},
"drafty_image": {
"translation": "이미지",
"defaultMessage": "Picture",
"description": "Comment for embedded images in drafty preview",
"description": "Comment for embedded images in drafty",
"missing": false,
"obsolete": false
},
@@ -1360,5 +1360,12 @@
"description": "Label for entering a new password",
"missing": false,
"obsolete": false
},
"drafty_unknown": {
"translation": "지원되지 않음",
"defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty",
"missing": false,
"obsolete": false
}
}
+10 -3
View File
@@ -324,21 +324,21 @@
"drafty_attachment": {
"translation": "Atasament",
"defaultMessage": "Attachment",
"description": "Comment for attachment in drafty preview",
"description": "Comment for attachment in drafty",
"missing": false,
"obsolete": false
},
"drafty_form": {
"translation": "Formă:",
"defaultMessage": "Form:",
"description": "Comment for form in drafty preview",
"description": "Comment for form in drafty",
"missing": false,
"obsolete": false
},
"drafty_image": {
"translation": "Imagine",
"defaultMessage": "Picture",
"description": "Comment for embedded images in drafty preview",
"description": "Comment for embedded images in drafty",
"missing": false,
"obsolete": false
},
@@ -1360,5 +1360,12 @@
"description": "Label for entering a new password",
"missing": false,
"obsolete": false
},
"drafty_unknown": {
"translation": "Neacceptat",
"defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty",
"missing": false,
"obsolete": false
}
}
+10 -3
View File
@@ -324,21 +324,21 @@
"drafty_attachment": {
"translation": "Аттачмент",
"defaultMessage": "Attachment",
"description": "Comment for attachment in drafty preview",
"description": "Comment for attachment in drafty",
"missing": false,
"obsolete": false
},
"drafty_form": {
"translation": "Форма:",
"defaultMessage": "Form:",
"description": "Comment for form in drafty preview",
"description": "Comment for form in drafty",
"missing": false,
"obsolete": false
},
"drafty_image": {
"translation": "Картинка",
"defaultMessage": "Picture",
"description": "Comment for embedded images in drafty preview",
"description": "Comment for embedded images in drafty",
"missing": false,
"obsolete": false
},
@@ -1360,5 +1360,12 @@
"description": "Label for entering a new password",
"missing": false,
"obsolete": false
},
"drafty_unknown": {
"translation": "Не поддерживается",
"defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty",
"missing": false,
"obsolete": false
}
}
+10 -3
View File
@@ -324,21 +324,21 @@
"drafty_attachment": {
"translation": "附加檔案",
"defaultMessage": "Attachment",
"description": "Comment for attachment in drafty preview",
"description": "Comment for attachment in drafty",
"missing": false,
"obsolete": false
},
"drafty_form": {
"translation": "表單:",
"defaultMessage": "Form:",
"description": "Comment for form in drafty preview",
"description": "Comment for form in drafty",
"missing": false,
"obsolete": false
},
"drafty_image": {
"translation": "圖片",
"defaultMessage": "Picture",
"description": "Comment for embedded images in drafty preview",
"description": "Comment for embedded images in drafty",
"missing": false,
"obsolete": false
},
@@ -1360,5 +1360,12 @@
"description": "Label for entering a new password",
"missing": false,
"obsolete": false
},
"drafty_unknown": {
"translation": "不支持",
"defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty",
"missing": false,
"obsolete": false
}
}
+10 -3
View File
@@ -324,21 +324,21 @@
"drafty_attachment": {
"translation": "附件",
"defaultMessage": "Attachment",
"description": "Comment for attachment in drafty preview",
"description": "Comment for attachment in drafty",
"missing": false,
"obsolete": false
},
"drafty_form": {
"translation": "形式:",
"defaultMessage": "Form:",
"description": "Comment for form in drafty preview",
"description": "Comment for form in drafty",
"missing": false,
"obsolete": false
},
"drafty_image": {
"translation": "图像",
"defaultMessage": "Picture",
"description": "Comment for embedded images in drafty preview",
"description": "Comment for embedded images in drafty",
"missing": false,
"obsolete": false
},
@@ -1360,5 +1360,12 @@
"description": "Label for entering a new password",
"missing": false,
"obsolete": false
},
"drafty_unknown": {
"translation": "不支持",
"defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty",
"missing": false,
"obsolete": false
}
}
+25 -1
View File
@@ -186,7 +186,7 @@ export function fileToBase64(file) {
export function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = (evt) => {
reader.onerror = _ => {
reject(reader.error);
};
reader.onload = () => {
@@ -281,3 +281,27 @@ export function base64ToBlob(str, mime) {
return null;
}
export function intArrayToBase64(arr) {
if (!Array.isArray(arr)) {
return null;
}
try {
let bin = '';
new Uint8Array(arr).forEach(b => bin += String.fromCharCode(b));
return window.btoa(bin);
} catch (err) {}
return null;
}
export function base64ToIntArray(b64) {
const arr = [];
try {
const bin = window.atob(b64);
[...bin].forEach(c => {
arr.push(c.charCodeAt(0));
});
return arr;
} catch (err) {}
return null;
}
+48 -59
View File
@@ -3,33 +3,39 @@ import { defineMessages } from 'react-intl';
import { Drafty } from 'tinode-sdk';
import AudioPlayer from '../widgets/audio-player.jsx'
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 { base64ToBlob, blobToBase64, fitImageSize, imageScaled } from './blob-helpers.js';
import { idToColorClass, shortenFileName } from './strformat.js';
import { cancelablePromise, sanitizeImageUrl } from './utils.js';
import { idToColorClass, secondsToTime, shortenFileName } from './strformat.js';
import { cancelablePromise, sanitizeUrlForMime } from './utils.js';
const messages = defineMessages({
drafty_form: {
id: 'drafty_form',
defaultMessage: 'Form: ',
description: 'Comment for form in drafty preview'
description: 'Comment for form in drafty'
},
drafty_attachment: {
id: 'drafty_attachment',
defaultMessage: 'Attachment',
description: 'Comment for attachment in drafty preview'
description: 'Comment for attachment in drafty'
},
drafty_image: {
id: 'drafty_image',
defaultMessage: 'Picture',
description: 'Comment for embedded images in drafty preview'
description: 'Comment for embedded images in drafty'
},
drafty_unknown: {
id: 'drafty_unknown',
defaultMessage: 'Unsupported',
description: 'Unsupported entity in drafty'
}
});
// Size the already scaled image.
// Additional processing of image data.
function handleImageData(el, data, attr) {
if (!data) {
attr.src = 'img/broken_image.png';
@@ -53,7 +59,7 @@ function handleImageData(el, data, attr) {
minHeight: dim.dstHeight + 'px'
};
if (!Drafty.isProcessing(data)) {
attr.src = this.authorizeURL(sanitizeImageUrl(attr.src));
attr.src = this.authorizeURL(sanitizeUrlForMime(attr.src, 'image'));
attr.alt = data.name;
if (attr.src) {
if (Math.max(data.width || 0, data.height || 0) > IMAGE_THUMBNAIL_DIM) {
@@ -73,50 +79,6 @@ function handleImageData(el, data, attr) {
return el;
}
function 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.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, 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;
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;
});
}
// The main Drafty formatter: converts Drafty elements into React classes. 'this' is set by the caller.
// 'this' must contain:
// viewportWidth: this.props.viewportWidth;
@@ -135,12 +97,27 @@ export function fullFormatter(style, data, values, key, stack) {
}
let el = Drafty.tagName(style);
const attr = Drafty.attrValue(style, data) || {};
let attr = Drafty.attrValue(style, data) || {};
attr.key = key;
switch (style) {
case 'AU':
// Show audio player.
if (attr.src) {
attr.src = this.authorizeURL(sanitizeUrlForMime(attr.src, 'audio'));
attr.duration = data.duration > 0 ? (data.duration | 0) : undefined;
attr.preview = data.preview;
attr.loading = 'lazy';
}
el = AudioPlayer;
// Audio element cannot have content.
values = null;
break;
case 'BR':
values = null;
break;
case 'EX':
// Ignore.
break;
case 'HL':
// Highlighted text. Assign class name.
attr.className = 'highlight';
@@ -187,10 +164,16 @@ export function fullFormatter(style, data, values, key, stack) {
attr.onClick = this.onQuoteClick;
break;
default:
if (el == '_UNKN') {
if (!el) {
// Unknown element.
el = React.Fragment;
values = [<i className="material-icons gray">extension</i>, ' '].concat(values || []);
attr = {key: key};
// Generate comment for unknown element.
let body = values;
if (!Array.isArray(values) || !values.join('').trim()) {
body = [<span key="x1" className="gray">{this.formatMessage(messages.drafty_unknown)}</span>];
}
values = [<i key="x0" className="material-icons gray">extension</i>, ' '].concat(body);
}
break;
}
@@ -213,6 +196,11 @@ export function previewFormatter(style, data, values, key) {
let el = Drafty.tagName(style);
const attr = { key: key };
switch (style) {
case 'AU':
// Voicemail as '[mic] 0:00'.
el = React.Fragment;
values = [<i key="au" className="material-icons">mic</i>, ' ', secondsToTime(data.duration/1000)];
break;
case 'BR':
// Replace new line with a space.
el = React.Fragment;
@@ -230,7 +218,7 @@ export function previewFormatter(style, data, values, key) {
case 'IM':
// Replace image with '[icon] Image'.
el = React.Fragment;
values = [<i key="im" className="material-icons">photo</i>, this.formatMessage(messages.drafty_image)];
values = [<i key="im" className="material-icons">photo</i>, ' ', this.formatMessage(messages.drafty_image)];
break;
case 'BN':
el = 'span';
@@ -255,7 +243,7 @@ export function previewFormatter(style, data, values, key) {
delete data.ref;
}
el = React.Fragment;
values = [<i key="ex" className="material-icons">attachment</i>, this.formatMessage(messages.drafty_attachment)];
values = [<i key="ex" className="material-icons">attachment</i>, ' ', this.formatMessage(messages.drafty_attachment)];
break;
case 'QQ':
case 'HD':
@@ -263,9 +251,10 @@ export function previewFormatter(style, data, values, key) {
values = null;
break;
default:
if (el == '_UNKN') {
if (!el) {
// Unknown element.
el = React.Fragment;
values = [<i key="unkn" className="material-icons">extension</i>, ' '].concat(values || []);
values = [<i key="x0" className="material-icons gray">extension</i>, ' ', this.formatMessage(messages.drafty_unknown)];
}
break;
}
@@ -354,7 +343,7 @@ function quoteImage(data) {
}
promise = Promise.resolve(blob);
} else if (data.ref) {
promise = fetch(this.authorizeURL(sanitizeImageUrl(data.ref)))
promise = fetch(this.authorizeURL(sanitizeUrlForMime(data.ref, 'image')))
.then(evt => {
if (evt.ok) {
return evt.blob();
+7 -3
View File
@@ -17,9 +17,13 @@ export function shortDateFormat(then, locale) {
}
// Convert seconds to minutes:seconds, i.e. 156 sec -> 2:36.
export function secondsToTime(seconds) {
const min = Math.floor(seconds / 60);
let sec = seconds % 60;
// If <code>fixedMins</code> is true, then minutes are represented by at least two digits.
export function secondsToTime(seconds, fixedMin) {
let min = Math.floor(seconds / 60) | 0;
if (fixedMin) {
min = min < 10 ? `0${min}` : min;
}
let sec = (seconds % 60) | 0;
sec = sec < 10 ? `0${sec}` : sec;
return `${min}:${sec}`;
}
+7 -6
View File
@@ -1,6 +1,6 @@
// Odds and ends
import Tinode from 'tinode-sdk';
import { Tinode } from 'tinode-sdk';
// Make shortcut icon appear with a green dot + show unread count in title.
export function updateFavicon(count) {
@@ -144,10 +144,10 @@ export function sanitizeUrl(url, allowedSchemes) {
return url;
}
// Ensure URL is suitable for <img src="url"> field: the URL must be a relative URL or
// Ensure URL is suitable as a source like <img src="url"> field: the URL must be a relative URL or
// have http:, https:, blob: or data: scheme.
// In case of data: scheme, the URL must start with a 'data:image/XXXX;base64,'.
export function sanitizeImageUrl(url) {
// In case of data: scheme, the URL must must be of the right MIME type such as 'data:{mimeMajor}/XXXX;base64,'.
export function sanitizeUrlForMime(url, mimeMajor) {
if (!url) {
return null;
}
@@ -157,8 +157,9 @@ export function sanitizeImageUrl(url) {
return sanitizedUrl;
}
// Is this a data: URL of an image?
if (/data:image\/[a-z0-9.-]+;base64,/i.test(url.trim())) {
// Is this a data: URL of the appropriate mime type?
const re = new RegExp(`data:${mimeMajor}\/[a-z0-9.-]+;base64,`, 'i');
if (re.test(url.trim())) {
return url;
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,3 +1,3 @@
// This is a generated file. Don't edit.
export const PACKAGE_VERSION = "0.18.6";
export const PACKAGE_VERSION = "0.19.0-beta1";
+1 -1
View File
@@ -124,7 +124,7 @@ class AccSecurityView extends React.Component {
<div>
<label className="small">
<FormattedMessage id="label_default_access_mode" defaultMessage="Default access mode:"
description="Label for default access mode" />
description="Label for default access mode" />
</label>
</div>
<div className="quoted">
+1 -1
View File
@@ -2,7 +2,7 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Tinode from 'tinode-sdk';
import { Tinode } from 'tinode-sdk';
import { APP_NAME, LINK_CONTACT_US, LINK_PRIVACY_POLICY, LINK_TERMS_OF_SERVICE } from '../config.js';
+1 -1
View File
@@ -2,7 +2,7 @@
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import Tinode from 'tinode-sdk';
import { Tinode } from 'tinode-sdk';
import AvatarUpload from '../widgets/avatar-upload.jsx';
import BadgeList from '../widgets/badge-list.jsx';
+1 -1
View File
@@ -2,7 +2,7 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Tinode from 'tinode-sdk';
import { Tinode } from 'tinode-sdk';
import { APP_NAME } from '../config.js';
+169 -58
View File
@@ -1,8 +1,9 @@
// Panel with a chat.
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import Tinode from 'tinode-sdk';
const Drafty = Tinode.Drafty;
import { Drafty, Tinode } from 'tinode-sdk';
import ChatMessage from '../widgets/chat-message.jsx';
import ContactBadges from '../widgets/contact-badges.jsx';
@@ -14,18 +15,20 @@ import Invitation from '../widgets/invitation.jsx';
import LetterTile from '../widgets/letter-tile.jsx';
import LoadSpinner from '../widgets/load-spinner.jsx';
import LogoView from './logo-view.jsx';
import MetaMessage from '../widgets/meta-message.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, QUOTED_REPLY_LENGTH } from '../config.js';
import { blobToBase64, fileToBase64,
imageScaled, makeImageUrl } from '../lib/blob-helpers.js';
import { blobToBase64, fileToBase64, imageScaled, makeImageUrl } from '../lib/blob-helpers.js';
import HashNavigation from '../lib/navigation.js';
import { bytesToHumanSize, shortDateFormat } from '../lib/strformat.js';
// Run timer with this frequency (ms) for checking notification queue.
const NOTIFICATION_EXEC_INTERVAL = 300;
// Scroll distance before [go to latest message] button is shown.
const SHOW_GO_TO_LAST_DIST = 100;
const messages = defineMessages({
online_now: {
@@ -88,6 +91,7 @@ class MessagesView extends React.Component {
this.retrySend = this.retrySend.bind(this);
this.sendImageAttachment = this.sendImageAttachment.bind(this);
this.sendFileAttachment = this.sendFileAttachment.bind(this);
this.sendAudioAttachment = this.sendAudioAttachment.bind(this);
this.sendKeyPress = this.sendKeyPress.bind(this);
this.subscribe = this.subscribe.bind(this);
this.handleScrollReference = this.handleScrollReference.bind(this);
@@ -109,6 +113,7 @@ class MessagesView extends React.Component {
this.handleCancelUpload = this.handleCancelUpload.bind(this);
this.postReadNotification = this.postReadNotification.bind(this);
this.clearNotificationQueue = this.clearNotificationQueue.bind(this);
this.goToLatestMessage = this.goToLatestMessage.bind(this);
this.handlePickReply = this.handlePickReply.bind(this);
this.handleCancelReply = this.handleCancelReply.bind(this);
@@ -148,13 +153,13 @@ class MessagesView extends React.Component {
// Scroll last message into view on component update e.g. on message received
// or vertical shrinking.
componentDidUpdate(prevProps, prevState) {
if (this.messagesScroller) {
if (prevState.topic != this.state.topic || prevState.messageCount != this.state.messageCount) {
// New message
this.messagesScroller.scrollTop = this.messagesScroller.scrollHeight - this.state.scrollPosition;
} else if (prevProps.viewportHeight > this.props.viewportHeight) {
// Componet changed height.
this.messagesScroller.scrollTop += prevProps.viewportHeight - this.props.viewportHeight;
if (this.messagesScroller &&
(prevState.topic != this.state.topic || prevState.messageCount != this.state.messageCount)) {
// New message.
if (this.state.scrollPosition < SHOW_GO_TO_LAST_DIST) {
this.messagesScroller.scrollTop = this.messagesScroller.scrollHeight -
this.state.scrollPosition -
this.messagesScroller.offsetHeight;
}
}
@@ -182,8 +187,13 @@ class MessagesView extends React.Component {
this.postReadNotification(0);
}
if ((this.state.topic != prevState.topic) || !prevProps.ready) {
this.subscribe(topic);
if (topic && ((this.state.topic != prevState.topic) || !prevProps.ready)) {
// Don't immediately subscribe to a new p2p topic, wait for the first message.
if (topic._new && topic.isP2PType()) {
topic.getMeta(topic.startMetaQuery().withDesc().build());
} else {
this.subscribe(topic);
}
}
}
@@ -201,6 +211,7 @@ class MessagesView extends React.Component {
isVerified: false,
isStaff: false,
isDangerous: false,
deleted: false,
docPreview: null,
imagePreview: null,
imagePostview: null,
@@ -209,7 +220,8 @@ class MessagesView extends React.Component {
fetchingMessages: false,
peerMessagingDisabled: false,
channel: false,
reply: null
reply: null,
showGoToLastButton: false
};
} else if (nextProps.topic != prevState.topic) {
const topic = nextProps.tinode.getTopic(nextProps.topic);
@@ -232,7 +244,9 @@ class MessagesView extends React.Component {
typingIndicator: false,
scrollPosition: 0,
fetchingMessages: false,
reply: reply
reply: reply,
showGoToLastButton: false,
deleted: topic._deleted
};
if (topic) {
@@ -333,7 +347,7 @@ class MessagesView extends React.Component {
}
subscribe(topic) {
if (!topic || topic.isSubscribed() || !this.props.ready) {
if (topic.isSubscribed() || !this.props.ready) {
return;
}
@@ -367,13 +381,16 @@ class MessagesView extends React.Component {
this.props.onNewTopicCreated(this.props.topic, ctrl.topic);
// If there are unsent messages, try sending them now.
topic.queuedMessages((pub) => {
if (!pub._sending && topic.isSubscribed()) {
if (pub._sending) {
return;
}
if (topic.isSubscribed()) {
this.retrySend(pub);
}
});
})
.catch((err) => {
console.error("Failed subscription to", this.state.topic);
console.error("Failed subscription to", this.state.topic, err);
this.props.onError(err.message, 'err');
const blankState = MessagesView.getDerivedStateFromProps({}, {});
blankState.title = this.props.intl.formatMessage(messages.not_found);
@@ -403,6 +420,7 @@ class MessagesView extends React.Component {
}
}
// Don't use React.createRef as the ref.current is not available in componentDidMount in this component.
handleScrollReference(node) {
if (node) {
node.addEventListener('scroll', this.handleScrollEvent);
@@ -411,9 +429,14 @@ class MessagesView extends React.Component {
}
}
// Get older messages
// Get older messages and show/hide [go to latest message] button.
handleScrollEvent(event) {
this.setState({scrollPosition: event.target.scrollHeight - event.target.scrollTop});
const pos = event.target.scrollHeight - event.target.scrollTop - event.target.offsetHeight;
this.setState({
scrollPosition: pos,
showGoToLastButton: pos > SHOW_GO_TO_LAST_DIST,
});
if (this.state.fetchingMessages) {
return;
}
@@ -430,6 +453,13 @@ class MessagesView extends React.Component {
}
}
goToLatestMessage() {
this.setState({scrollPosition: 0});
if (this.messagesScroller) {
this.messagesScroller.scrollTop = this.messagesScroller.scrollHeight;
}
}
handleDescChange(desc) {
if (desc.public) {
this.setState({
@@ -555,7 +585,11 @@ class MessagesView extends React.Component {
// Scroll to the bottom if the message is added to the end of the message list.
// TODO: This should be replaced by showing a "scroll to bottom" button.
if (topic.isNewMessage(msg.seq)) {
this.setState({scrollPosition: 0});
if (this.state.scrollPosition > SHOW_GO_TO_LAST_DIST) {
this.setState({showGoToLastButton: true});
} else {
this.setState({scrollPosition: 0});
}
}
// Aknowledge messages except own messages. They are
@@ -713,6 +747,10 @@ class MessagesView extends React.Component {
if (file.size > maxInbandAttachmentSize) {
// Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper();
if (!uploader) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return;
}
const uploadCompletionPromise = uploader.upload(file);
const msg = Drafty.attachFile(null, {
mime: file.type,
@@ -730,7 +768,7 @@ class MessagesView extends React.Component {
}
}
// handleAttachFile method is called when [Attach file] button is clicked.
// handleAttachFile method is called when [Attach file] button is clicked: launch attachment preview.
handleAttachFile(file) {
const maxExternAttachmentSize = this.props.tinode.getServerLimit('maxFileUploadSize', MAX_EXTERN_ATTACHMENT_SIZE);
@@ -815,7 +853,7 @@ class MessagesView extends React.Component {
});
}
// handleAttachImage method is called when [Attach image] button is clicked.
// handleAttachImage method is called when [Attach image] button is clicked: launch image preview.
handleAttachImage(file) {
const maxExternAttachmentSize = this.props.tinode.getServerLimit('maxFileUploadSize', MAX_EXTERN_ATTACHMENT_SIZE);
@@ -836,6 +874,47 @@ class MessagesView extends React.Component {
});
}
// sendAudioAttachment sends audio bits inband as Drafty message (no preview).
sendAudioAttachment(url, preview, duration) {
fetch(url)
.then(result => result.blob())
.then(blob => {
// Server-provided limit reduced for base64 encoding and overhead.
const maxInbandAttachmentSize = this.props.tinode.getServerLimit('maxMessageSize', MAX_INBAND_ATTACHMENT_SIZE) * 0.75 - 1024;
if (blob.size > maxInbandAttachmentSize) {
// Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper();
if (!uploader) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return;
}
const uploadCompletionPromise = uploader.upload(blob);
const msg = Drafty.appendAudio(null, {
mime: blob.type,
size: blob.size,
duration: duration,
preview: preview,
urlPromise: uploadCompletionPromise
});
// Pass data and the uploader to the TinodeWeb.
this.sendMessage(msg, uploadCompletionPromise, uploader);
} else {
// Small enough to send inband.
blobToBase64(blob)
.then(b64 => {
this.sendMessage(Drafty.appendAudio(null, {
mime: b64.mime,
size: blob.size,
data: b64.bits,
duration: duration,
preview: preview,
}))
})
}
})
.catch(err => {this.props.onError(err)});;
}
handleCancelUpload(seq, uploader) {
const topic = this.props.tinode.getTopic(this.state.topic);
const found = topic.findMessage(seq);
@@ -950,9 +1029,12 @@ class MessagesView extends React.Component {
icon_badges.push({icon: 'dangerous', color: 'badge-inv'});
}
}
let messageNodes = [];
const messageNodes = [];
let previousFrom = null;
let prevDate = null;
let chatBoxClass = null;
const dateFmt = new Intl.DateTimeFormat(this.props.intl.locale);
topic.messages((msg, prev, next, i) => {
let nextFrom = next ? (next.from || 'chan') : null;
@@ -987,36 +1069,55 @@ class MessagesView extends React.Component {
replyToSeq = null;
}
messageNodes.push(
<ChatMessage
tinode={this.props.tinode}
content={msg.content}
deleted={msg.hi}
mimeType={msg.head ? msg.head.mime : null}
timestamp={msg.ts}
response={isReply}
seq={msg.seq}
isGroup={groupTopic}
isChan={this.state.channel}
userFrom={userFrom}
userName={userName}
userAvatar={userAvatar}
sequence={sequence}
received={deliveryStatus}
uploader={msg._uploader}
viewportWidth={this.props.viewportWidth} // Used by `formatter`.
showContextMenu={this.handleShowMessageContextMenu}
onImagePreview={this.handleImagePostview}
onFormResponse={this.handleFormResponse}
onError={this.props.onError}
onCancelUpload={this.handleCancelUpload}
pickReply={this.handlePickReply}
replyToSeq={replyToSeq}
onQuoteClick={this.handleQuoteClick}
ref={ref}
userIsWriter={this.state.isWriter}
key={msg.seq} />
);
if (msg.hi) {
// Deleted message.
messageNodes.push(
<MetaMessage
deleted={true}
key={msg.seq} />
);
} else {
const thisDate = new Date(msg.ts);
// This message was sent on a different date than the previous.
if (!prevDate || prevDate.toDateString() != thisDate.toDateString()) {
messageNodes.push(
<MetaMessage
date={dateFmt.format(msg.ts)}
locale={this.props.intl.locale}
key={'date-' + msg.seq} />
);
prevDate = thisDate;
}
messageNodes.push(
<ChatMessage
tinode={this.props.tinode}
content={msg.content}
mimeType={msg.head ? msg.head.mime : null}
timestamp={msg.ts}
response={isReply}
seq={msg.seq}
isGroup={groupTopic}
isChan={this.state.channel}
userFrom={userFrom}
userName={userName}
userAvatar={userAvatar}
sequence={sequence}
received={deliveryStatus}
uploader={msg._uploader}
viewportWidth={this.props.viewportWidth} // Used by `formatter`.
showContextMenu={this.handleShowMessageContextMenu}
onImagePreview={this.handleImagePostview}
onFormResponse={this.handleFormResponse}
onError={this.props.onError}
onCancelUpload={this.handleCancelUpload}
pickReply={this.handlePickReply}
replyToSeq={replyToSeq}
onQuoteClick={this.handleQuoteClick}
ref={ref}
userIsWriter={this.state.isWriter}
key={msg.seq} />
);
}
});
let lastSeen = null;
@@ -1035,7 +1136,10 @@ class MessagesView extends React.Component {
}
}
const avatar = this.state.avatar || true;
const online = this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline';
const online = this.state.deleted ? null :
this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline';
const titleClass = 'panel-title' + (this.state.deleted ? ' deleted' : '');
component2 = (
<>
@@ -1051,11 +1155,12 @@ class MessagesView extends React.Component {
tinode={this.props.tinode}
avatar={avatar}
topic={this.state.topic}
title={this.state.title} />
title={this.state.title}
deleted={this.state.deleted} />
{!isChannel ? <span className={online} /> : null}
</div>
<div id="topic-title-group">
<div id="topic-title" className="panel-title">{
<div id="topic-title" className={titleClass}>{
this.state.title ||
<i><FormattedMessage id="unnamed_topic" defaultMessage="Unnamed"
description="Title shown when the topic has no name" /></i>
@@ -1082,6 +1187,10 @@ class MessagesView extends React.Component {
: null}
<LoadSpinner show={this.state.fetchingMessages} />
<div id="messages-container">
<button className={'action-button' + (this.state.showGoToLastButton ? '' : ' hidden')}
onClick={this.goToLatestMessage}>
<i className="material-icons">arrow_downward</i>
</button>
<div id="messages-panel" ref={this.handleScrollReference}>
<ul id="scroller" className={chatBoxClass}>
{messageNodes}
@@ -1111,12 +1220,14 @@ class MessagesView extends React.Component {
:
<SendMessage
tinode={this.props.tinode}
topicName={this.state.topic}
noInput={!!this.props.forwardMessage}
disabled={!this.state.isWriter}
disabled={!this.state.isWriter || this.state.deleted}
onKeyPress={this.sendKeyPress}
onSendMessage={this.sendMessage}
onAttachFile={this.props.forwardMessage ? null : this.handleAttachFile}
onAttachImage={this.props.forwardMessage ? null : this.handleAttachImage}
onAttachAudio={this.props.forwardMessage ? null : this.sendAudioAttachment}
onError={this.props.onError}
reply={this.state.reply}
onQuoteClick={this.handleQuoteClick}
+5 -5
View File
@@ -2,7 +2,7 @@
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import Tinode from 'tinode-sdk';
import { Tinode } from 'tinode-sdk';
import ContactList from '../widgets/contact-list.jsx';
import NewTopicById from '../widgets/new-topic-by-id.jsx';
@@ -41,7 +41,7 @@ class NewTopicView extends React.Component {
this.handleTabClick = this.handleTabClick.bind(this);
this.handleSearchContacts = this.handleSearchContacts.bind(this);
this.handleContactSelected = this.handleContactSelected.bind(this);
this.handleSearchResultSelected = this.handleSearchResultSelected.bind(this);
this.handleNewGroupSubmit = this.handleNewGroupSubmit.bind(this);
this.handleGroupByID = this.handleGroupByID.bind(this);
}
@@ -61,10 +61,10 @@ class NewTopicView extends React.Component {
this.setState({searchQuery: Tinode.isNullValue(query) ? null : query});
}
handleContactSelected(uid) {
handleSearchResultSelected(name) {
if (this.state.tabSelected == 'find') {
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
this.props.onCreateTopic(uid);
this.props.onCreateTopic(name);
}
}
@@ -126,7 +126,7 @@ class NewTopicView extends React.Component {
showOnline={false}
showUnread={false}
showContextMenu={false}
onTopicSelected={this.handleContactSelected} />
onTopicSelected={this.handleSearchResultSelected} />
</div>}
</div>
);
+135 -122
View File
@@ -2,10 +2,11 @@
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import * as firebase from 'firebase/app';
import 'firebase/messaging';
import { initializeApp as firebaseInitApp } from 'firebase/app';
import { getMessaging as firebaseGetMessaging, getToken as firebaseGetToken,
deleteToken as firebaseDelToken, onMessage as firebaseOnMessage } from 'firebase/messaging';
import Tinode from 'tinode-sdk';
import { Drafty, Tinode } from 'tinode-sdk';
import Alert from '../widgets/alert.jsx';
import ContextMenu from '../widgets/context-menu.jsx';
@@ -114,8 +115,7 @@ class TinodeWeb extends React.Component {
this.handleCredDelete = this.handleCredDelete.bind(this);
this.handleCredConfirm = this.handleCredConfirm.bind(this);
this.initFCMessaging = this.initFCMessaging.bind(this);
this.togglePushToken = this.togglePushToken.bind(this);
this.requestPushToken = this.requestPushToken.bind(this);
this.toggleFCMToken = this.toggleFCMToken.bind(this);
this.handlePushMessage = this.handlePushMessage.bind(this);
this.handleSidepanelCancel = this.handleSidepanelCancel.bind(this);
this.handleStartTopicRequest = this.handleStartTopicRequest.bind(this);
@@ -164,9 +164,11 @@ class TinodeWeb extends React.Component {
// "On" is the default, so saving the "off" state.
messageSounds: !settings.messageSoundsOff,
incognitoMode: false,
// Persistent request to enable alerts.
desktopAlerts: persist && !!settings.desktopAlerts,
// Enable / disable checkbox.
desktopAlertsEnabled: (isSecureConnection() || isLocalHost()) &&
(typeof firebase != 'undefined') && (typeof navigator != 'undefined') &&
(typeof firebaseInitApp != 'undefined') && (typeof navigator != 'undefined') &&
(typeof FIREBASE_INIT != 'undefined'),
firebaseToken: persist ? LocalStorageUtil.getObject('firebase-token') : null,
@@ -257,11 +259,7 @@ class TinodeWeb extends React.Component {
if (this.state.desktopAlertsEnabled) {
this.initFCMessaging().then(() => {
if (this.state.desktopAlerts) {
if (!this.state.firebaseToken) {
this.togglePushToken(true);
} else {
this.tinode.setDeviceToken(this.state.firebaseToken);
}
this.tinode.setDeviceToken(this.state.firebaseToken);
}
}).catch(() => {
// do nothing: handled earlier.
@@ -317,11 +315,22 @@ class TinodeWeb extends React.Component {
return tinode;
}
// Notifiy Tinode that a push message was received from the server.
handlePushMessage(data) {
if (data.what == 'msg' && Tinode.isChannelTopicName(data.topic)) {
// The last argument is a fake user Id: otherwise the update is seen as one from the current user.
this.tinode.oobNotification(data.topic, data.seq, 'fake-uid');
// Tinode received a push notification from the server.
handlePushMessage(payload) {
const data = payload.data || {};
switch (data.what) {
case 'msg':
if (Tinode.isChannelTopicName(data.topic)) {
// The last argument is a fake user Id: otherwise the update is seen as one from the current user.
this.tinode.oobNotification('msg', data.topic, data.seq, 'fake-uid');
}
break;
case 'read':
// Message read on another device, clear it here.
this.tinode.oobNotification('read', data.topic, data.seq);
break;
default:
console.warn("Unknown push type ignored", data.what, data);
}
}
@@ -330,40 +339,77 @@ class TinodeWeb extends React.Component {
const onError = (msg, err) => {
console.error(msg, err);
this.handleError(formatMessage(messages.push_init_failed), 'err');
this.setState({desktopAlertsEnabled: false});
this.setState({desktopAlertsEnabled: false, firebaseToken: null});
LocalStorageUtil.updateObject('settings', {desktopAlerts: false});
}
try {
this.fbPush = firebase.initializeApp(FIREBASE_INIT, APP_NAME).messaging();
this.fbPush.usePublicVapidKey(FIREBASE_INIT.messagingVapidKey);
this.fcm = firebaseGetMessaging(firebaseInitApp(FIREBASE_INIT, APP_NAME));
return navigator.serviceWorker.register('/service-worker.js').then((reg) => {
this.checkForAppUpdate(reg);
this.fbPush.useServiceWorker(reg);
reg.active.postMessage(JSON.stringify({locale: locale, version: PACKAGE_VERSION}));
// Google could not be bothered to mention that onTokenRefresh is never called.
this.fbPush.onTokenRefresh(() => {
this.requestPushToken();
});
return reg;
}).then((reg) => {
// Request token.
return TinodeWeb.requestFCMToken(this.fcm, reg);
}).then((token) => {
const persist = LocalStorageUtil.getObject('keep-logged-in');
if (token != this.state.firebaseToken) {
this.tinode.setDeviceToken(token);
if (persist) {
LocalStorageUtil.setObject('firebase-token', token);
}
}
this.setState({firebaseToken: token, desktopAlerts: true});
if (persist) {
LocalStorageUtil.updateObject('settings', {desktopAlerts: true});
}
// Handhe FCM pushes
// (a) for channels always,
// (b) pushes when the app is in foreground but has not focus.
this.fbPush.onMessage(payload => { this.handlePushMessage(payload.data); });
return reg;
// (b) pushes when the app is in foreground but has no focus.
firebaseOnMessage(this.fcm, (payload) => { this.handlePushMessage(payload); });
}).catch((err) => {
// registration failed :(
onError("Failed to register service worker:", err);
return Promise.reject(err);
// SW registration or FCM has failed :(
onError(err);
throw err;
});
} catch (err) {
onError("Failed to initialize push notifications", err);
onError(err);
return Promise.reject(err);
}
}
// Google's FCM API is idiotic.
static requestFCMToken(fcm, sw) {
return firebaseGetToken(fcm, {
serviceWorkerRegistration: sw,
vapidKey: FIREBASE_INIT.messagingVapidKey
}).then((token) => {
if (token) {
return token;
} else {
// Try to request permissions.
return Notification.requestPermission().then((permission) => {
if (permission === 'granted') {
return firebaseGetToken(fcm, {
serviceWorkerRegistration: reg,
vapidKey: FIREBASE_INIT.messagingVapidKey
}).then((token) => {
if (token) {
return token;
} else {
throw new Error("Failed to initialize notifications");
}
});
} else {
throw new Error("No permission to send notifications: " + permission);
}
});
}
});
}
handleResize() {
const mobile = document.documentElement.clientWidth <= MEDIA_BREAKPOINT;
this.setState({
@@ -414,14 +460,10 @@ class TinodeWeb extends React.Component {
// Clear invalid topic name.
topicName = null;
}
const newState = {
topicSelected: topicName
};
const acs = this.tinode.getTopicAccessMode(topicName);
if (acs) {
newState.topicSelectedAcs = acs;
}
this.setState(newState);
this.setState({
topicSelected: topicName,
topicSelectedAcs: this.tinode.getTopicAccessMode(topicName)
});
}
} else {
// Empty hashpath
@@ -544,7 +586,7 @@ class TinodeWeb extends React.Component {
this.handleError(
formatMessage(messages.reconnect_countdown, {seconds: timeLeft}),
'warn',
() => {
_ => {
clearInterval(this.reconnectCountdown);
this.tinode.reconnect();
},
@@ -865,7 +907,7 @@ class TinodeWeb extends React.Component {
});
}
// User clicked on a contact in the side panel or deleted a contact.
// User clicked on a topic in the side panel or deleted a topic.
handleTopicSelected(topicName) {
// Clear newTopicParams after use.
if (this.state.newTopicParams && this.state.newTopicParams._topicName != topicName) {
@@ -881,7 +923,7 @@ class TinodeWeb extends React.Component {
mobilePanel: 'topic-view',
infoPanel: undefined
});
// Different contact selected.
// Different topic selected.
if (this.state.topicSelected != topicName) {
this.setState({
topicSelectedOnline: this.tinode.isTopicOnline(topicName),
@@ -918,14 +960,14 @@ class TinodeWeb extends React.Component {
// with attachments.
// - msg - Drafty message with content
// - promise - Promise to be resolved when the upload is completed
// - uploader - for tracking progress
// - uploadCompletionPromise - for tracking progress
// - head - head dictionary to be attached to the message
handleSendMessage(msg, promise, uploader, head) {
handleSendMessage(msg, uploadCompletionPromise, uploader, head) {
const topic = this.tinode.getTopic(this.state.topicSelected);
this.sendMessageToTopic(topic, msg, promise, uploader, head);
this.sendMessageToTopic(topic, msg, uploadCompletionPromise, uploader, head);
}
sendMessageToTopic(topic, msg, promise, uploader, head) {
sendMessageToTopic(topic, msg, uploadCompletionPromise, uploader, head) {
msg = topic.createMessage(msg, false);
// The uploader is used to show progress.
msg._uploader = uploader;
@@ -934,33 +976,33 @@ class TinodeWeb extends React.Component {
msg.head = Object.assign(msg.head || {}, head);
}
const completion = [];
if (uploadCompletionPromise) {
completion.push(uploadCompletionPromise);
}
if (!topic.isSubscribed()) {
// Topic is not subscribed yet. Subscribe.
if (!promise) {
promise = Promise.resolve();
}
promise = promise
.then(() => topic.subscribe())
.then(() => {
// If there are unsent messages, try sending them now.
topic.queuedMessages((pub) => {
if (!pub._sending && topic.isSubscribed()) {
this.sendMessage(pub);
}
const subscribePromise =
topic.subscribe()
.then(() => {
// If there are unsent messages, try sending them now.
topic.queuedMessages(pub => {
if (pub._sending || pub.seq == msg.seq) {
return;
}
if (topic.isSubscribed()) {
topic.publishMessage(pub);
}
});
});
});
completion.push(subscribePromise);
}
if (promise) {
promise = promise.catch((err) => {
this.handleError(err.message, 'err');
});
}
topic.publishDraft(msg, promise)
.then((ctrl) => {
topic.publishDraft(msg, Promise.all(completion))
.then(_ => {
if (topic.isArchived()) {
return topic.archive(false);
topic.archive(false);
}
})
.catch((err) => {
@@ -1101,31 +1143,19 @@ class TinodeWeb extends React.Component {
this.state.myUserId ? 'blocked' : ''));
}
togglePushToken(enabled) {
toggleFCMToken(enabled) {
if (enabled) {
this.setState({desktopAlerts: null});
if (!this.state.firebaseToken) {
const fcm = this.fbPush ?
Promise.resolve() :
this.initFCMessaging();
fcm.then(() => {
return this.fbPush.requestPermission();
}).then(() => {
this.requestPushToken();
}).catch((err) => {
console.error("Failed to get notification permission.", err);
this.handleError(err.message, 'err');
this.setState({desktopAlerts: false, firebaseToken: null});
LocalStorageUtil.updateObject('settings', {desktopAlerts: false});
});
this.initFCMessaging();
} else {
this.setState({desktopAlerts: true});
if (LocalStorageUtil.getObject('keep-logged-in')) {
LocalStorageUtil.updateObject('settings', {desktopAlerts: true});
}
}
} else if (this.state.firebaseToken && this.fbPush) {
this.fbPush.deleteToken(this.state.firebaseToken).catch((err) => {
} else if (this.state.firebaseToken && this.fcm) {
firebaseDelToken(this.fcm).catch((err) => {
console.error("Unable to delete token.", err);
}).finally(() => {
LocalStorageUtil.updateObject('settings', {desktopAlerts: false});
@@ -1140,25 +1170,6 @@ class TinodeWeb extends React.Component {
}
}
requestPushToken() {
this.fbPush.getToken().then((refreshedToken) => {
const persist = LocalStorageUtil.getObject('keep-logged-in');
if (refreshedToken != this.state.firebaseToken) {
this.tinode.setDeviceToken(refreshedToken);
if (persist) {
LocalStorageUtil.setObject('firebase-token', refreshedToken);
}
}
this.setState({firebaseToken: refreshedToken, desktopAlerts: true});
if (persist) {
LocalStorageUtil.updateObject('settings', {desktopAlerts: true});
}
}).catch((err) => {
this.handleError(err.message, 'err');
console.error("Failed to retrieve firebase token", err);
});
}
handleToggleMessageSounds(enabled) {
this.setState({messageSounds: enabled});
LocalStorageUtil.updateObject('settings', {
@@ -1338,24 +1349,30 @@ class TinodeWeb extends React.Component {
localStorage.removeItem('firebase-token');
localStorage.removeItem('settings');
if (this.state.firebaseToken) {
this.fbPush.deleteToken(this.state.firebaseToken)
firebaseDelToken(this.fcm);
}
clearInterval(this.reconnectCountdown);
let cleared;
if (this.tinode) {
this.tinode.clearStorage();
cleared = this.tinode.clearStorage();
this.tinode.onDisconnect = undefined;
this.tinode.disconnect();
} else {
cleared = Promose.resolve();
}
this.setState(this.getBlankState());
this.tinode = TinodeWeb.tnSetup(this.state.serverAddress,
this.state.transport, this.props.intl.locale, LocalStorageUtil.getObject('keep-logged-in'));
this.tinode.onConnect = this.handleConnected;
this.tinode.onDisconnect = this.handleDisconnect;
this.tinode.onAutoreconnectIteration = this.handleAutoreconnectIteration;
HashNavigation.navigateTo('');
cleared.then(() => {
this.tinode = TinodeWeb.tnSetup(this.state.serverAddress,
this.state.transport, this.props.intl.locale, LocalStorageUtil.getObject('keep-logged-in'), () => {
this.tinode.onConnect = this.handleConnected;
this.tinode.onDisconnect = this.handleDisconnect;
this.tinode.onAutoreconnectIteration = this.handleAutoreconnectIteration;
HashNavigation.navigateTo('');
})
});
}
handleDeleteAccount() {
@@ -1454,15 +1471,11 @@ class TinodeWeb extends React.Component {
this.handleSidepanelCancel();
}
const header = '➦ ' + params.userName;
const content = typeof params.content == 'string' ?
Tinode.Drafty.init(params.content) : Tinode.Drafty.forwardedContent(params.content);
const preview = Tinode.Drafty.preview(content, FORWARDED_PREVIEW_LENGTH, true);
const msg = Tinode.Drafty.append(
Tinode.Drafty.appendLineBreak(
Tinode.Drafty.mention(header, params.userFrom)),
content);
const content = typeof params.content == 'string' ? Drafty.init(params.content) : Drafty.forwardedContent(params.content);
const preview = Drafty.preview(content, FORWARDED_PREVIEW_LENGTH, true);
const msg = Drafty.append(Drafty.appendLineBreak(Drafty.mention(header, params.userFrom)), content);
const msgPreview = Drafty.quote(header, params.userFrom, preview);
const msgPreview = Tinode.Drafty.quote(header, params.userFrom, preview);
const head = {
forwarded: params.topicName + ':' + params.seq
};
@@ -1714,7 +1727,7 @@ class TinodeWeb extends React.Component {
onUpdateAccountDesc={this.handleTopicUpdateRequest}
onUpdatePassword={this.handleUpdatePasswordRequest}
onUpdateAccountTags={this.handleUpdateAccountTagsRequest}
onTogglePushNotifications={this.togglePushToken}
onTogglePushNotifications={this.toggleFCMToken}
onToggleMessageSounds={this.handleToggleMessageSounds}
onToggleIncognitoMode={this.handleToggleIncognitoMode}
onCredAdd={this.handleCredAdd}
+260
View File
@@ -0,0 +1,260 @@
// Audio recorder widget.
import React from 'react';
import { secondsToTime } from '../lib/strformat';
import { base64ToIntArray } from '../lib/blob-helpers';
// Make canvas bigger than the element size to reduce blurring.
const CANVAS_UPSCALING = 2.0;
// Thickness of a visualization bar.
const LINE_WIDTH = 3 * CANVAS_UPSCALING;
// Spacing between two visualization bars.
const SPACING = 2 * CANVAS_UPSCALING;
// Color of histogram bars.
const BAR_COLOR = '#888A';
const BAR_COLOR_DARK = '#666C';
const THUMB_COLOR = '#444E';
// Minimum number of amplitude bars to draw.
const MIN_PREVIEW_LENGTH = 16;
export default class AudioPlayer extends React.PureComponent {
constructor(props) {
super(props);
let preview = base64ToIntArray(this.props.preview);
if (!Array.isArray(preview) || preview.length < MIN_PREVIEW_LENGTH) {
preview = null;
}
this.state = {
canPlay: false,
playing: false,
currentTime: '0:00',
duration: this.props.duration > 0 ? secondsToTime(this.props.duration / 1000) : '-:--',
longMin: this.props.duration >= 600000,
preview: preview
};
this.initAudio = this.initAudio.bind(this);
this.initCanvas = this.initCanvas.bind(this);
this.resampleBars = this.resampleBars.bind(this);
this.visualize = this.visualize.bind(this);
this.handlePlay = this.handlePlay.bind(this);
this.handleSeek = this.handleSeek.bind(this);
this.handleError = this.handleError.bind(this);
this.audioPlayer = null;
this.viewBuffer = [];
this.canvasRef = React.createRef();
}
componentDidMount() {
if (this.props.src) {
this.initAudio();
}
this.initCanvas();
}
componentWillUnmount() {
if (this.audioPlayer) {
this.audioPlayer.oncanplay = null;
this.audioPlayer.ontimeupdate = null;
this.audioPlayer.onended = null;
this.audioPlayer.pause();
this.audioPlayer = null;
}
}
componentDidUpdate(prevProps) {
if (this.props.src != prevProps.src) {
this.initAudio();
}
if (this.props.preview != prevProps.preview) {
let preview = base64ToIntArray(this.props.preview);
if (!Array.isArray(preview) || preview.length < MIN_PREVIEW_LENGTH) {
preview = null;
}
this.setState({preview: preview}, this.initCanvas);
}
}
initAudio() {
this.audioPlayer = new Audio(this.props.src);
this.audioPlayer.oncanplay = _ => this.setState({canPlay: true});
this.audioPlayer.ontimeupdate = _ => this.setState({
currentTime: secondsToTime(this.audioPlayer.currentTime, this.state.longMin)
});
this.audioPlayer.onended = _ => {
this.audioPlayer.currentTime = 0;
this.setState({playing: false, currentTime: secondsToTime(0, this.state.longMin)})
}
}
initCanvas() {
// Force canvas aspect ratio to match one of the element + upscale canvas to reduce blurring.
this.canvasRef.current.width = this.canvasRef.current.offsetWidth * CANVAS_UPSCALING;
this.canvasRef.current.height = this.canvasRef.current.offsetHeight * CANVAS_UPSCALING;
this.canvasContext = this.canvasRef.current.getContext('2d');
this.canvasContext.lineCap = 'round';
this.viewBuffer = this.resampleBars(this.state.preview);
this.visualize();
}
// Draw amplitude of sound.
visualize() {
if (!this.canvasRef.current) {
return;
}
const width = this.effectiveWidth;
const height = this.canvasRef.current.height;
this.canvasContext.lineWidth = LINE_WIDTH;
const drawFrame = () => {
if (!this.canvasRef.current || !this.audioPlayer) {
// The component is unmounted.
return;
}
this.canvasContext.clearRect(0, 0, this.canvasRef.current.width, height);
if (this.viewBuffer) {
if (this.state.playing) {
window.requestAnimationFrame(drawFrame);
}
// Current playback position.
const thumbAt = this.props.duration ?
Math.max(0, Math.min(this.audioPlayer.currentTime * 1000 / this.props.duration, 1)) * (width - LINE_WIDTH * 2) : -1;
// Draw amplitude bars.
this.canvasContext.beginPath();
this.canvasContext.strokeStyle = BAR_COLOR_DARK;
for (let i = 0; i < this.viewBuffer.length; i++) {
let x = 1 + i * (LINE_WIDTH + SPACING) + LINE_WIDTH * 0.5;
let y = this.viewBuffer[i] * height * 0.9;
const color = x < thumbAt ? BAR_COLOR_DARK : BAR_COLOR;
if (this.canvasContext.strokeStyle != color) {
this.canvasContext.stroke();
this.canvasContext.beginPath();
this.canvasContext.strokeStyle = color;
}
this.canvasContext.moveTo(x, (height - y) * 0.5);
this.canvasContext.lineTo(x, height * 0.5 + y * 0.5);
}
// Actually draw the bars on canvas.
this.canvasContext.stroke();
// Draw thumb.
if (this.props.duration) {
this.canvasContext.beginPath();
this.canvasContext.arc(thumbAt + LINE_WIDTH * 2, height * 0.5, LINE_WIDTH * 2, 0, 2 * Math.PI);
this.canvasContext.fillStyle = THUMB_COLOR;
this.canvasContext.fill();
}
}
}
drawFrame();
}
// Quick and dirty downsampling of the original preview bars into a smaller (or equal) number of bars we can display here.
resampleBars(original) {
const dstCount = ((this.canvasRef.current.width - SPACING) / (LINE_WIDTH + SPACING)) | 0;
// Remove extra padding on the right due to fractional bar which is not drawn.
this.effectiveWidth = dstCount * (LINE_WIDTH + SPACING) + SPACING;
if (!Array.isArray(original) || original.length == 0) {
return Array.apply(null, Array(dstCount)).map(_ => 0.01);
}
const factor = original.length / dstCount;
let amps = [];
let maxAmp = -1;
for (let i=0; i<dstCount; i++) {
let lo = (i * factor) | 0; // low bound;
let hi = ((i + 1) * factor) | 0; // high bound;
if (hi == lo) {
amps[i] = original[lo];
} else {
let amp = 0.0;
for (let j=lo; j<hi; j++) {
amp += original[j]
}
amps[i] = Math.max(0, amp / (hi - lo));
}
maxAmp = Math.max(amps[i], maxAmp);
}
if (maxAmp > 0) {
return amps.map(a => a / maxAmp);
}
return Array.apply(null, Array(dstCount)).map(_ => 0.01);
}
handlePlay(e) {
e.preventDefault();
if (!this.state.canPlay) {
return;
}
if (this.state.playing) {
this.audioPlayer.pause();
this.setState({playing: false});
} else {
this.audioPlayer.play();
this.setState({playing: true}, this.visualize);
}
}
handleError(err) {
console.log(err);
}
handleSeek(e) {
e.preventDefault();
if (e.target && this.props.duration) {
const rect = e.target.getBoundingClientRect();
const offset = (e.clientX - rect.left) / this.effectiveWidth * CANVAS_UPSCALING;
this.audioPlayer.currentTime = this.props.duration * offset / 1000;
this.setState({currentTime: secondsToTime(this.audioPlayer.currentTime, this.state.longMin)});
if (!this.state.playing) {
this.visualize();
}
}
}
render() {
const playClass = 'material-icons' +
(this.props.short ? '' : ' large') +
(this.state.canPlay ? '' : ' disabled');
const play = (<a href="#" onClick={this.handlePlay} title="Play">
<i className={playClass}>{this.state.playing ? 'pause_circle' :
(this.state.canPlay ? 'play_circle' : 'not_interested')}</i>
</a>);
return (<div className="audio-player">{this.props.short ?
<>
<canvas className="playback" ref={this.canvasRef} onClick={this.handleSeek} />
{play}
</>
:
<>
{play}
<div>
<canvas className="playback" ref={this.canvasRef} onClick={this.handleSeek} />
<div className="timer">{this.state.currentTime}/{this.state.duration}</div>
</div>
</>
}
</div>);
}
}
+346
View File
@@ -0,0 +1,346 @@
// Audio recorder widget.
import React from 'react';
import AudioPlayer from './audio-player.jsx';
// Workaround for https://bugs.chromium.org/p/chromium/issues/detail?id=642012
import fixWebmDuration from 'fix-webm-duration';
import { intArrayToBase64 } from '../lib/blob-helpers.js'
import { secondsToTime } from '../lib/strformat';
import { MAX_DURATION, MIN_DURATION } from '../config.js';
// FFT resolution.
const BUFFER_SIZE = 256;
// Make canvas bigger than the element size to reduce blurring.
const CANVAS_UPSCALING = 2.0;
// Thickness of a visualization bar.
const LINE_WIDTH = 3 * CANVAS_UPSCALING;
// Spacing between two visualization bars.
const SPACING = 2 * CANVAS_UPSCALING;
// Duration represented by one visualization bar.
const MILLIS_PER_BAR = 100;
// Color of histogram bars
const BAR_COLOR = '#BBBD';
// Vertical scaling for visualization bars.
const BAR_SCALE = 64.0;
// Number of bars in preview.
const VISUALIZATION_BARS = 96;
// Maximum number of samples per bar.
const MAX_SAMPLES_PER_BAR = 10;
// Recording format.
const AUDIO_MIME_TYPE = 'audio/webm';
export default class AudioRecorder extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
enabled: true,
audioRecord: null,
recording: true,
paused: false,
duration: '0:00',
blobUrl: null,
preview: null
};
this.visualize = this.visualize.bind(this);
this.initMediaRecording = this.initMediaRecording.bind(this);
this.initCanvas = this.initCanvas.bind(this);
this.getRecording = this.getRecording.bind(this);
this.cleanUp = this.cleanUp.bind(this);
this.handleResume = this.handleResume.bind(this);
this.handlePause = this.handlePause.bind(this);
this.handleDelete = this.handleDelete.bind(this);
this.handleDone = this.handleDone.bind(this);
this.durationMillis = 0;
this.startedOn = null;
this.viewBuffer = [];
this.canvasRef = React.createRef();
}
componentDidMount() {
this.stream = null;
this.mediaRecorder = null;
this.audioContext = null;
this.audioInput = null;
this.analyser = null;
this.audioChunks = [];
// Start recorder right away.
try {
navigator.mediaDevices.getUserMedia({audio: true, video: false}).then(this.initMediaRecording, this.props.onError);
} catch (err) {
this.props.onError(err);
}
}
componentWillUnmount() {
this.startedOn = null;
if (this.stream) {
this.cleanUp();
}
}
// Draw amplitude of sound.
visualize() {
this.initCanvas();
const pcmData = new Uint8Array(this.analyser.frequencyBinCount);
const width = this.canvasWidth;
const height = this.canvasHeight;
// Number of bars.
const viewLength = (width / (LINE_WIDTH + SPACING)) | 0;
// Duration of audio which fits onto the screen.
const viewDuration = MILLIS_PER_BAR * viewLength;
this.canvasContext.lineWidth = LINE_WIDTH;
this.canvasContext.strokeStyle = BAR_COLOR;
let prevBarCount = 0;
let volume = 0.0;
let countPerBar = 0;
const drawFrame = () => {
if (!this.startedOn) {
return;
}
window.requestAnimationFrame(drawFrame);
const duration = this.durationMillis + (Date.now() - this.startedOn);
// Update record length timer.
this.setState({duration: secondsToTime(duration / 1000)});
// Check if record is too long.
if (duration > MAX_DURATION) {
this.startedOn = null;
this.mediaRecorder.pause();
this.durationMillis += Date.now() - this.startedOn;
this.setState({enabled: false, recording: false, duration: secondsToTime(this.durationMillis / 1000)});
}
// Draw histogram.
// Get current waveform and calculate its amplitude.
this.analyser.getByteTimeDomainData(pcmData);
let amp = 0.0;
for (const amplitude of pcmData) {
amp += (amplitude - 127) ** 2;
}
// Sum the amplitude.
volume += Math.sqrt(amp/pcmData.length);
countPerBar ++;
let barCount = (duration / MILLIS_PER_BAR) | 0;
// Shift of the histogram along x-axis to make scrolling smooth. No need to shift if recording is too short.
const dx = viewDuration > duration ? 0 :
(duration - MILLIS_PER_BAR * barCount) / MILLIS_PER_BAR * (LINE_WIDTH + SPACING);
if (prevBarCount != barCount) {
prevBarCount = barCount;
// Add new amplitude visualization bar.
this.viewBuffer.push(volume/countPerBar);
volume = 0.0;
countPerBar = 0;
if (this.viewBuffer.length > viewLength) {
// Keep at most 'viewLength' amplitude bars.
this.viewBuffer.shift();
}
}
// Clear canvas.
this.canvasContext.clearRect(0, 0, width, height);
// Draw amplitude bars.
this.canvasContext.beginPath();
for (let i = 0; i < this.viewBuffer.length; i++) {
let x = i * (LINE_WIDTH + SPACING) - dx;
let y = Math.min(this.viewBuffer[i] / BAR_SCALE, 0.9) * height;
this.canvasContext.moveTo(x, (height - y) * 0.5);
this.canvasContext.lineTo(x, height * 0.5 + y * 0.5);
}
// Actually draw the bars on canvas.
this.canvasContext.stroke();
}
drawFrame();
}
handlePause(e) {
e.preventDefault();
this.mediaRecorder.pause();
this.mediaRecorder.requestData();
this.durationMillis += Date.now() - this.startedOn;
this.startedOn = null;
this.setState({recording: false});
}
handleResume(e) {
e.preventDefault();
if (this.state.enabled) {
this.startedOn = Date.now();
this.mediaRecorder.resume();
this.setState({recording: true}, this.visualize);
}
}
handleDelete(e) {
e.preventDefault();
this.durationMillis = 0;
this.startedOn = null;
this.mediaRecorder.stop();
this.cleanUp();
this.setState({recording: false});
}
handleDone(e) {
e.preventDefault();
this.setState({recording: false});
if (this.startedOn) {
this.durationMillis += Date.now() - this.startedOn;
this.startedOn = null;
}
// Stop recording and return data.
if (this.mediaRecorder) {
this.mediaRecorder.stop();
}
}
initCanvas() {
this.canvasRef.current.width = this.canvasRef.current.offsetWidth * CANVAS_UPSCALING;
this.canvasRef.current.height = this.canvasRef.current.offsetHeight * CANVAS_UPSCALING;
this.canvasContext = this.canvasRef.current.getContext('2d');
this.canvasContext.lineCap = 'round';
// To reduce line blurring.
this.canvasContext.translate(0.5, 0.5);
this.canvasWidth = this.canvasRef.current.width;
this.canvasHeight = this.canvasRef.current.height;
}
initMediaRecording(stream) {
this.stream = stream;
this.mediaRecorder = new MediaRecorder(stream, {mimeType: AUDIO_MIME_TYPE});
// The following code is needed for visualization.
this.audioContext = new AudioContext();
this.audioInput = this.audioContext.createMediaStreamSource(stream);
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = BUFFER_SIZE;
this.audioInput.connect(this.analyser);
this.mediaRecorder.onstop = _ => {
if (this.durationMillis > MIN_DURATION) {
this.getRecording(this.mediaRecorder.mimeType, this.durationMillis)
.then(result => this.props.onFinished(result.url, result.preview, this.durationMillis));
} else {
this.props.onDeleted();
}
this.cleanUp();
}
this.mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
this.audioChunks.push(e.data);
}
if (this.mediaRecorder.state != 'inactive') {
this.getRecording(this.mediaRecorder.mimeType).then(result => {
this.setState({
blobUrl: result.url,
preview: result.preview
});
});
}
}
this.durationMillis = 0;
this.startedOn = Date.now();
this.mediaRecorder.start();
this.visualize();
}
// Obtain data in a form sutable for sending or playing back.
// If duration is valid, apply fix for Chrome's WebM duration bug.
getRecording(mimeType, duration) {
let blob = new Blob(this.audioChunks, {type: mimeType || AUDIO_MIME_TYPE});
return (duration > 0 ? fixWebmDuration(blob, duration, {logger: false}) : Promise.resolve(blob))
.then(fixedBlob => { blob = fixedBlob; return fixedBlob.arrayBuffer(); })
.then(arrayBuff => this.audioContext.decodeAudioData(arrayBuff))
.then(decoded => this.createPreview(decoded))
.then(preview => ({url: window.URL.createObjectURL(blob), preview: intArrayToBase64(preview)}));
}
// Preview must be calculated at the source: Chrome does not allow background AudioContext.
createPreview(audio) {
const data = audio.getChannelData(0);
// Number of amplitude bars in preview.
const viewLength = Math.min(data.length, VISUALIZATION_BARS);
// The number of samples in each bar.
const totalSPB = (data.length / viewLength) | 0;
// Distance between samples: we are going to take just a fracton of samples.
const samplingRate = Math.max(1, (totalSPB / MAX_SAMPLES_PER_BAR) | 0);
let buffer = [];
let max = -1;
for (let i = 0; i < viewLength; i++) {
let amplitude = 0;
let count = 0;
for (let j = 0; j < totalSPB; j += samplingRate) {
amplitude += data[totalSPB * i + j] ** 2;
count ++;
}
const val = Math.sqrt(amplitude / count);
buffer.push(val);
max = Math.max(max, val);
}
// Normalize amplitude to 0..100.
if (max > 0) {
buffer = buffer.map(a => (100 * a / max) | 0);
}
return buffer;
}
cleanUp() {
this.audioInput.disconnect();
this.stream.getTracks().forEach(track => track.stop());
}
render() {
const resumeClass = 'material-icons ' + (this.state.enabled ? 'red' : 'gray');
return (
<div className="audio">
<a href="#" onClick={this.handleDelete} title="Delete">
<i className="material-icons gray">delete_outline</i>
</a>
{this.state.recording ?
<canvas ref={this.canvasRef} />
:
<AudioPlayer
src={this.state.blobUrl}
preview={this.state.preview}
duration={this.durationMillis}
short={true} />
}
<div className="duration">{this.state.duration}</div>
{this.state.recording ?
<a href="#" onClick={this.handlePause} title="Pause">
<i className="material-icons">pause_circle_outline</i>
</a> :
<a href="#" onClick={this.handleResume} title="Resume">
<i className={resumeClass}>radio_button_checked</i>
</a>
}
<a href="#" onClick={this.handleDone} title="Send">
<i className="material-icons">send</i>
</a>
</div>
);
}
}
+2 -2
View File
@@ -3,7 +3,7 @@ import React from 'react';
import LetterTile from './letter-tile.jsx';
import LoadSpinner from './load-spinner.jsx';
import { sanitizeImageUrl } from '../lib/utils.js';
import { sanitizeUrlForMime } from '../lib/utils.js';
import { AVATAR_SIZE } from '../config.js';
@@ -44,7 +44,7 @@ export default class AvatarUpload extends React.Component {
<i className="material-icons">clear</i>
</a>}
{this.state.source ?
<img src={this.props.tinode.authorizeURL(sanitizeImageUrl(this.state.source))} className="preview" /> :
<img src={this.props.tinode.authorizeURL(sanitizeUrlForMime(this.state.source, 'image'))} className="preview" /> :
this.props.readOnly && this.props.uid ?
<div className="avatar-box">
<LetterTile
+8 -15
View File
@@ -1,7 +1,8 @@
// Single message, sent or received.
// Single chat message bubble, sent or received.
import React from 'react';
import { FormattedMessage, injectIntl } from 'react-intl';
import { Drafty } from 'tinode-sdk';
import { Drafty, Tinode } from 'tinode-sdk';
import Attachment from './attachment.jsx';
import LetterTile from './letter-tile.jsx';
@@ -110,11 +111,10 @@ class BaseChatMessage extends React.PureComponent {
}
render() {
const sideClass = this.props.deleted ? 'center' :
(this.props.sequence + ' ' + (this.props.response ? 'left' : 'right'));
const sideClass = this.props.sequence + ' ' + (this.props.response ? 'left' : 'right');
const bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ? 'bubble tip' : 'bubble';
const avatar = this.props.deleted ? null : (this.props.userAvatar || true);
const fullDisplay = (this.props.isGroup && this.props.response && !this.props.deleted &&
const avatar = this.props.userAvatar || true;
const fullDisplay = (this.props.isGroup && this.props.response &&
(this.props.sequence == 'single' || this.props.sequence == 'last'));
let content = this.props.content;
@@ -140,12 +140,6 @@ class BaseChatMessage extends React.PureComponent {
}, 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.
content = <><i className="material-icons gray">block</i> <i className="gray">
<FormattedMessage id="deleted_content"
defaultMessage="content deleted" description="Shown when messages are deleted" />
</i></>
} else if (typeof content != 'string') {
content = <><i className="material-icons gray">warning_amber</i> <i className="gray">
<FormattedMessage id="invalid_content"
@@ -178,13 +172,12 @@ class BaseChatMessage extends React.PureComponent {
received={this.props.received} />
: null}
</div>
{this.props.deleted || !this.props.showContextMenu ?
null :
{this.props.showContextMenu ?
<span className="menuTrigger">
<a href="#" onClick={this.handleContextClick}>
<i className="material-icons">expand_more</i>
</a>
</span>
</span> : null
}
</div>
{fullDisplay ?
+2 -2
View File
@@ -3,8 +3,7 @@
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { Drafty } from 'tinode-sdk';
import Tinode from 'tinode-sdk';
import { Drafty, Tinode } from 'tinode-sdk';
import Contact from './contact.jsx';
import ContactAction from './contact-action.jsx';
@@ -115,6 +114,7 @@ class ContactList extends React.Component {
isVerified={c.trusted && c.trusted.verified}
isStaff={c.trusted && c.trusted.staff}
isDangerous={c.trusted && c.trusted.danger}
deleted={c._deleted}
item={key}
index={contactNodes.length}
key={key} />
+10 -7
View File
@@ -4,7 +4,6 @@ import { FormattedMessage, injectIntl } from 'react-intl';
import ContactBadges from './contact-badges.jsx';
import LetterTile from './letter-tile.jsx';
import ReceivedMarker from './received-marker.jsx'
import UnreadBadge from './unread-badge.jsx';
import { Drafty } from 'tinode-sdk';
@@ -87,6 +86,7 @@ class Contact extends React.Component {
const icon = deliveryMarker(this.props.received);
const marker = icon ? <i className={'material-icons small space-right' +
(icon.color ? ' ' + icon.color : '')}>{icon.name}</i> : null;
const titleClass = 'contact-title' + (this.props.deleted ? ' deleted' : '');
return (
<li className={!this.props.showCheckmark && this.props.selected ? 'selected' : null} onClick={this.handleClick}>
@@ -95,16 +95,19 @@ class Contact extends React.Component {
tinode={this.props.tinode}
avatar={avatar}
title={this.props.title}
topic={this.props.item} />
{this.props.showOnline ? <span className={online} /> :
(this.props.showCheckmark && this.props.selected ?
topic={this.props.item}
deleted={this.props.deleted} />
{this.props.deleted ? <i className="deleted material-icons">cancel</i> :
this.props.showOnline ? <span className={online} /> :
(this.props.showCheckmark && this.props.selected) ?
<i className="checkmark material-icons">check_circle</i>
: null)}
: null}
</div>
<div className="text-box">
<div><span className="contact-title">{title}</span>
<div><span className={titleClass}>{title}</span>
{this.props.isChannel ? <img src="/img/channel.png" className="channel" alt="channel" /> : null}
<ContactBadges badges={icon_badges} /><UnreadBadge count={this.props.unread} />
<ContactBadges badges={icon_badges} />
{!this.props.deleted ? <UnreadBadge count={this.props.unread} /> : null}
</div>
{this.props.showMode ?
<span><ContactBadges badges={badges} /></span> :
+6 -6
View File
@@ -237,7 +237,7 @@ class ContextMenu extends React.Component {
(() => {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: ", params.topicName);
console.warn("Topic not found: ", params.topicName);
return;
}
return topic.delTopic(true).catch((err) => {
@@ -258,7 +258,7 @@ class ContextMenu extends React.Component {
handler: (params, errorHandler) => {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: ", params.topicName);
console.warn("Topic not found: ", params.topicName);
return;
}
return topic.archive(true).catch((err) => {
@@ -274,7 +274,7 @@ class ContextMenu extends React.Component {
handler: (params, errorHandler) => {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: ", params.topicName);
console.warn("Topic not found: ", params.topicName);
return;
}
return topic.archive(false).catch((err) => {
@@ -296,7 +296,7 @@ class ContextMenu extends React.Component {
handler: (params, errorHandler) => {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic || !params.user) {
console.log("Topic or user not found: '" + params.topicName + "', '" + params.user + "'");
console.warn("Topic or user not found: '" + params.topicName + "', '" + params.user + "'");
return;
}
return topic.delSubscription(params.user).catch((err) => {
@@ -378,7 +378,7 @@ class ContextMenu extends React.Component {
deleteMessages(all, hard, params, errorHandler) {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: ", params.topicName);
console.warn("Topic not found: ", params.topicName);
return;
}
@@ -420,7 +420,7 @@ class ContextMenu extends React.Component {
topicPermissionSetter(mode, params, errorHandler) {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found", params.topicName);
console.warn("Topic not found", params.topicName);
return;
}
+1 -1
View File
@@ -2,7 +2,7 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Tinode from 'tinode-sdk';
import { Tinode } from 'tinode-sdk';
import ContactList from './contact-list.jsx';
import SearchContacts from './search-contacts.jsx';
+9 -9
View File
@@ -1,29 +1,29 @@
import React from 'react';
import Tinode from 'tinode-sdk';
import { Tinode } from 'tinode-sdk';
import { idToColorClass } from '../lib/strformat.js';
import { sanitizeImageUrl } from '../lib/utils.js';
import { sanitizeUrlForMime } from '../lib/utils.js';
export default class LetterTile extends React.PureComponent {
render() {
let avatar;
if (this.props.avatar === true) {
const isGroup = (Tinode.topicType(this.props.topic) == 'grp');
const isGroup = Tinode.isGroupTopicName(this.props.topic);
const iconColor = idToColorClass(this.props.topic, isGroup);
if (this.props.topic && this.props.title && this.props.title.trim()) {
const letter = this.props.title.trim().charAt(0);
const className = 'lettertile ' + iconColor;
const className = 'lettertile ' + iconColor + (this.props.deleted ? ' disabled' : '');
avatar = (<div className={className}><div>{letter}</div></div>)
} else {
const className = 'material-icons ' + iconColor;
avatar = isGroup ?
<i className={className}>group</i> : <i className={className}>person</i>;
const className = 'material-icons ' + iconColor + (this.props.deleted ? ' disabled' : '');
avatar = isGroup ? <i className={className}>group</i> : <i className={className}>person</i>;
}
} else if (this.props.avatar) {
const url = this.props.tinode.authorizeURL(sanitizeImageUrl(this.props.avatar));
const url = this.props.tinode.authorizeURL(sanitizeUrlForMime(this.props.avatar, 'image'));
// If avatar image is invalid, show a placeholder.
avatar = <img className="avatar" alt="avatar" src={url}
const className = 'avatar' + (this.props.deleted ? ' deleted' : '');
avatar = <img className={className} alt="avatar" src={url}
onError={(e)=>{e.target.onerror = null; e.target.src="../img/broken_image.png"}} />;
} else {
avatar = null;
+41
View File
@@ -0,0 +1,41 @@
// Message bubble with non-payload content, such as an indicator of deleted content or a date stamp.
import React from 'react';
import { FormattedMessage } from 'react-intl';
export default class MetaMessage extends React.PureComponent {
constructor(props) {
super(props);
}
render() {
let content = null;
let bubbleClass = 'bubble';
if (this.props.deleted) {
// Message represents a range of deleted messages.
content = <><i className="material-icons gray">block</i> <i className="gray">
<FormattedMessage id="deleted_content"
defaultMessage="content deleted" description="Shown when messages are deleted" />
</i></>
bubbleClass += ' deleted';
} else if (this.props.date) {
// A date badge.
content = <>{this.props.date}</>
bubbleClass += ' date';
}
if (!content) {
return <>{null}</>
}
return (
<li className="meta">
<div className={bubbleClass}>
<div className="message-content">
{content}
</div>
</div>
</li>
);
}
};
+2 -3
View File
@@ -2,9 +2,8 @@
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import Tinode from 'tinode-sdk';
import { Tinode } from 'tinode-sdk';
import { shortDateFormat } from '../lib/strformat.js';
import { deliveryMarker } from '../lib/utils.js';
const messages = defineMessages({
@@ -29,7 +28,7 @@ class ReceivedMarker extends React.PureComponent {
} else if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
timestamp = formatMessage(messages.message_sending_failed);
} else {
timestamp = shortDateFormat(this.props.timestamp, this.props.intl.locale);
timestamp = this.props.timestamp.toLocaleTimeString(this.props.intl.locale, { timeStyle: 'short' });
}
const icon = deliveryMarker(this.props.received);
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import Tinode from 'tinode-sdk';
import { Tinode } from 'tinode-sdk';
export default class SearchContacts extends React.PureComponent {
constructor(props) {
+41 -12
View File
@@ -3,6 +3,8 @@ import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { Drafty } from 'tinode-sdk';
import AudioRecorder from './audio-recorder.jsx';
import { KEYPRESS_DELAY } from '../config.js';
import { filePasted } from '../lib/blob-helpers.js';
import { replyFormatter } from '../lib/formatters.js';
@@ -42,6 +44,8 @@ class SendMessage extends React.PureComponent {
this.state = {
quote: null,
message: '',
audioRec: false,
audioAvailable: !!navigator.mediaDevices.getUserMedia,
// Make initial keypress time as if it happened 5001 milliseconds in the past.
keypressTimestamp: new Date().getTime() - KEYPRESS_DELAY - 1
};
@@ -49,6 +53,7 @@ class SendMessage extends React.PureComponent {
this.handlePasteEvent = this.handlePasteEvent.bind(this);
this.handleAttachImage = this.handleAttachImage.bind(this);
this.handleAttachFile = this.handleAttachFile.bind(this);
this.handleAttachAudio = this.handleAttachAudio.bind(this);
this.handleSend = this.handleSend.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this);
this.handleMessageTyping = this.handleMessageTyping.bind(this);
@@ -77,7 +82,9 @@ class SendMessage extends React.PureComponent {
this.messageEditArea.focus();
}
if (prevProps.reply != this.props.reply) {
if (prevProps.topicName != this.props.topicName) {
this.setState({message: '', audioRec: false, quote: null});
} else if (prevProps.reply != this.props.reply) {
this.setState({quote: this.formatReply()});
}
}
@@ -118,7 +125,6 @@ class SendMessage extends React.PureComponent {
}
handleAttachFile(e) {
const {formatMessage} = this.props.intl;
if (e.target.files && e.target.files.length > 0) {
this.props.onAttachFile(e.target.files[0]);
}
@@ -126,6 +132,11 @@ class SendMessage extends React.PureComponent {
e.target.value = '';
}
handleAttachAudio(url, preview, duration) {
this.setState({audioRec: false});
this.props.onAttachAudio(url, preview, duration);
}
handleSend(e) {
e.preventDefault();
const message = this.state.message.trim();
@@ -137,6 +148,13 @@ class SendMessage extends React.PureComponent {
/* Send on Enter key */
handleKeyPress(e) {
if (this.state.audioRec) {
// Ignore key presses while audio is being recorded.
e.preventDefault();
e.stopPropagation();
return;
}
// Remove this if you don't want Enter to trigger send
if (e.key === 'Enter') {
// Have Shift-Enter insert a line break instead
@@ -185,14 +203,14 @@ class SendMessage extends React.PureComponent {
</div>
{this.state.quote}
</div>);
const audioEnabled = this.state.audioAvailable && this.props.onAttachAudio;
return (
<div id="send-message-wrapper">
{this.state.quote && !this.props.noInput ? quote : null}
<div id="send-message-panel">
{!this.props.disabled ?
<>
{this.props.onAttachFile ?
{this.props.onAttachFile && !this.state.audioRec ?
<>
<a href="#" onClick={(e) => {e.preventDefault(); this.attachImage.click();}} title="Add image">
<i className="material-icons secondary">photo</i>
@@ -205,14 +223,25 @@ class SendMessage extends React.PureComponent {
null}
{this.props.noInput ?
(this.state.quote ? quote : <div className="hr thin" />) :
<textarea id="sendMessage" placeholder={prompt}
value={this.state.message} onChange={this.handleMessageTyping}
onKeyPress={this.handleKeyPress}
ref={(ref) => {this.messageEditArea = ref;}}
autoFocus />}
<a href="#" onClick={this.handleSend} title="Send">
<i className="material-icons">send</i>
</a>
(this.state.audioRec ?
<AudioRecorder
onDeleted={_ => this.setState({audioRec: false})}
onFinished={this.handleAttachAudio}/> :
<textarea id="sendMessage" placeholder={prompt}
value={this.state.message} onChange={this.handleMessageTyping}
onKeyPress={this.handleKeyPress}
ref={(ref) => {this.messageEditArea = ref;}}
autoFocus />)}
{this.state.message || !audioEnabled ?
<a href="#" onClick={this.handleSend} title="Send">
<i className="material-icons">send</i>
</a> :
!this.state.audioRec ?
<a href="#" onClick={e => {e.preventDefault(); this.setState({audioRec: true})}} title="Voice">
<i className="material-icons">mic</i>
</a> :
null
}
<input type="file" ref={(ref) => {this.attachFile = ref;}}
onChange={this.handleAttachFile} style={{display: 'none'}} />
<input type="file" ref={(ref) => {this.attachImage = ref;}} accept="image/*"
+2 -2
View File
@@ -6,7 +6,7 @@ import ContactBadges from './contact-badges.jsx';
import MenuContacts from './menu-contacts.jsx';
import MenuStart from './menu-start.jsx';
import { sanitizeImageUrl } from '../lib/utils.js';
import { sanitizeUrlForMime } from '../lib/utils.js';
export default class SideNavbar extends React.PureComponent {
render() {
@@ -18,7 +18,7 @@ export default class SideNavbar extends React.PureComponent {
}
let avatar = null;
if (this.props.tinode) {
avatar = this.props.tinode.authorizeURL(sanitizeImageUrl(this.props.avatar));
avatar = this.props.tinode.authorizeURL(sanitizeUrlForMime(this.props.avatar, 'image'));
}
return (
<div id="side-caption-panel" className="caption-panel">
+2 -2
View File
@@ -3,7 +3,7 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Tinode from 'tinode-sdk';
import { Tinode } from 'tinode-sdk';
import AvatarCrop from './avatar-crop.jsx';
import AvatarUpload from './avatar-upload.jsx';
@@ -109,7 +109,7 @@ export default class TopicDescEdit extends React.Component {
// for uploading it to the server out of band.
uploadAvatar(mime, blob, width, height) {
const readyToUpload = (image) => {
let {mime, blob, width, height} = image;
let {mime, blob} = image;
if (blob.size > MAX_AVATAR_BYTES) {
// Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper();