Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
408b221bac | ||
|
|
2a11885ac3 | ||
|
|
62e4a4283f | ||
|
|
60b52b342d | ||
|
|
ae2d7a12ad | ||
|
|
9ddd103ac5 | ||
|
|
237e97c323 | ||
|
|
891780bb52 | ||
|
|
c9391cf451 | ||
|
|
3d2af23f14 | ||
|
|
7044ce08c1 | ||
|
|
c5011a4383 | ||
|
|
d0c4251eda | ||
|
|
afea2aeec0 | ||
|
|
e121b1645f | ||
|
|
2ab56887b7 | ||
|
|
7e3a616983 | ||
|
|
e852e2972a | ||
|
|
589b46a8c2 | ||
|
|
cc3b1190fd | ||
|
|
025d4cfadb | ||
|
|
d3cc0571ce | ||
|
|
539a37e5ee |
@@ -8,7 +8,7 @@ Overall, it's a lot like open source WhatsApp or Telegram web apps.
|
||||
|
||||
Although the app is generally usable, keep in mind that this is work in progress. Some bugs probably exist, some features are missing. The app was tested in the latest Chrome & Firefox only. NPM package is [available](https://www.npmjs.com/package/tinode-webapp).
|
||||
|
||||
Try possibly newer or older version live at https://web.tinode.co/.
|
||||
Try possibly newer or older version live at https://sandbox.tinode.co/.
|
||||
|
||||
<a href="https://web.tinode.co/"><img src="web-app.svg" height=36></a>
|
||||
|
||||
|
||||
+44
-2
@@ -2650,15 +2650,57 @@ ul.tabbar li.active > a {
|
||||
border-radius: 1.5rem;
|
||||
}
|
||||
|
||||
.audio canvas.visualiser {
|
||||
width: 12rem;
|
||||
.audio canvas {
|
||||
width: 14rem;
|
||||
height: 2.6rem;
|
||||
margin-right: 2rem;
|
||||
}
|
||||
|
||||
.audio canvas.playback {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.audio .duration {
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
.audio-player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.audio-player .material-icons.large {
|
||||
font-size: 2.4rem;
|
||||
color: #667575;
|
||||
}
|
||||
|
||||
.audio-player .material-icons.disabled {
|
||||
color: #999;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.audio-player .playback {
|
||||
width: 14rem;
|
||||
height: 2.6rem;
|
||||
}
|
||||
|
||||
.audio-player canvas.playback {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.audio-player div.playback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #757575;
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.audio-player .timer {
|
||||
font-size: smaller;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/*
|
||||
Mobile styles.
|
||||
*/
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Generated
+20
-9
@@ -1,19 +1,20 @@
|
||||
{
|
||||
"name": "tinode-webapp",
|
||||
"version": "0.18.3-beta1",
|
||||
"version": "0.19.0-alpha1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "tinode-webapp",
|
||||
"version": "0.18.3-beta1",
|
||||
"version": "0.19.0-alpha1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"firebase": "^9.6.7",
|
||||
"fix-webm-duration": "^1.0.5",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-intl": "^5.20.2",
|
||||
"tinode-sdk": "^0.18.2"
|
||||
"tinode-sdk": "^0.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.14.5",
|
||||
@@ -4274,6 +4275,11 @@
|
||||
"@firebase/util": "1.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fix-webm-duration": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/fix-webm-duration/-/fix-webm-duration-1.0.5.tgz",
|
||||
"integrity": "sha512-b6oula3OfSknx0aWoLsxvp4DVIYbwsf+UAkr6EDAK3iuMYk/OSNKzmeSI61GXK0MmFTEuzle19BPvTxMIKjkZg=="
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.0.1.tgz",
|
||||
@@ -6616,9 +6622,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/tinode-sdk": {
|
||||
"version": "0.18.2",
|
||||
"resolved": "https://registry.npmjs.org/tinode-sdk/-/tinode-sdk-0.18.2.tgz",
|
||||
"integrity": "sha512-K1Yt5akrRsBPmZwhpK0alhhxJ1F8EJai1svDjxbUbFfFQeTE4y4rXck+sqCQwfVjrPGCgjA/JIbuMpGTapMjbQ=="
|
||||
"version": "0.18.3",
|
||||
"resolved": "https://registry.npmjs.org/tinode-sdk/-/tinode-sdk-0.18.3.tgz",
|
||||
"integrity": "sha512-MKI8KL43oF3K5OEYp2Dfyfj9LYogKyE0hw0MYNmHMsg1+rHM/IbnnLLaEnlwD9hAAgmamCPY2AS6Oo2OeiCr1w=="
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.1",
|
||||
@@ -10345,6 +10351,11 @@
|
||||
"@firebase/util": "1.4.3"
|
||||
}
|
||||
},
|
||||
"fix-webm-duration": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/fix-webm-duration/-/fix-webm-duration-1.0.5.tgz",
|
||||
"integrity": "sha512-b6oula3OfSknx0aWoLsxvp4DVIYbwsf+UAkr6EDAK3iuMYk/OSNKzmeSI61GXK0MmFTEuzle19BPvTxMIKjkZg=="
|
||||
},
|
||||
"fs-extra": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.0.1.tgz",
|
||||
@@ -12005,9 +12016,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"tinode-sdk": {
|
||||
"version": "0.18.2",
|
||||
"resolved": "https://registry.npmjs.org/tinode-sdk/-/tinode-sdk-0.18.2.tgz",
|
||||
"integrity": "sha512-K1Yt5akrRsBPmZwhpK0alhhxJ1F8EJai1svDjxbUbFfFQeTE4y4rXck+sqCQwfVjrPGCgjA/JIbuMpGTapMjbQ=="
|
||||
"version": "0.18.3",
|
||||
"resolved": "https://registry.npmjs.org/tinode-sdk/-/tinode-sdk-0.18.3.tgz",
|
||||
"integrity": "sha512-MKI8KL43oF3K5OEYp2Dfyfj9LYogKyE0hw0MYNmHMsg1+rHM/IbnnLLaEnlwD9hAAgmamCPY2AS6Oo2OeiCr1w=="
|
||||
},
|
||||
"tmp": {
|
||||
"version": "0.2.1",
|
||||
|
||||
+1
-1
@@ -2,7 +2,6 @@
|
||||
"name": "tinode-webapp",
|
||||
"description": "Tinode messenger for the web",
|
||||
"version": "0.19.0-alpha1",
|
||||
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tinode/example-react-js.git"
|
||||
@@ -68,6 +67,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"firebase": "^9.6.7",
|
||||
"fix-webm-duration": "^1.0.5",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-intl": "^5.20.2",
|
||||
|
||||
+9
-2
@@ -1362,10 +1362,17 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"translation": "",
|
||||
"translation": "Nicht unterstützt",
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty",
|
||||
"missing": true,
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"preview_unavailable": {
|
||||
"translation": "nicht verfügbar",
|
||||
"defaultMessage": "unavailable",
|
||||
"description": "Message shown when media preview is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1367,5 +1367,12 @@
|
||||
"description": "Unsupported entity in drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"preview_unavailable": {
|
||||
"translation": "unavailable",
|
||||
"defaultMessage": "unavailable",
|
||||
"description": "Message shown when media preview is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -1362,10 +1362,17 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"translation": "",
|
||||
"translation": "No soportado",
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty",
|
||||
"missing": true,
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"preview_unavailable": {
|
||||
"translation": "indisponible",
|
||||
"defaultMessage": "unavailable",
|
||||
"description": "Message shown when media preview is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,6 +608,10 @@
|
||||
"defaultMessage": "phone",
|
||||
"description": "Dative case of 'phone', i.e. 'phone' in 'by phone'"
|
||||
},
|
||||
"preview_unavailable": {
|
||||
"defaultMessage": "unavailable",
|
||||
"description": "Message shown when media preview is not available"
|
||||
},
|
||||
"private_editing_placeholder": {
|
||||
"defaultMessage": "Visible to you only",
|
||||
"description": "Placeholder for editing 'private'"
|
||||
|
||||
+9
-2
@@ -1362,10 +1362,17 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"translation": "",
|
||||
"translation": "지원되지 않음",
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty",
|
||||
"missing": true,
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"preview_unavailable": {
|
||||
"translation": "없는",
|
||||
"defaultMessage": "unavailable",
|
||||
"description": "Message shown when media preview is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -1362,10 +1362,17 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"translation": "",
|
||||
"translation": "Neacceptat",
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty",
|
||||
"missing": true,
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"preview_unavailable": {
|
||||
"translation": "indisponibil",
|
||||
"defaultMessage": "unavailable",
|
||||
"description": "Message shown when media preview is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -1362,10 +1362,17 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"translation": "",
|
||||
"translation": "Не поддерживается",
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty",
|
||||
"missing": true,
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"preview_unavailable": {
|
||||
"translation": "недоступно",
|
||||
"defaultMessage": "unavailable",
|
||||
"description": "Message shown when media preview is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -1362,10 +1362,17 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"translation": "",
|
||||
"translation": "不支持",
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty",
|
||||
"missing": true,
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"preview_unavailable": {
|
||||
"translation": "不可用",
|
||||
"defaultMessage": "unavailable",
|
||||
"description": "Message shown when media preview is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -1362,10 +1362,17 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"translation": "",
|
||||
"translation": "不支持",
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty",
|
||||
"missing": true,
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"preview_unavailable": {
|
||||
"translation": "不可用",
|
||||
"defaultMessage": "unavailable",
|
||||
"description": "Message shown when media preview is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ const language = (params && params.hl) ||
|
||||
const baseLanguage = language.toLowerCase().split(/[-_]/)[0];
|
||||
|
||||
// Try the full locale first, then the locale without the region code, fallback to 'en'.
|
||||
const htmlLang = allMessages[language] ? langauge : allMessages[baseLanguage] ? baseLanguage : 'en';
|
||||
const htmlLang = allMessages[language] ? language : allMessages[baseLanguage] ? baseLanguage : 'en';
|
||||
const messages = allMessages[htmlLang];
|
||||
|
||||
// Set lang attribute of the HTML element: <html lang="XX">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+42
-20
@@ -3,13 +3,14 @@ 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: {
|
||||
@@ -34,15 +35,7 @@ const messages = defineMessages({
|
||||
}
|
||||
});
|
||||
|
||||
// Get comment for unknown element
|
||||
function unsupportedComment(values, ifMissing) {
|
||||
if (Array.isArray(values) && values.join('').trim()) {
|
||||
return values;
|
||||
}
|
||||
return [<span className="gray">{ifMissing}</span>];
|
||||
}
|
||||
|
||||
// Size the already scaled image.
|
||||
// Additional processing of image data.
|
||||
function handleImageData(el, data, attr) {
|
||||
if (!data) {
|
||||
attr.src = 'img/broken_image.png';
|
||||
@@ -66,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) {
|
||||
@@ -104,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';
|
||||
@@ -156,10 +164,17 @@ export function fullFormatter(style, data, values, key, stack) {
|
||||
attr.onClick = this.onQuoteClick;
|
||||
break;
|
||||
default:
|
||||
if (!el) {
|
||||
// Unknown element.
|
||||
el = React.Fragment;
|
||||
values = [<i className="material-icons gray">extension</i>, ' ']
|
||||
.concat(unsupportedComment(values, this.formatMessage(messages.drafty_unknown)));
|
||||
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;
|
||||
}
|
||||
if (!el) {
|
||||
@@ -181,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;
|
||||
@@ -198,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';
|
||||
@@ -223,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':
|
||||
@@ -231,9 +251,11 @@ export function previewFormatter(style, data, values, key) {
|
||||
values = null;
|
||||
break;
|
||||
default:
|
||||
if (!el) {
|
||||
// Unknown element.
|
||||
el = React.Fragment;
|
||||
values = [<i className="material-icons gray">extension</i>, ' ']
|
||||
.concat(unsupportedComment(values, this.formatMessage(messages.drafty_unknown)));
|
||||
values = [<i key="x0" className="material-icons gray">extension</i>, ' ', this.formatMessage(messages.drafty_unknown)];
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!el) {
|
||||
@@ -321,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();
|
||||
|
||||
@@ -19,7 +19,7 @@ export function shortDateFormat(then, locale) {
|
||||
// Convert seconds to minutes:seconds, i.e. 156 sec -> 2:36.
|
||||
// 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);
|
||||
let min = Math.floor(seconds / 60) | 0;
|
||||
if (fixedMin) {
|
||||
min = min < 10 ? `0${min}` : min;
|
||||
}
|
||||
|
||||
+6
-5
@@ -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
File diff suppressed because one or more lines are too long
@@ -18,7 +18,7 @@ import TopicSecurity from '../widgets/topic-security.jsx';
|
||||
import { MAX_TITLE_LENGTH, MAX_TOPIC_DESCRIPTION_LENGTH, NO_ACCESS_MODE } from '../config.js';
|
||||
|
||||
import { makeImageUrl } from '../lib/blob-helpers.js';
|
||||
import { arrayEqual, theCard } from '../lib/utils.js';
|
||||
import { theCard } from '../lib/utils.js';
|
||||
|
||||
const messages = defineMessages({
|
||||
info: {
|
||||
|
||||
+37
-25
@@ -188,9 +188,9 @@ class MessagesView extends React.Component {
|
||||
}
|
||||
|
||||
if (topic && ((this.state.topic != prevState.topic) || !prevProps.ready)) {
|
||||
if (topic._new) {
|
||||
console.log('DEBUG: Fetching new topic description');
|
||||
// topic.getMeta(topic.startMetaQuery().withDesc().build());
|
||||
// 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);
|
||||
}
|
||||
@@ -381,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);
|
||||
@@ -872,13 +875,12 @@ class MessagesView extends React.Component {
|
||||
}
|
||||
|
||||
// sendAudioAttachment sends audio bits inband as Drafty message (no preview).
|
||||
sendAudioAttachment(url, duration) {
|
||||
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) | 0;
|
||||
fetch(url).then(result => {
|
||||
return result.blob();
|
||||
}).then(blob => {
|
||||
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();
|
||||
@@ -891,6 +893,7 @@ class MessagesView extends React.Component {
|
||||
mime: blob.type,
|
||||
size: blob.size,
|
||||
duration: duration,
|
||||
preview: preview,
|
||||
urlPromise: uploadCompletionPromise
|
||||
});
|
||||
// Pass data and the uploader to the TinodeWeb.
|
||||
@@ -899,11 +902,17 @@ class MessagesView extends React.Component {
|
||||
// Small enough to send inband.
|
||||
blobToBase64(blob)
|
||||
.then(b64 => {
|
||||
this.sendMessage(Drafty.appendAudio(null, {mime: b64.mime, data: b64.bits, duration: duration}))
|
||||
this.sendMessage(Drafty.appendAudio(null, {
|
||||
mime: b64.mime,
|
||||
size: blob.size,
|
||||
data: b64.bits,
|
||||
duration: duration,
|
||||
preview: preview,
|
||||
}))
|
||||
})
|
||||
.catch(err => {this.props.onError(err)});
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(err => {this.props.onError(err)});;
|
||||
}
|
||||
|
||||
handleCancelUpload(seq, uploader) {
|
||||
@@ -1023,6 +1032,7 @@ class MessagesView extends React.Component {
|
||||
|
||||
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) => {
|
||||
@@ -1059,16 +1069,6 @@ class MessagesView extends React.Component {
|
||||
replyToSeq = null;
|
||||
}
|
||||
|
||||
// This message was sent on a different date than the previous.
|
||||
if (!prev || new Date(prev.ts).toDateString() != new Date(msg.ts).toDateString()) {
|
||||
messageNodes.push(
|
||||
<MetaMessage
|
||||
date={dateFmt.format(msg.ts)}
|
||||
locale={this.props.intl.locale}
|
||||
key={'date-' + msg.seq} />
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.hi) {
|
||||
// Deleted message.
|
||||
messageNodes.push(
|
||||
@@ -1077,6 +1077,17 @@ class MessagesView extends React.Component {
|
||||
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}
|
||||
@@ -1209,13 +1220,14 @@ class MessagesView extends React.Component {
|
||||
:
|
||||
<SendMessage
|
||||
tinode={this.props.tinode}
|
||||
topicName={this.state.topic}
|
||||
noInput={!!this.props.forwardMessage}
|
||||
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.sendAudioAttachment}
|
||||
onAttachAudio={this.props.forwardMessage ? null : this.sendAudioAttachment}
|
||||
onError={this.props.onError}
|
||||
reply={this.state.reply}
|
||||
onQuoteClick={this.handleQuoteClick}
|
||||
|
||||
+23
-26
@@ -960,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;
|
||||
@@ -976,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())
|
||||
const subscribePromise =
|
||||
topic.subscribe()
|
||||
.then(() => {
|
||||
// If there are unsent messages, try sending them now.
|
||||
topic.queuedMessages((pub) => {
|
||||
if (!pub._sending && topic.isSubscribed()) {
|
||||
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) => {
|
||||
@@ -1443,10 +1443,7 @@ class TinodeWeb extends React.Component {
|
||||
}
|
||||
|
||||
// Publish spam report.
|
||||
this.tinode.publish(Tinode.TOPIC_SYS, Drafty.attachJSON(null, {
|
||||
'action': 'report',
|
||||
'target': topicName
|
||||
}));
|
||||
this.tinode.report('report',topicName);
|
||||
|
||||
// Remove J and P permissions.
|
||||
topic.updateMode(null, '-JP').then((ctrl) => {
|
||||
@@ -1475,10 +1472,10 @@ class TinodeWeb extends React.Component {
|
||||
}
|
||||
const header = '➦ ' + params.userName;
|
||||
const content = typeof params.content == 'string' ? Drafty.init(params.content) : Drafty.forwardedContent(params.content);
|
||||
const preview = Drafty.preview(content, FORWARDED_PREVIEW_LENGTH, undefined, params.forwarded != null);
|
||||
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 head = {
|
||||
forwarded: params.topicName + ':' + params.seq
|
||||
};
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
// Audio recorder widget.
|
||||
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
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 = '#bbb';
|
||||
const BAR_COLOR_DARK = '#888';
|
||||
const THUMB_COLOR = '#666';
|
||||
// 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();
|
||||
}
|
||||
|
||||
if (this.state.preview) {
|
||||
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 ration 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) {
|
||||
// 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 pplayback 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 barCount = Math.min(original.length, ((this.canvasRef.current.width - SPACING) / (LINE_WIDTH + SPACING)) | 0);
|
||||
const factor = original.length / barCount;
|
||||
let amps = [];
|
||||
let max = -1;
|
||||
for (let i=0;i<barCount;i++) {
|
||||
let lo = (i * factor) | 0; // low bound;
|
||||
let hi = ((i + 1) * factor) | 0; // high bound;
|
||||
let amp = 0.0;
|
||||
for (let j=lo; j<hi; j++) {
|
||||
amp += original[j]
|
||||
}
|
||||
amps[i] = Math.max(0, amp / (hi - lo));
|
||||
max = Math.max(amps[i], max);
|
||||
}
|
||||
|
||||
if (max > 0) {
|
||||
// Remove extra padding on the right due to fractional bar which is not drawn.
|
||||
this.effectiveWidth = barCount * (LINE_WIDTH + SPACING) + SPACING;
|
||||
return amps.map(a => a / max);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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.state.preview) {
|
||||
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 ?
|
||||
<>
|
||||
<div>
|
||||
{this.state.preview ?
|
||||
<canvas className="playback" ref={this.canvasRef} onClick={this.handleSeek} /> :
|
||||
<div className="playback"> - </div>
|
||||
}
|
||||
</div>
|
||||
{play}
|
||||
</>
|
||||
:
|
||||
<>
|
||||
{play}
|
||||
<div>
|
||||
{this.state.preview ?
|
||||
<canvas className="playback" ref={this.canvasRef} onClick={this.handleSeek} /> :
|
||||
<div className="playback">
|
||||
<FormattedMessage
|
||||
id="preview_unavailable" defaultMessage="unavailable" description="Message shown when media preview is not available" />
|
||||
</div>
|
||||
}
|
||||
<div className="timer">{this.state.currentTime}/{this.state.duration}</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
+129
-32
@@ -2,26 +2,38 @@
|
||||
|
||||
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';
|
||||
|
||||
// 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 = 4;
|
||||
const LINE_WIDTH = 3 * CANVAS_UPSCALING;
|
||||
// Spacing between two visualization bars.
|
||||
const SPACING = 2;
|
||||
const SPACING = 2 * CANVAS_UPSCALING;
|
||||
// Duration represented by one visualization bar.
|
||||
const MILLIS_PER_BAR = 100;
|
||||
// Color of histogram bars
|
||||
const BAR_COLOR = '#8fbed6';
|
||||
// Scaling for visualization bars.
|
||||
const BAR_SCALE = 96.0;
|
||||
// Background color
|
||||
const BKG_COLOR = '#eeeeee';
|
||||
const BAR_COLOR = '#bbb';
|
||||
// Vertical scaling for visualization bars.
|
||||
const BAR_SCALE = 64.0;
|
||||
// Minimum duration of a recording in milliseconds.
|
||||
const MIN_DURATION = 200;
|
||||
// Maximum duration of a recording in milliseconds (10 min).
|
||||
const MAX_DURATION = 600000;
|
||||
// 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) {
|
||||
@@ -32,11 +44,15 @@ export default class AudioRecorder extends React.PureComponent {
|
||||
audioRecord: null,
|
||||
recording: true,
|
||||
paused: false,
|
||||
duration: '0:00'
|
||||
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);
|
||||
@@ -51,11 +67,6 @@ export default class AudioRecorder extends React.PureComponent {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.canvasContext = this.canvasRef.current.getContext('2d');
|
||||
this.canvasContext.lineCap = 'round';
|
||||
// To reduce line blurring.
|
||||
this.canvasContext.translate(0.5, 0.5);
|
||||
|
||||
this.stream = null;
|
||||
this.mediaRecorder = null;
|
||||
this.audioContext = null;
|
||||
@@ -72,19 +83,31 @@ export default class AudioRecorder extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
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.canvasRef.current.width;
|
||||
const height = this.canvasRef.current.height;
|
||||
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.fillStyle = BKG_COLOR;
|
||||
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;
|
||||
@@ -97,9 +120,9 @@ export default class AudioRecorder extends React.PureComponent {
|
||||
|
||||
// Check if record is too long.
|
||||
if (duration > MAX_DURATION) {
|
||||
this.startedOn = null;
|
||||
this.mediaRecorder.pause();
|
||||
this.durationMillis += Date.now() - this.startedOn;
|
||||
this.startedOn = null;
|
||||
this.setState({enabled: false, recording: false, duration: secondsToTime(this.durationMillis / 1000)});
|
||||
}
|
||||
|
||||
@@ -107,11 +130,14 @@ export default class AudioRecorder extends React.PureComponent {
|
||||
|
||||
// Get current waveform and calculate its amplitude.
|
||||
this.analyser.getByteTimeDomainData(pcmData);
|
||||
let volume = 0.0;
|
||||
let amp = 0.0;
|
||||
for (const amplitude of pcmData) {
|
||||
volume += (amplitude - 127) ** 2;
|
||||
amp += (amplitude - 127) ** 2;
|
||||
}
|
||||
volume = Math.sqrt(volume/pcmData.length);
|
||||
|
||||
// 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.
|
||||
@@ -121,7 +147,9 @@ export default class AudioRecorder extends React.PureComponent {
|
||||
if (prevBarCount != barCount) {
|
||||
prevBarCount = barCount;
|
||||
// Add new amplitude visualization bar.
|
||||
this.viewBuffer.push(volume);
|
||||
this.viewBuffer.push(volume/countPerBar);
|
||||
volume = 0.0;
|
||||
countPerBar = 0;
|
||||
if (this.viewBuffer.length > viewLength) {
|
||||
// Keep at most 'viewLength' amplitude bars.
|
||||
this.viewBuffer.shift();
|
||||
@@ -129,13 +157,13 @@ export default class AudioRecorder extends React.PureComponent {
|
||||
}
|
||||
|
||||
// Clear canvas.
|
||||
this.canvasContext.fillRect(0, 0, width, height);
|
||||
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 = this.viewBuffer[i] / BAR_SCALE * height;
|
||||
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);
|
||||
@@ -150,9 +178,10 @@ export default class AudioRecorder extends React.PureComponent {
|
||||
handlePause(e) {
|
||||
e.preventDefault();
|
||||
this.mediaRecorder.pause();
|
||||
this.mediaRecorder.requestData();
|
||||
this.durationMillis += Date.now() - this.startedOn;
|
||||
this.startedOn = null;
|
||||
this.setState({recording: false, duration: secondsToTime(this.durationMillis / 1000)});
|
||||
this.setState({recording: false});
|
||||
}
|
||||
|
||||
handleResume(e) {
|
||||
@@ -186,9 +215,21 @@ export default class AudioRecorder extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
this.mediaRecorder = new MediaRecorder(stream, {mimeType: AUDIO_MIME_TYPE});
|
||||
|
||||
// The following code is needed for visualization.
|
||||
this.audioContext = new AudioContext();
|
||||
@@ -198,21 +239,28 @@ export default class AudioRecorder extends React.PureComponent {
|
||||
this.audioInput.connect(this.analyser);
|
||||
|
||||
this.mediaRecorder.onstop = _ => {
|
||||
const blob = new Blob(this.audioChunks, { 'type' : 'audio/mp3; codecs=mp3' });
|
||||
this.audioChunks = [];
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
this.cleanUp();
|
||||
if (this.durationMillis > MIN_DURATION) {
|
||||
this.props.onFinished(url, this.durationMillis);
|
||||
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;
|
||||
@@ -221,6 +269,47 @@ export default class AudioRecorder extends React.PureComponent {
|
||||
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());
|
||||
@@ -231,9 +320,17 @@ export default class AudioRecorder extends React.PureComponent {
|
||||
return (
|
||||
<div className="audio">
|
||||
<a href="#" onClick={this.handleDelete} title="Delete">
|
||||
<i className="material-icons">delete_outline</i>
|
||||
<i className="material-icons gray">delete_outline</i>
|
||||
</a>
|
||||
<canvas className="visualiser" width="200" height="60" ref={this.canvasRef} />
|
||||
{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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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';
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from 'react';
|
||||
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() {
|
||||
@@ -20,7 +20,7 @@ export default class LetterTile extends React.PureComponent {
|
||||
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.
|
||||
const className = 'avatar' + (this.props.deleted ? ' deleted' : '');
|
||||
avatar = <img className={className} alt="avatar" src={url}
|
||||
|
||||
@@ -45,7 +45,7 @@ class SendMessage extends React.PureComponent {
|
||||
quote: null,
|
||||
message: '',
|
||||
audioRec: false,
|
||||
audioEnabled: !!navigator.mediaDevices.getUserMedia,
|
||||
audioAvailable: !!navigator.mediaDevices.getUserMedia,
|
||||
// Make initial keypress time as if it happened 5001 milliseconds in the past.
|
||||
keypressTimestamp: new Date().getTime() - KEYPRESS_DELAY - 1
|
||||
};
|
||||
@@ -82,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()});
|
||||
}
|
||||
}
|
||||
@@ -130,9 +132,9 @@ class SendMessage extends React.PureComponent {
|
||||
e.target.value = '';
|
||||
}
|
||||
|
||||
handleAttachAudio(url, duration) {
|
||||
handleAttachAudio(url, preview, duration) {
|
||||
this.setState({audioRec: false});
|
||||
this.props.onAttachAudio(url, duration);
|
||||
this.props.onAttachAudio(url, preview, duration);
|
||||
}
|
||||
|
||||
handleSend(e) {
|
||||
@@ -201,7 +203,7 @@ 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}
|
||||
@@ -230,7 +232,7 @@ class SendMessage extends React.PureComponent {
|
||||
onKeyPress={this.handleKeyPress}
|
||||
ref={(ref) => {this.messageEditArea = ref;}}
|
||||
autoFocus />)}
|
||||
{this.state.message || !this.state.audioEnabled ?
|
||||
{this.state.message || !audioEnabled ?
|
||||
<a href="#" onClick={this.handleSend} title="Send">
|
||||
<i className="material-icons">send</i>
|
||||
</a> :
|
||||
|
||||
@@ -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">
|
||||
|
||||
+1117
-142
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+5474
-3372
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
@@ -52,8 +52,8 @@ module.exports = (env, argv) => {
|
||||
]
|
||||
},
|
||||
performance: {
|
||||
maxEntrypointSize: 320000,
|
||||
maxAssetSize: 320000
|
||||
maxEntrypointSize: 360000,
|
||||
maxAssetSize: 360000
|
||||
},
|
||||
plugins: [
|
||||
new CopyPlugin({
|
||||
|
||||
Reference in New Issue
Block a user