Compare commits

...
23 Commits
Author SHA1 Message Date
or-else 408b221bac rebuilt 2022-04-04 10:48:01 -07:00
or-else 2a11885ac3 rebuilt with the sdk fix 2022-03-30 19:45:48 -07:00
or-else 62e4a4283f Workaround for https://bugs.chromium.org/p/chromium/issues/detail?id=642012 2022-03-27 11:31:54 -07:00
or-else 60b52b342d rebuilt with the sdk fix 2022-03-23 17:32:08 -07:00
or-else ae2d7a12ad rename calcPreviewBars -> resampleBars 2022-03-23 16:10:47 -07:00
or-else 9ddd103ac5 correctly report topics 2022-03-19 12:56:32 -07:00
or-else 237e97c323 don't immediately subscribe to a new p2p topic, wait for the first message' 2022-03-18 18:58:14 -07:00
or-else 891780bb52 stop recording on topic change 2022-03-17 15:52:25 -07:00
or-else c9391cf451 clear input field when changing topics 2022-03-17 14:12:25 -07:00
or-else 3d2af23f14 fix forwarding previews, remove audio button when forwarding 2022-03-16 20:01:00 -07:00
or-else 7044ce08c1 fix missing reply menu 2022-03-16 16:42:05 -07:00
or-else c5011a4383 fix missing reply menu 2022-03-16 16:34:46 -07:00
or-else d0c4251eda recording+playback+sending works correctly 2022-03-16 16:19:09 -07:00
or-else afea2aeec0 crash on missing preview 2022-03-15 19:27:54 -07:00
or-else e121b1645f putting it all together 2022-03-15 18:20:24 -07:00
or-else 2ab56887b7 Merge branch 'devel' into next 2022-03-15 09:14:21 -07:00
or-else 7e3a616983 fix for #91 (typo) 2022-03-15 09:04:14 -07:00
or-else e852e2972a visual cleanup of audio player 2022-03-15 09:03:14 -07:00
or-else 589b46a8c2 audio messages are fully functional but buggy and ugly 2022-03-14 20:50:24 -07:00
or-else cc3b1190fd calc audio preview at the source 2022-03-13 15:36:30 -07:00
or-else 025d4cfadb adding audio playback 2022-03-12 18:41:10 -08:00
or-else d3cc0571ce fix date display with deleted messages 2022-03-12 10:53:55 -08:00
or-else 539a37e5ee Unsupported is correctly displayed 2022-03-11 18:39:52 -08:00
37 changed files with 8139 additions and 4529 deletions
+1 -1
View File
@@ -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). 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> <a href="https://web.tinode.co/"><img src="web-app.svg" height=36></a>
+44 -2
View File
@@ -2650,15 +2650,57 @@ ul.tabbar li.active > a {
border-radius: 1.5rem; border-radius: 1.5rem;
} }
.audio canvas.visualiser { .audio canvas {
width: 12rem; width: 14rem;
height: 2.6rem; height: 2.6rem;
margin-right: 2rem;
}
.audio canvas.playback {
margin-right: 0;
} }
.audio .duration { .audio .duration {
font-size: large; 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. Mobile styles.
*/ */
+1 -1
View File
File diff suppressed because one or more lines are too long
+20 -9
View File
@@ -1,19 +1,20 @@
{ {
"name": "tinode-webapp", "name": "tinode-webapp",
"version": "0.18.3-beta1", "version": "0.19.0-alpha1",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "tinode-webapp", "name": "tinode-webapp",
"version": "0.18.3-beta1", "version": "0.19.0-alpha1",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"firebase": "^9.6.7", "firebase": "^9.6.7",
"fix-webm-duration": "^1.0.5",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-intl": "^5.20.2", "react-intl": "^5.20.2",
"tinode-sdk": "^0.18.2" "tinode-sdk": "^0.18.3"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.14.5", "@babel/core": "^7.14.5",
@@ -4274,6 +4275,11 @@
"@firebase/util": "1.4.3" "@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": { "node_modules/fs-extra": {
"version": "10.0.1", "version": "10.0.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.0.1.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.0.1.tgz",
@@ -6616,9 +6622,9 @@
"dev": true "dev": true
}, },
"node_modules/tinode-sdk": { "node_modules/tinode-sdk": {
"version": "0.18.2", "version": "0.18.3",
"resolved": "https://registry.npmjs.org/tinode-sdk/-/tinode-sdk-0.18.2.tgz", "resolved": "https://registry.npmjs.org/tinode-sdk/-/tinode-sdk-0.18.3.tgz",
"integrity": "sha512-K1Yt5akrRsBPmZwhpK0alhhxJ1F8EJai1svDjxbUbFfFQeTE4y4rXck+sqCQwfVjrPGCgjA/JIbuMpGTapMjbQ==" "integrity": "sha512-MKI8KL43oF3K5OEYp2Dfyfj9LYogKyE0hw0MYNmHMsg1+rHM/IbnnLLaEnlwD9hAAgmamCPY2AS6Oo2OeiCr1w=="
}, },
"node_modules/tmp": { "node_modules/tmp": {
"version": "0.2.1", "version": "0.2.1",
@@ -10345,6 +10351,11 @@
"@firebase/util": "1.4.3" "@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": { "fs-extra": {
"version": "10.0.1", "version": "10.0.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.0.1.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.0.1.tgz",
@@ -12005,9 +12016,9 @@
"dev": true "dev": true
}, },
"tinode-sdk": { "tinode-sdk": {
"version": "0.18.2", "version": "0.18.3",
"resolved": "https://registry.npmjs.org/tinode-sdk/-/tinode-sdk-0.18.2.tgz", "resolved": "https://registry.npmjs.org/tinode-sdk/-/tinode-sdk-0.18.3.tgz",
"integrity": "sha512-K1Yt5akrRsBPmZwhpK0alhhxJ1F8EJai1svDjxbUbFfFQeTE4y4rXck+sqCQwfVjrPGCgjA/JIbuMpGTapMjbQ==" "integrity": "sha512-MKI8KL43oF3K5OEYp2Dfyfj9LYogKyE0hw0MYNmHMsg1+rHM/IbnnLLaEnlwD9hAAgmamCPY2AS6Oo2OeiCr1w=="
}, },
"tmp": { "tmp": {
"version": "0.2.1", "version": "0.2.1",
+1 -1
View File
@@ -2,7 +2,6 @@
"name": "tinode-webapp", "name": "tinode-webapp",
"description": "Tinode messenger for the web", "description": "Tinode messenger for the web",
"version": "0.19.0-alpha1", "version": "0.19.0-alpha1",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/tinode/example-react-js.git" "url": "https://github.com/tinode/example-react-js.git"
@@ -68,6 +67,7 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"firebase": "^9.6.7", "firebase": "^9.6.7",
"fix-webm-duration": "^1.0.5",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-intl": "^5.20.2", "react-intl": "^5.20.2",
+9 -2
View File
@@ -1362,10 +1362,17 @@
"obsolete": false "obsolete": false
}, },
"drafty_unknown": { "drafty_unknown": {
"translation": "", "translation": "Nicht unterstützt",
"defaultMessage": "Unsupported", "defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty", "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 "obsolete": false
} }
} }
+7
View File
@@ -1367,5 +1367,12 @@
"description": "Unsupported entity in drafty", "description": "Unsupported entity in drafty",
"missing": false, "missing": false,
"obsolete": false "obsolete": false
},
"preview_unavailable": {
"translation": "unavailable",
"defaultMessage": "unavailable",
"description": "Message shown when media preview is not available",
"missing": false,
"obsolete": false
} }
} }
+9 -2
View File
@@ -1362,10 +1362,17 @@
"obsolete": false "obsolete": false
}, },
"drafty_unknown": { "drafty_unknown": {
"translation": "", "translation": "No soportado",
"defaultMessage": "Unsupported", "defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty", "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 "obsolete": false
} }
} }
+4
View File
@@ -608,6 +608,10 @@
"defaultMessage": "phone", "defaultMessage": "phone",
"description": "Dative case of 'phone', i.e. 'phone' in 'by 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": { "private_editing_placeholder": {
"defaultMessage": "Visible to you only", "defaultMessage": "Visible to you only",
"description": "Placeholder for editing 'private'" "description": "Placeholder for editing 'private'"
+9 -2
View File
@@ -1362,10 +1362,17 @@
"obsolete": false "obsolete": false
}, },
"drafty_unknown": { "drafty_unknown": {
"translation": "", "translation": "지원되지 않음",
"defaultMessage": "Unsupported", "defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty", "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 "obsolete": false
} }
} }
+9 -2
View File
@@ -1362,10 +1362,17 @@
"obsolete": false "obsolete": false
}, },
"drafty_unknown": { "drafty_unknown": {
"translation": "", "translation": "Neacceptat",
"defaultMessage": "Unsupported", "defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty", "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 "obsolete": false
} }
} }
+9 -2
View File
@@ -1362,10 +1362,17 @@
"obsolete": false "obsolete": false
}, },
"drafty_unknown": { "drafty_unknown": {
"translation": "", "translation": "Не поддерживается",
"defaultMessage": "Unsupported", "defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty", "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 "obsolete": false
} }
} }
+9 -2
View File
@@ -1362,10 +1362,17 @@
"obsolete": false "obsolete": false
}, },
"drafty_unknown": { "drafty_unknown": {
"translation": "", "translation": "不支持",
"defaultMessage": "Unsupported", "defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty", "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 "obsolete": false
} }
} }
+9 -2
View File
@@ -1362,10 +1362,17 @@
"obsolete": false "obsolete": false
}, },
"drafty_unknown": { "drafty_unknown": {
"translation": "", "translation": "不支持",
"defaultMessage": "Unsupported", "defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty", "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 "obsolete": false
} }
} }
+1 -1
View File
@@ -23,7 +23,7 @@ const language = (params && params.hl) ||
const baseLanguage = language.toLowerCase().split(/[-_]/)[0]; const baseLanguage = language.toLowerCase().split(/[-_]/)[0];
// Try the full locale first, then the locale without the region code, fallback to 'en'. // 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]; const messages = allMessages[htmlLang];
// Set lang attribute of the HTML element: <html lang="XX"> // Set lang attribute of the HTML element: <html lang="XX">
+24
View File
@@ -281,3 +281,27 @@ export function base64ToBlob(str, mime) {
return null; 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
View File
@@ -3,13 +3,14 @@ import { defineMessages } from 'react-intl';
import { Drafty } from 'tinode-sdk'; import { Drafty } from 'tinode-sdk';
import AudioPlayer from '../widgets/audio-player.jsx'
import LazyImage from '../widgets/lazy-image.jsx' import LazyImage from '../widgets/lazy-image.jsx'
import UploadingImage from '../widgets/uploading-image.jsx' import UploadingImage from '../widgets/uploading-image.jsx'
import { IMAGE_THUMBNAIL_DIM, BROKEN_IMAGE_SIZE, REM_SIZE } from '../config.js'; import { IMAGE_THUMBNAIL_DIM, BROKEN_IMAGE_SIZE, REM_SIZE } from '../config.js';
import { base64ToBlob, blobToBase64, fitImageSize, imageScaled } from './blob-helpers.js'; import { base64ToBlob, blobToBase64, fitImageSize, imageScaled } from './blob-helpers.js';
import { idToColorClass, shortenFileName } from './strformat.js'; import { idToColorClass, secondsToTime, shortenFileName } from './strformat.js';
import { cancelablePromise, sanitizeImageUrl } from './utils.js'; import { cancelablePromise, sanitizeUrlForMime } from './utils.js';
const messages = defineMessages({ const messages = defineMessages({
drafty_form: { drafty_form: {
@@ -34,15 +35,7 @@ const messages = defineMessages({
} }
}); });
// Get comment for unknown element // Additional processing of image data.
function unsupportedComment(values, ifMissing) {
if (Array.isArray(values) && values.join('').trim()) {
return values;
}
return [<span className="gray">{ifMissing}</span>];
}
// Size the already scaled image.
function handleImageData(el, data, attr) { function handleImageData(el, data, attr) {
if (!data) { if (!data) {
attr.src = 'img/broken_image.png'; attr.src = 'img/broken_image.png';
@@ -66,7 +59,7 @@ function handleImageData(el, data, attr) {
minHeight: dim.dstHeight + 'px' minHeight: dim.dstHeight + 'px'
}; };
if (!Drafty.isProcessing(data)) { if (!Drafty.isProcessing(data)) {
attr.src = this.authorizeURL(sanitizeImageUrl(attr.src)); attr.src = this.authorizeURL(sanitizeUrlForMime(attr.src, 'image'));
attr.alt = data.name; attr.alt = data.name;
if (attr.src) { if (attr.src) {
if (Math.max(data.width || 0, data.height || 0) > IMAGE_THUMBNAIL_DIM) { 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); let el = Drafty.tagName(style);
const attr = Drafty.attrValue(style, data) || {}; let attr = Drafty.attrValue(style, data) || {};
attr.key = key; attr.key = key;
switch (style) { 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': case 'BR':
values = null; values = null;
break; break;
case 'EX':
// Ignore.
break;
case 'HL': case 'HL':
// Highlighted text. Assign class name. // Highlighted text. Assign class name.
attr.className = 'highlight'; attr.className = 'highlight';
@@ -156,10 +164,17 @@ export function fullFormatter(style, data, values, key, stack) {
attr.onClick = this.onQuoteClick; attr.onClick = this.onQuoteClick;
break; break;
default: default:
if (!el) {
// Unknown element. // Unknown element.
el = React.Fragment; el = React.Fragment;
values = [<i className="material-icons gray">extension</i>, ' '] attr = {key: key};
.concat(unsupportedComment(values, this.formatMessage(messages.drafty_unknown))); // 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; break;
} }
if (!el) { if (!el) {
@@ -181,6 +196,11 @@ export function previewFormatter(style, data, values, key) {
let el = Drafty.tagName(style); let el = Drafty.tagName(style);
const attr = { key: key }; const attr = { key: key };
switch (style) { 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': case 'BR':
// Replace new line with a space. // Replace new line with a space.
el = React.Fragment; el = React.Fragment;
@@ -198,7 +218,7 @@ export function previewFormatter(style, data, values, key) {
case 'IM': case 'IM':
// Replace image with '[icon] Image'. // Replace image with '[icon] Image'.
el = React.Fragment; 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; break;
case 'BN': case 'BN':
el = 'span'; el = 'span';
@@ -223,7 +243,7 @@ export function previewFormatter(style, data, values, key) {
delete data.ref; delete data.ref;
} }
el = React.Fragment; 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; break;
case 'QQ': case 'QQ':
case 'HD': case 'HD':
@@ -231,9 +251,11 @@ export function previewFormatter(style, data, values, key) {
values = null; values = null;
break; break;
default: default:
if (!el) {
// Unknown element.
el = React.Fragment; el = React.Fragment;
values = [<i className="material-icons gray">extension</i>, ' '] values = [<i key="x0" className="material-icons gray">extension</i>, ' ', this.formatMessage(messages.drafty_unknown)];
.concat(unsupportedComment(values, this.formatMessage(messages.drafty_unknown))); }
break; break;
} }
if (!el) { if (!el) {
@@ -321,7 +343,7 @@ function quoteImage(data) {
} }
promise = Promise.resolve(blob); promise = Promise.resolve(blob);
} else if (data.ref) { } else if (data.ref) {
promise = fetch(this.authorizeURL(sanitizeImageUrl(data.ref))) promise = fetch(this.authorizeURL(sanitizeUrlForMime(data.ref, 'image')))
.then(evt => { .then(evt => {
if (evt.ok) { if (evt.ok) {
return evt.blob(); return evt.blob();
+1 -1
View File
@@ -19,7 +19,7 @@ export function shortDateFormat(then, locale) {
// Convert seconds to minutes:seconds, i.e. 156 sec -> 2:36. // 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. // If <code>fixedMins</code> is true, then minutes are represented by at least two digits.
export function secondsToTime(seconds, fixedMin) { export function secondsToTime(seconds, fixedMin) {
let min = Math.floor(seconds / 60); let min = Math.floor(seconds / 60) | 0;
if (fixedMin) { if (fixedMin) {
min = min < 10 ? `0${min}` : min; min = min < 10 ? `0${min}` : min;
} }
+6 -5
View File
@@ -144,10 +144,10 @@ export function sanitizeUrl(url, allowedSchemes) {
return url; 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. // have http:, https:, blob: or data: scheme.
// In case of data: scheme, the URL must start with a 'data:image/XXXX;base64,'. // In case of data: scheme, the URL must must be of the right MIME type such as 'data:{mimeMajor}/XXXX;base64,'.
export function sanitizeImageUrl(url) { export function sanitizeUrlForMime(url, mimeMajor) {
if (!url) { if (!url) {
return null; return null;
} }
@@ -157,8 +157,9 @@ export function sanitizeImageUrl(url) {
return sanitizedUrl; return sanitizedUrl;
} }
// Is this a data: URL of an image? // Is this a data: URL of the appropriate mime type?
if (/data:image\/[a-z0-9.-]+;base64,/i.test(url.trim())) { const re = new RegExp(`data:${mimeMajor}\/[a-z0-9.-]+;base64,`, 'i');
if (re.test(url.trim())) {
return url; return url;
} }
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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 { MAX_TITLE_LENGTH, MAX_TOPIC_DESCRIPTION_LENGTH, NO_ACCESS_MODE } from '../config.js';
import { makeImageUrl } from '../lib/blob-helpers.js'; import { makeImageUrl } from '../lib/blob-helpers.js';
import { arrayEqual, theCard } from '../lib/utils.js'; import { theCard } from '../lib/utils.js';
const messages = defineMessages({ const messages = defineMessages({
info: { info: {
+37 -25
View File
@@ -188,9 +188,9 @@ class MessagesView extends React.Component {
} }
if (topic && ((this.state.topic != prevState.topic) || !prevProps.ready)) { if (topic && ((this.state.topic != prevState.topic) || !prevProps.ready)) {
if (topic._new) { // Don't immediately subscribe to a new p2p topic, wait for the first message.
console.log('DEBUG: Fetching new topic description'); if (topic._new && topic.isP2PType()) {
// topic.getMeta(topic.startMetaQuery().withDesc().build()); topic.getMeta(topic.startMetaQuery().withDesc().build());
} else { } else {
this.subscribe(topic); this.subscribe(topic);
} }
@@ -381,13 +381,16 @@ class MessagesView extends React.Component {
this.props.onNewTopicCreated(this.props.topic, ctrl.topic); this.props.onNewTopicCreated(this.props.topic, ctrl.topic);
// If there are unsent messages, try sending them now. // If there are unsent messages, try sending them now.
topic.queuedMessages((pub) => { topic.queuedMessages((pub) => {
if (!pub._sending && topic.isSubscribed()) { if (pub._sending) {
return;
}
if (topic.isSubscribed()) {
this.retrySend(pub); this.retrySend(pub);
} }
}); });
}) })
.catch((err) => { .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'); this.props.onError(err.message, 'err');
const blankState = MessagesView.getDerivedStateFromProps({}, {}); const blankState = MessagesView.getDerivedStateFromProps({}, {});
blankState.title = this.props.intl.formatMessage(messages.not_found); 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 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. // Server-provided limit reduced for base64 encoding and overhead.
const maxInbandAttachmentSize = (this.props.tinode.getServerLimit( const maxInbandAttachmentSize = this.props.tinode.getServerLimit('maxMessageSize', MAX_INBAND_ATTACHMENT_SIZE) * 0.75 - 1024;
'maxMessageSize', MAX_INBAND_ATTACHMENT_SIZE) * 0.75 - 1024) | 0;
fetch(url).then(result => {
return result.blob();
}).then(blob => {
if (blob.size > maxInbandAttachmentSize) { if (blob.size > maxInbandAttachmentSize) {
// Too large to send inband - uploading out of band and sending as a link. // Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper(); const uploader = this.props.tinode.getLargeFileHelper();
@@ -891,6 +893,7 @@ class MessagesView extends React.Component {
mime: blob.type, mime: blob.type,
size: blob.size, size: blob.size,
duration: duration, duration: duration,
preview: preview,
urlPromise: uploadCompletionPromise urlPromise: uploadCompletionPromise
}); });
// Pass data and the uploader to the TinodeWeb. // Pass data and the uploader to the TinodeWeb.
@@ -899,11 +902,17 @@ class MessagesView extends React.Component {
// Small enough to send inband. // Small enough to send inband.
blobToBase64(blob) blobToBase64(blob)
.then(b64 => { .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) { handleCancelUpload(seq, uploader) {
@@ -1023,6 +1032,7 @@ class MessagesView extends React.Component {
const messageNodes = []; const messageNodes = [];
let previousFrom = null; let previousFrom = null;
let prevDate = null;
let chatBoxClass = null; let chatBoxClass = null;
const dateFmt = new Intl.DateTimeFormat(this.props.intl.locale); const dateFmt = new Intl.DateTimeFormat(this.props.intl.locale);
topic.messages((msg, prev, next, i) => { topic.messages((msg, prev, next, i) => {
@@ -1059,16 +1069,6 @@ class MessagesView extends React.Component {
replyToSeq = null; 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) { if (msg.hi) {
// Deleted message. // Deleted message.
messageNodes.push( messageNodes.push(
@@ -1077,6 +1077,17 @@ class MessagesView extends React.Component {
key={msg.seq} /> key={msg.seq} />
); );
} else { } 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( messageNodes.push(
<ChatMessage <ChatMessage
tinode={this.props.tinode} tinode={this.props.tinode}
@@ -1209,13 +1220,14 @@ class MessagesView extends React.Component {
: :
<SendMessage <SendMessage
tinode={this.props.tinode} tinode={this.props.tinode}
topicName={this.state.topic}
noInput={!!this.props.forwardMessage} noInput={!!this.props.forwardMessage}
disabled={!this.state.isWriter || this.state.deleted} disabled={!this.state.isWriter || this.state.deleted}
onKeyPress={this.sendKeyPress} onKeyPress={this.sendKeyPress}
onSendMessage={this.sendMessage} onSendMessage={this.sendMessage}
onAttachFile={this.props.forwardMessage ? null : this.handleAttachFile} onAttachFile={this.props.forwardMessage ? null : this.handleAttachFile}
onAttachImage={this.props.forwardMessage ? null : this.handleAttachImage} onAttachImage={this.props.forwardMessage ? null : this.handleAttachImage}
onAttachAudio={this.sendAudioAttachment} onAttachAudio={this.props.forwardMessage ? null : this.sendAudioAttachment}
onError={this.props.onError} onError={this.props.onError}
reply={this.state.reply} reply={this.state.reply}
onQuoteClick={this.handleQuoteClick} onQuoteClick={this.handleQuoteClick}
+23 -26
View File
@@ -960,14 +960,14 @@ class TinodeWeb extends React.Component {
// with attachments. // with attachments.
// - msg - Drafty message with content // - msg - Drafty message with content
// - promise - Promise to be resolved when the upload is completed // - 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 // - 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); 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); msg = topic.createMessage(msg, false);
// The uploader is used to show progress. // The uploader is used to show progress.
msg._uploader = uploader; msg._uploader = uploader;
@@ -976,33 +976,33 @@ class TinodeWeb extends React.Component {
msg.head = Object.assign(msg.head || {}, head); msg.head = Object.assign(msg.head || {}, head);
} }
const completion = [];
if (uploadCompletionPromise) {
completion.push(uploadCompletionPromise);
}
if (!topic.isSubscribed()) { if (!topic.isSubscribed()) {
// Topic is not subscribed yet. Subscribe. // Topic is not subscribed yet. Subscribe.
if (!promise) { const subscribePromise =
promise = Promise.resolve(); topic.subscribe()
}
promise = promise
.then(() => topic.subscribe())
.then(() => { .then(() => {
// If there are unsent messages, try sending them now. // If there are unsent messages, try sending them now.
topic.queuedMessages((pub) => { topic.queuedMessages(pub => {
if (!pub._sending && topic.isSubscribed()) { if (pub._sending || pub.seq == msg.seq) {
return;
}
if (topic.isSubscribed()) {
topic.publishMessage(pub); topic.publishMessage(pub);
} }
}); });
}); });
completion.push(subscribePromise);
} }
if (promise) { topic.publishDraft(msg, Promise.all(completion))
promise = promise.catch((err) => { .then(_ => {
this.handleError(err.message, 'err');
});
}
topic.publishDraft(msg, promise)
.then((ctrl) => {
if (topic.isArchived()) { if (topic.isArchived()) {
return topic.archive(false); topic.archive(false);
} }
}) })
.catch((err) => { .catch((err) => {
@@ -1443,10 +1443,7 @@ class TinodeWeb extends React.Component {
} }
// Publish spam report. // Publish spam report.
this.tinode.publish(Tinode.TOPIC_SYS, Drafty.attachJSON(null, { this.tinode.report('report',topicName);
'action': 'report',
'target': topicName
}));
// Remove J and P permissions. // Remove J and P permissions.
topic.updateMode(null, '-JP').then((ctrl) => { topic.updateMode(null, '-JP').then((ctrl) => {
@@ -1475,10 +1472,10 @@ class TinodeWeb extends React.Component {
} }
const header = '➦ ' + params.userName; const header = '➦ ' + params.userName;
const content = typeof params.content == 'string' ? Drafty.init(params.content) : Drafty.forwardedContent(params.content); 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 msg = Drafty.append(Drafty.appendLineBreak(Drafty.mention(header, params.userFrom)), content);
const msgPreview = Drafty.quote(header, params.userFrom, preview); const msgPreview = Drafty.quote(header, params.userFrom, preview);
const head = { const head = {
forwarded: params.topicName + ':' + params.seq forwarded: params.topicName + ':' + params.seq
}; };
+265
View File
@@ -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
View File
@@ -2,26 +2,38 @@
import React from 'react'; 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 { secondsToTime } from '../lib/strformat';
// FFT resolution. // FFT resolution.
const BUFFER_SIZE = 256; const BUFFER_SIZE = 256;
// Make canvas bigger than the element size to reduce blurring.
const CANVAS_UPSCALING = 2.0;
// Thickness of a visualization bar. // Thickness of a visualization bar.
const LINE_WIDTH = 4; const LINE_WIDTH = 3 * CANVAS_UPSCALING;
// Spacing between two visualization bars. // Spacing between two visualization bars.
const SPACING = 2; const SPACING = 2 * CANVAS_UPSCALING;
// Duration represented by one visualization bar. // Duration represented by one visualization bar.
const MILLIS_PER_BAR = 100; const MILLIS_PER_BAR = 100;
// Color of histogram bars // Color of histogram bars
const BAR_COLOR = '#8fbed6'; const BAR_COLOR = '#bbb';
// Scaling for visualization bars. // Vertical scaling for visualization bars.
const BAR_SCALE = 96.0; const BAR_SCALE = 64.0;
// Background color
const BKG_COLOR = '#eeeeee';
// Minimum duration of a recording in milliseconds. // Minimum duration of a recording in milliseconds.
const MIN_DURATION = 200; const MIN_DURATION = 200;
// Maximum duration of a recording in milliseconds (10 min). // Maximum duration of a recording in milliseconds (10 min).
const MAX_DURATION = 600000; 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 { export default class AudioRecorder extends React.PureComponent {
constructor(props) { constructor(props) {
@@ -32,11 +44,15 @@ export default class AudioRecorder extends React.PureComponent {
audioRecord: null, audioRecord: null,
recording: true, recording: true,
paused: false, paused: false,
duration: '0:00' duration: '0:00',
blobUrl: null,
preview: null
}; };
this.visualize = this.visualize.bind(this); this.visualize = this.visualize.bind(this);
this.initMediaRecording = this.initMediaRecording.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.cleanUp = this.cleanUp.bind(this);
this.handleResume = this.handleResume.bind(this); this.handleResume = this.handleResume.bind(this);
@@ -51,11 +67,6 @@ export default class AudioRecorder extends React.PureComponent {
} }
componentDidMount() { 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.stream = null;
this.mediaRecorder = null; this.mediaRecorder = null;
this.audioContext = 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. // Draw amplitude of sound.
visualize() { visualize() {
this.initCanvas();
const pcmData = new Uint8Array(this.analyser.frequencyBinCount); const pcmData = new Uint8Array(this.analyser.frequencyBinCount);
const width = this.canvasRef.current.width; const width = this.canvasWidth;
const height = this.canvasRef.current.height; const height = this.canvasHeight;
// Number of bars.
const viewLength = (width / (LINE_WIDTH + SPACING)) | 0; const viewLength = (width / (LINE_WIDTH + SPACING)) | 0;
// Duration of audio which fits onto the screen.
const viewDuration = MILLIS_PER_BAR * viewLength; const viewDuration = MILLIS_PER_BAR * viewLength;
this.canvasContext.fillStyle = BKG_COLOR;
this.canvasContext.lineWidth = LINE_WIDTH; this.canvasContext.lineWidth = LINE_WIDTH;
this.canvasContext.strokeStyle = BAR_COLOR; this.canvasContext.strokeStyle = BAR_COLOR;
let prevBarCount = 0; let prevBarCount = 0;
let volume = 0.0;
let countPerBar = 0;
const drawFrame = () => { const drawFrame = () => {
if (!this.startedOn) { if (!this.startedOn) {
return; return;
@@ -97,9 +120,9 @@ export default class AudioRecorder extends React.PureComponent {
// Check if record is too long. // Check if record is too long.
if (duration > MAX_DURATION) { if (duration > MAX_DURATION) {
this.startedOn = null;
this.mediaRecorder.pause(); this.mediaRecorder.pause();
this.durationMillis += Date.now() - this.startedOn; this.durationMillis += Date.now() - this.startedOn;
this.startedOn = null;
this.setState({enabled: false, recording: false, duration: secondsToTime(this.durationMillis / 1000)}); 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. // Get current waveform and calculate its amplitude.
this.analyser.getByteTimeDomainData(pcmData); this.analyser.getByteTimeDomainData(pcmData);
let volume = 0.0; let amp = 0.0;
for (const amplitude of pcmData) { 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; 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. // 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) { if (prevBarCount != barCount) {
prevBarCount = barCount; prevBarCount = barCount;
// Add new amplitude visualization bar. // Add new amplitude visualization bar.
this.viewBuffer.push(volume); this.viewBuffer.push(volume/countPerBar);
volume = 0.0;
countPerBar = 0;
if (this.viewBuffer.length > viewLength) { if (this.viewBuffer.length > viewLength) {
// Keep at most 'viewLength' amplitude bars. // Keep at most 'viewLength' amplitude bars.
this.viewBuffer.shift(); this.viewBuffer.shift();
@@ -129,13 +157,13 @@ export default class AudioRecorder extends React.PureComponent {
} }
// Clear canvas. // Clear canvas.
this.canvasContext.fillRect(0, 0, width, height); this.canvasContext.clearRect(0, 0, width, height);
// Draw amplitude bars. // Draw amplitude bars.
this.canvasContext.beginPath(); this.canvasContext.beginPath();
for (let i = 0; i < this.viewBuffer.length; i++) { for (let i = 0; i < this.viewBuffer.length; i++) {
let x = i * (LINE_WIDTH + SPACING) - dx; 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.moveTo(x, (height - y) * 0.5);
this.canvasContext.lineTo(x, height * 0.5 + 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) { handlePause(e) {
e.preventDefault(); e.preventDefault();
this.mediaRecorder.pause(); this.mediaRecorder.pause();
this.mediaRecorder.requestData();
this.durationMillis += Date.now() - this.startedOn; this.durationMillis += Date.now() - this.startedOn;
this.startedOn = null; this.startedOn = null;
this.setState({recording: false, duration: secondsToTime(this.durationMillis / 1000)}); this.setState({recording: false});
} }
handleResume(e) { 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) { initMediaRecording(stream) {
this.stream = 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. // The following code is needed for visualization.
this.audioContext = new AudioContext(); this.audioContext = new AudioContext();
@@ -198,21 +239,28 @@ export default class AudioRecorder extends React.PureComponent {
this.audioInput.connect(this.analyser); this.audioInput.connect(this.analyser);
this.mediaRecorder.onstop = _ => { 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) { 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 { } else {
this.props.onDeleted(); this.props.onDeleted();
} }
this.cleanUp();
} }
this.mediaRecorder.ondataavailable = (e) => { this.mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) { if (e.data.size > 0) {
this.audioChunks.push(e.data); 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.durationMillis = 0;
@@ -221,6 +269,47 @@ export default class AudioRecorder extends React.PureComponent {
this.visualize(); 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() { cleanUp() {
this.audioInput.disconnect(); this.audioInput.disconnect();
this.stream.getTracks().forEach(track => track.stop()); this.stream.getTracks().forEach(track => track.stop());
@@ -231,9 +320,17 @@ export default class AudioRecorder extends React.PureComponent {
return ( return (
<div className="audio"> <div className="audio">
<a href="#" onClick={this.handleDelete} title="Delete"> <a href="#" onClick={this.handleDelete} title="Delete">
<i className="material-icons">delete_outline</i> <i className="material-icons gray">delete_outline</i>
</a> </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> <div className="duration">{this.state.duration}</div>
{this.state.recording ? {this.state.recording ?
<a href="#" onClick={this.handlePause} title="Pause"> <a href="#" onClick={this.handlePause} title="Pause">
+2 -2
View File
@@ -3,7 +3,7 @@ import React from 'react';
import LetterTile from './letter-tile.jsx'; import LetterTile from './letter-tile.jsx';
import LoadSpinner from './load-spinner.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'; import { AVATAR_SIZE } from '../config.js';
@@ -44,7 +44,7 @@ export default class AvatarUpload extends React.Component {
<i className="material-icons">clear</i> <i className="material-icons">clear</i>
</a>} </a>}
{this.state.source ? {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 ? this.props.readOnly && this.props.uid ?
<div className="avatar-box"> <div className="avatar-box">
<LetterTile <LetterTile
+1 -1
View File
@@ -2,7 +2,7 @@
import React from 'react'; import React from 'react';
import { FormattedMessage, injectIntl } from 'react-intl'; import { FormattedMessage, injectIntl } from 'react-intl';
import { Drafty } from 'tinode-sdk'; import { Drafty, Tinode } from 'tinode-sdk';
import Attachment from './attachment.jsx'; import Attachment from './attachment.jsx';
import LetterTile from './letter-tile.jsx'; import LetterTile from './letter-tile.jsx';
+2 -2
View File
@@ -3,7 +3,7 @@ import React from 'react';
import { Tinode } from 'tinode-sdk'; import { Tinode } from 'tinode-sdk';
import { idToColorClass } from '../lib/strformat.js'; 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 { export default class LetterTile extends React.PureComponent {
render() { render() {
@@ -20,7 +20,7 @@ export default class LetterTile extends React.PureComponent {
avatar = isGroup ? <i className={className}>group</i> : <i className={className}>person</i>; avatar = isGroup ? <i className={className}>group</i> : <i className={className}>person</i>;
} }
} else if (this.props.avatar) { } 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. // If avatar image is invalid, show a placeholder.
const className = 'avatar' + (this.props.deleted ? ' deleted' : ''); const className = 'avatar' + (this.props.deleted ? ' deleted' : '');
avatar = <img className={className} alt="avatar" src={url} avatar = <img className={className} alt="avatar" src={url}
+8 -6
View File
@@ -45,7 +45,7 @@ class SendMessage extends React.PureComponent {
quote: null, quote: null,
message: '', message: '',
audioRec: false, audioRec: false,
audioEnabled: !!navigator.mediaDevices.getUserMedia, audioAvailable: !!navigator.mediaDevices.getUserMedia,
// Make initial keypress time as if it happened 5001 milliseconds in the past. // Make initial keypress time as if it happened 5001 milliseconds in the past.
keypressTimestamp: new Date().getTime() - KEYPRESS_DELAY - 1 keypressTimestamp: new Date().getTime() - KEYPRESS_DELAY - 1
}; };
@@ -82,7 +82,9 @@ class SendMessage extends React.PureComponent {
this.messageEditArea.focus(); 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()}); this.setState({quote: this.formatReply()});
} }
} }
@@ -130,9 +132,9 @@ class SendMessage extends React.PureComponent {
e.target.value = ''; e.target.value = '';
} }
handleAttachAudio(url, duration) { handleAttachAudio(url, preview, duration) {
this.setState({audioRec: false}); this.setState({audioRec: false});
this.props.onAttachAudio(url, duration); this.props.onAttachAudio(url, preview, duration);
} }
handleSend(e) { handleSend(e) {
@@ -201,7 +203,7 @@ class SendMessage extends React.PureComponent {
</div> </div>
{this.state.quote} {this.state.quote}
</div>); </div>);
const audioEnabled = this.state.audioAvailable && this.props.onAttachAudio;
return ( return (
<div id="send-message-wrapper"> <div id="send-message-wrapper">
{this.state.quote && !this.props.noInput ? quote : null} {this.state.quote && !this.props.noInput ? quote : null}
@@ -230,7 +232,7 @@ class SendMessage extends React.PureComponent {
onKeyPress={this.handleKeyPress} onKeyPress={this.handleKeyPress}
ref={(ref) => {this.messageEditArea = ref;}} ref={(ref) => {this.messageEditArea = ref;}}
autoFocus />)} autoFocus />)}
{this.state.message || !this.state.audioEnabled ? {this.state.message || !audioEnabled ?
<a href="#" onClick={this.handleSend} title="Send"> <a href="#" onClick={this.handleSend} title="Send">
<i className="material-icons">send</i> <i className="material-icons">send</i>
</a> : </a> :
+2 -2
View File
@@ -6,7 +6,7 @@ import ContactBadges from './contact-badges.jsx';
import MenuContacts from './menu-contacts.jsx'; import MenuContacts from './menu-contacts.jsx';
import MenuStart from './menu-start.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 { export default class SideNavbar extends React.PureComponent {
render() { render() {
@@ -18,7 +18,7 @@ export default class SideNavbar extends React.PureComponent {
} }
let avatar = null; let avatar = null;
if (this.props.tinode) { if (this.props.tinode) {
avatar = this.props.tinode.authorizeURL(sanitizeImageUrl(this.props.avatar)); avatar = this.props.tinode.authorizeURL(sanitizeUrlForMime(this.props.avatar, 'image'));
} }
return ( return (
<div id="side-caption-panel" className="caption-panel"> <div id="side-caption-panel" className="caption-panel">
+1117 -142
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+5401 -3299
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -52,8 +52,8 @@ module.exports = (env, argv) => {
] ]
}, },
performance: { performance: {
maxEntrypointSize: 320000, maxEntrypointSize: 360000,
maxAssetSize: 320000 maxAssetSize: 360000
}, },
plugins: [ plugins: [
new CopyPlugin({ new CopyPlugin({