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).
|
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
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Generated
+20
-9
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
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 { 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
@@ -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
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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> :
|
||||||
|
|||||||
@@ -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
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
+5401
-3299
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: {
|
performance: {
|
||||||
maxEntrypointSize: 320000,
|
maxEntrypointSize: 360000,
|
||||||
maxAssetSize: 320000
|
maxAssetSize: 360000
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
new CopyPlugin({
|
new CopyPlugin({
|
||||||
|
|||||||
Reference in New Issue
Block a user