Compare commits

...
20 Commits
Author SHA1 Message Date
or-else e42a7ca6de corrected display of unsupported drafty entities 2022-03-11 16:43:19 -08:00
or-else 609d3e3a9f audio messages are correctly formed and sent 2022-03-11 15:42:07 -08:00
or-else 038f62d535 do not close audiocontext - throws 2022-03-10 18:00:55 -08:00
or-else 37451b9aff clean up audio after finishing recording 2022-03-10 17:42:39 -08:00
or-else 00a1b656e4 audio recorder mostly OK 2022-03-10 17:12:09 -08:00
or-else e0c412a33c audio recording widget is reasonably functional 2022-03-09 16:54:20 -08:00
or-else a2ca095c24 adding support for voice messages 2022-03-08 18:23:13 -08:00
or-else 42ae953055 minor cleanup, support for delayed topic creation 2022-03-07 19:41:17 -08:00
or-else 9c2d3f3693 clean up pushes 2022-03-05 20:09:02 -08:00
or-else 9d8a1fdfc4 removed debug logging 2022-03-04 13:58:11 -08:00
or-else c6a9df9fe9 support for "read" pushes 2022-03-03 17:47:32 -08:00
or-else 470d38e583 merge devel 2022-03-01 18:11:20 -08:00
or-else 65dff7d168 bumped version to 0.18.2 and rebuilt 2022-03-01 12:15:42 -08:00
or-else d2d83a0865 rebuilt with updated sdk, version bumped 2022-02-28 16:04:35 -08:00
or-else aa498a7d1f add comment to update Firebase in service worker 2022-02-28 15:42:32 -08:00
or-else d106f6382a google's FCM API is VERY poorly designed 2022-02-24 17:39:51 -08:00
or-else cdff2ee697 a bit more work on delayed topic removal 2022-02-22 12:54:18 -08:00
or-else d76756c67a race condition when clearing DB 2022-02-21 13:20:46 -08:00
or-else 4edb48fd24 rebuilt with the SDK fixes 2022-02-20 18:20:27 -08:00
or-else 860ae42513 rebuilt with updated sdk 2022-02-20 15:17:29 -08:00
49 changed files with 10507 additions and 2828 deletions
+1 -1
View File
@@ -44,7 +44,7 @@ More translations are welcome. See [instructions](https://github.com/tinode/chat
## Not done yet
* Voice or video messages, video or audio calling.
* Video or audio calling.
* End-to-End encryption.
* Emoji support is weak.
* Mentions, hashtags.
+41 -8
View File
@@ -714,6 +714,10 @@ table.permission-editor td.checkbox {
white-space: nowrap;
}
.panel-title.deleted {
text-decoration: line-through;
}
.caption-panel {
display: flex;
align-items: center;
@@ -956,6 +960,12 @@ i.search {
white-space: nowrap;
}
.contact-box .contact-title.deleted {
opacity: 0.66;
text-decoration: line-through;
text-decoration-thickness: 0.5px;
}
.contact-box .contact-comment {
text-overflow: ellipsis;
overflow-x: hidden;
@@ -1007,8 +1017,9 @@ i.search {
height: 2rem;
}
.avatar.disabled {
opacity: 0.5;
.avatar.deleted {
opacity: 0.75;
filter: grayscale(1);
}
.contact-box .avatar-box > .material-icons {
@@ -1093,22 +1104,24 @@ img.channel {
font-size: 1.5rem;
color: #0097A7;
position: absolute;
right: 0.05rem;
bottom: 0.05rem;
right: 0;
bottom: 0;
background-color: white;
overflow: visible;
border: 0.0px solid transparent;
padding-left: 0.5px;
}
.contact-box .deleted.material-icons {
width: 1.5rem;
height: 1.5rem;
font-size: 1.5rem;
color: gray;
color: #999;
position: absolute;
right: 0.05rem;
bottom: 0.05rem;
right: 0;
bottom: 0;
background-color: white;
padding-left: 0.5px;
}
/* END of avatar box */
@@ -1437,7 +1450,8 @@ img.channel {
}
#send-message-panel .material-icons {
min-width: 1.75rem;
min-width: 2rem;
font-size: 1.6rem;
}
#send-message-panel .material-icons.secondary, .material-icons.disabled {
@@ -2626,6 +2640,25 @@ ul.tabbar li.active > a {
height: 80%;
}
.audio {
display: flex;
margin-left: auto;
align-items: center;
flex: none;
background-color: #eee;
height: 3rem;
border-radius: 1.5rem;
}
.audio canvas.visualiser {
width: 12rem;
height: 2.6rem;
}
.audio .duration {
font-size: large;
}
/*
Mobile styles.
*/
+1 -1
View File
File diff suppressed because one or more lines are too long
-3
View File
@@ -33,9 +33,6 @@
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@17/umd/react-dom.development.js"></script>
<!-- React-Intl for translations -->
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@5/react-intl.iife.js"></script>
<!-- Firebase for push notifications -->
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@8/firebase-app.js"></script>
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@8/firebase-messaging.js"></script>
</head>
<body>
-2
View File
@@ -25,8 +25,6 @@
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react@17.0.2/umd/react.production.min.js"></script>
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@17.0.2/umd/react-dom.production.min.js"></script>
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@5.20.7/react-intl.iife.min.js"></script>
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@8.10.0/firebase-app.js"></script>
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@8.10.0/firebase-messaging.js"></script>
</head>
<body>
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
+6 -1
View File
@@ -36,5 +36,10 @@
"platform": "itunes",
"url": "https://apps.apple.com/us/app/tinode/id1483763538"
}
]
],
"permissions": {
"audio-capture": {
"description": "Required for recording voice messages"
}
}
}
+1484 -1011
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -1,7 +1,8 @@
{
"name": "tinode-webapp",
"description": "Tinode messenger for the web",
"version": "0.18.2-beta1",
"version": "0.19.0-alpha1",
"repository": {
"type": "git",
"url": "https://github.com/tinode/example-react-js.git"
@@ -66,11 +67,11 @@
"author": "Tinode Authors <info@tinode.co>",
"license": "Apache-2.0",
"dependencies": {
"firebase": "^8.10.0",
"firebase": "^9.6.7",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-intl": "^5.20.2",
"tinode-sdk": "^0.18.1"
"tinode-sdk": "^0.19.0-alpha1"
},
"devDependencies": {
"@babel/core": "^7.14.5",
+29 -17
View File
@@ -1,6 +1,7 @@
// Must be located at the root.
importScripts('https://cdn.jsdelivr.net/npm/firebase@8.9.1/firebase-app.js');
importScripts('https://cdn.jsdelivr.net/npm/firebase@8.9.1/firebase-messaging.js');
// FIXME: update Firebase version.
importScripts('https://cdn.jsdelivr.net/npm/firebase@8.10.1/firebase-app.js');
importScripts('https://cdn.jsdelivr.net/npm/firebase@8.10.1/firebase-messaging.js');
importScripts('firebase-init.js');
importScripts('version.js');
@@ -43,7 +44,11 @@ const i18n = {
'new_chat': "新聊天",
}
};
self.i18nMessage = function(id) {
if (!id) {
return null;
}
// Choose translations: given something like 'de-CH', try 'de-CH' then 'de' then 'en'.
const lang = i18n[self.locale] || i18n[self.baseLocale] || i18n['en'];
// Try finding string by id in the specified language, if missing try English, otherwise use the id itself
@@ -66,18 +71,21 @@ fbMessaging.onBackgroundMessage((payload) => {
webAppChannel.postMessage(payload.data);
}
const titles = {'msg': 'new_message', 'sub': 'new_chat'};
const pushType = payload.data.what || 'msg';
const title = payload.data.title || self.i18nMessage(pushType == 'msg' ? 'new_message' : 'new_chat');
const options = {
body: payload.data.content || '', // TODO: content for 'sub' should be topic's or user's title.
icon: '/img/logo96.png', // TODO: use topic's or user's avatar (would have to fetch for 'sub', read from db for 'msg').
badge: '/img/badge96.png',
tag: payload.data.topic || undefined,
data: {
topic: payload.data.topic
}
};
return self.registration.showNotification(title, options);
const title = payload.data.title || self.i18nMessage(titles[pushType]);
if (title) {
const options = {
body: payload.data.content || '', // TODO: content for 'sub' should be topic's or user's title.
icon: '/img/logo96.png', // TODO: use topic's or user's avatar (would have to fetch for 'sub', read from db for 'msg').
badge: '/img/badge96.png',
tag: payload.data.topic || undefined,
data: {
topic: payload.data.topic
}
};
return self.registration.showNotification(title, options);
}
});
// Update service worker immediately for both the current client
@@ -89,10 +97,14 @@ self.addEventListener('install', event => {
// This code handles a click on notification: takes
// the user to the browser tab with the chat or opens a new tab.
self.addEventListener('notificationclick', event => {
const notification = event.notification;
notification.close();
const data = event.notification.data;
event.notification.close();
if (!data) {
console.info("Missing 'data' in notification", event.notification);
return;
}
const urlHash = '#/' + notification.data.topic;
const urlHash = '#/' + data.topic;
event.waitUntil(self.clients.matchAll({
type: 'window',
@@ -101,7 +113,7 @@ self.addEventListener('notificationclick', event => {
let anyClient = null;
for (let i = 0; i < windowClients.length; i++) {
const url = new URL(windowClients[i].url);
if (url.hash.includes(notification.data.topic)) {
if (url.hash.includes(data.topic)) {
// Found the Tinode tab with the right topic open.
return windowClients[i].focus();
} else {
+10 -3
View File
@@ -324,21 +324,21 @@
"drafty_attachment": {
"translation": "Dateianhang",
"defaultMessage": "Attachment",
"description": "Comment for attachment in drafty preview",
"description": "Comment for attachment in drafty",
"missing": false,
"obsolete": false
},
"drafty_form": {
"translation": "Formular:",
"defaultMessage": "Form:",
"description": "Comment for form in drafty preview",
"description": "Comment for form in drafty",
"missing": false,
"obsolete": false
},
"drafty_image": {
"translation": "Bild",
"defaultMessage": "Picture",
"description": "Comment for embedded images in drafty preview",
"description": "Comment for embedded images in drafty",
"missing": false,
"obsolete": false
},
@@ -1360,5 +1360,12 @@
"description": "Label for entering a new password",
"missing": false,
"obsolete": false
},
"drafty_unknown": {
"translation": "",
"defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty",
"missing": true,
"obsolete": false
}
}
+10 -3
View File
@@ -324,21 +324,21 @@
"drafty_attachment": {
"translation": "Attachment",
"defaultMessage": "Attachment",
"description": "Comment for attachment in drafty preview",
"description": "Comment for attachment in drafty",
"missing": false,
"obsolete": false
},
"drafty_form": {
"translation": "Form:",
"defaultMessage": "Form:",
"description": "Comment for form in drafty preview",
"description": "Comment for form in drafty",
"missing": false,
"obsolete": false
},
"drafty_image": {
"translation": "Picture",
"defaultMessage": "Picture",
"description": "Comment for embedded images in drafty preview",
"description": "Comment for embedded images in drafty",
"missing": false,
"obsolete": false
},
@@ -1360,5 +1360,12 @@
"description": "Label for entering a new password",
"missing": false,
"obsolete": false
},
"drafty_unknown": {
"translation": "Unsupported",
"defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty",
"missing": false,
"obsolete": false
}
}
+10 -3
View File
@@ -324,21 +324,21 @@
"drafty_attachment": {
"translation": "Archivo",
"defaultMessage": "Attachment",
"description": "Comment for attachment in drafty preview",
"description": "Comment for attachment in drafty",
"missing": false,
"obsolete": false
},
"drafty_form": {
"translation": "Formulario:",
"defaultMessage": "Form:",
"description": "Comment for form in drafty preview",
"description": "Comment for form in drafty",
"missing": false,
"obsolete": false
},
"drafty_image": {
"translation": "Imagen",
"defaultMessage": "Picture",
"description": "Comment for embedded images in drafty preview",
"description": "Comment for embedded images in drafty",
"missing": false,
"obsolete": false
},
@@ -1360,5 +1360,12 @@
"description": "Label for entering a new password",
"missing": false,
"obsolete": false
},
"drafty_unknown": {
"translation": "",
"defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty",
"missing": true,
"obsolete": false
}
}
+7 -3
View File
@@ -193,15 +193,19 @@
},
"drafty_attachment": {
"defaultMessage": "Attachment",
"description": "Comment for attachment in drafty preview"
"description": "Comment for attachment in drafty"
},
"drafty_form": {
"defaultMessage": "Form:",
"description": "Comment for form in drafty preview"
"description": "Comment for form in drafty"
},
"drafty_image": {
"defaultMessage": "Picture",
"description": "Comment for embedded images in drafty preview"
"description": "Comment for embedded images in drafty"
},
"drafty_unknown": {
"defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty"
},
"email_dative": {
"defaultMessage": "email",
+10 -3
View File
@@ -324,21 +324,21 @@
"drafty_attachment": {
"translation": "부착",
"defaultMessage": "Attachment",
"description": "Comment for attachment in drafty preview",
"description": "Comment for attachment in drafty",
"missing": false,
"obsolete": false
},
"drafty_form": {
"translation": "형태:",
"defaultMessage": "Form:",
"description": "Comment for form in drafty preview",
"description": "Comment for form in drafty",
"missing": false,
"obsolete": false
},
"drafty_image": {
"translation": "이미지",
"defaultMessage": "Picture",
"description": "Comment for embedded images in drafty preview",
"description": "Comment for embedded images in drafty",
"missing": false,
"obsolete": false
},
@@ -1360,5 +1360,12 @@
"description": "Label for entering a new password",
"missing": false,
"obsolete": false
},
"drafty_unknown": {
"translation": "",
"defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty",
"missing": true,
"obsolete": false
}
}
+10 -3
View File
@@ -324,21 +324,21 @@
"drafty_attachment": {
"translation": "Atasament",
"defaultMessage": "Attachment",
"description": "Comment for attachment in drafty preview",
"description": "Comment for attachment in drafty",
"missing": false,
"obsolete": false
},
"drafty_form": {
"translation": "Formă:",
"defaultMessage": "Form:",
"description": "Comment for form in drafty preview",
"description": "Comment for form in drafty",
"missing": false,
"obsolete": false
},
"drafty_image": {
"translation": "Imagine",
"defaultMessage": "Picture",
"description": "Comment for embedded images in drafty preview",
"description": "Comment for embedded images in drafty",
"missing": false,
"obsolete": false
},
@@ -1360,5 +1360,12 @@
"description": "Label for entering a new password",
"missing": false,
"obsolete": false
},
"drafty_unknown": {
"translation": "",
"defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty",
"missing": true,
"obsolete": false
}
}
+10 -3
View File
@@ -324,21 +324,21 @@
"drafty_attachment": {
"translation": "Аттачмент",
"defaultMessage": "Attachment",
"description": "Comment for attachment in drafty preview",
"description": "Comment for attachment in drafty",
"missing": false,
"obsolete": false
},
"drafty_form": {
"translation": "Форма:",
"defaultMessage": "Form:",
"description": "Comment for form in drafty preview",
"description": "Comment for form in drafty",
"missing": false,
"obsolete": false
},
"drafty_image": {
"translation": "Картинка",
"defaultMessage": "Picture",
"description": "Comment for embedded images in drafty preview",
"description": "Comment for embedded images in drafty",
"missing": false,
"obsolete": false
},
@@ -1360,5 +1360,12 @@
"description": "Label for entering a new password",
"missing": false,
"obsolete": false
},
"drafty_unknown": {
"translation": "",
"defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty",
"missing": true,
"obsolete": false
}
}
+10 -3
View File
@@ -324,21 +324,21 @@
"drafty_attachment": {
"translation": "附加檔案",
"defaultMessage": "Attachment",
"description": "Comment for attachment in drafty preview",
"description": "Comment for attachment in drafty",
"missing": false,
"obsolete": false
},
"drafty_form": {
"translation": "表單:",
"defaultMessage": "Form:",
"description": "Comment for form in drafty preview",
"description": "Comment for form in drafty",
"missing": false,
"obsolete": false
},
"drafty_image": {
"translation": "圖片",
"defaultMessage": "Picture",
"description": "Comment for embedded images in drafty preview",
"description": "Comment for embedded images in drafty",
"missing": false,
"obsolete": false
},
@@ -1360,5 +1360,12 @@
"description": "Label for entering a new password",
"missing": false,
"obsolete": false
},
"drafty_unknown": {
"translation": "",
"defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty",
"missing": true,
"obsolete": false
}
}
+10 -3
View File
@@ -324,21 +324,21 @@
"drafty_attachment": {
"translation": "附件",
"defaultMessage": "Attachment",
"description": "Comment for attachment in drafty preview",
"description": "Comment for attachment in drafty",
"missing": false,
"obsolete": false
},
"drafty_form": {
"translation": "形式:",
"defaultMessage": "Form:",
"description": "Comment for form in drafty preview",
"description": "Comment for form in drafty",
"missing": false,
"obsolete": false
},
"drafty_image": {
"translation": "图像",
"defaultMessage": "Picture",
"description": "Comment for embedded images in drafty preview",
"description": "Comment for embedded images in drafty",
"missing": false,
"obsolete": false
},
@@ -1360,5 +1360,12 @@
"description": "Label for entering a new password",
"missing": false,
"obsolete": false
},
"drafty_unknown": {
"translation": "",
"defaultMessage": "Unsupported",
"description": "Unsupported entity in drafty",
"missing": true,
"obsolete": false
}
}
+1 -1
View File
@@ -186,7 +186,7 @@ export function fileToBase64(file) {
export function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = (evt) => {
reader.onerror = _ => {
reject(reader.error);
};
reader.onload = () => {
+23 -56
View File
@@ -15,20 +15,33 @@ const messages = defineMessages({
drafty_form: {
id: 'drafty_form',
defaultMessage: 'Form: ',
description: 'Comment for form in drafty preview'
description: 'Comment for form in drafty'
},
drafty_attachment: {
id: 'drafty_attachment',
defaultMessage: 'Attachment',
description: 'Comment for attachment in drafty preview'
description: 'Comment for attachment in drafty'
},
drafty_image: {
id: 'drafty_image',
defaultMessage: 'Picture',
description: 'Comment for embedded images in drafty preview'
description: 'Comment for embedded images in drafty'
},
drafty_unknown: {
id: 'drafty_unknown',
defaultMessage: 'Unsupported',
description: 'Unsupported entity in drafty'
}
});
// Get comment for unknown element
function unsupportedComment(values, ifMissing) {
if (Array.isArray(values) && values.join('').trim()) {
return values;
}
return [<span className="gray">{ifMissing}</span>];
}
// Size the already scaled image.
function handleImageData(el, data, attr) {
if (!data) {
@@ -73,50 +86,6 @@ function handleImageData(el, data, attr) {
return el;
}
function quotedImage(data) {
let promise;
// Get the blob from the image data.
if (data.val) {
const blob = base64ToBlob(data.val, data.mime);
promise = blob ? Promise.resolve(blob) : Prmise.reject(new Error("Invalid image"));
} else {
promise = fetch(this.authorizeURL(sanitizeImageUrl(data.ref))).then(evt => {
if (evt.ok) {
return evt.blob();
} else {
throw new Error(`Image fetch unsuccessful: ${evt.status} ${evt.statusText}`);
}
});
}
// Scale the blob.
return promise
.then(blob => {
return imageScaled(blob, IMAGE_THUMBNAIL_DIM, IMAGE_THUMBNAIL_DIM, -1, true)
}).then(scaled => {
data.mime = scaled.mime;
data.size = scaled.blob.size;
data.width = scaled.width;
data.height = scaled.height;
delete data.ref;
// Keeping the original file name, if provided: ex.data.name;
return blobToBase64(scaled.blob);
}).then(b64 => {
data.val = b64.bits;
return data;
}).catch(err => {
delete data.val;
delete data.name;
data.width = IMAGE_THUMBNAIL_DIM;
data.height = IMAGE_THUMBNAIL_DIM;
data.maxWidth = IMAGE_THUMBNAIL_DIM;
data.maxHeight = IMAGE_THUMBNAIL_DIM;
// Rethrow.
throw err;
});
}
// The main Drafty formatter: converts Drafty elements into React classes. 'this' is set by the caller.
// 'this' must contain:
// viewportWidth: this.props.viewportWidth;
@@ -187,11 +156,10 @@ export function fullFormatter(style, data, values, key, stack) {
attr.onClick = this.onQuoteClick;
break;
default:
if (el == '_UNKN') {
// Unknown element.
el = React.Fragment;
values = [<i className="material-icons gray">extension</i>, ' '].concat(values || []);
}
// Unknown element.
el = React.Fragment;
values = [<i className="material-icons gray">extension</i>, ' ']
.concat(unsupportedComment(values, this.formatMessage(messages.drafty_unknown)));
break;
}
if (!el) {
@@ -263,10 +231,9 @@ export function previewFormatter(style, data, values, key) {
values = null;
break;
default:
if (el == '_UNKN') {
el = React.Fragment;
values = [<i key="unkn" className="material-icons">extension</i>, ' '].concat(values || []);
}
el = React.Fragment;
values = [<i className="material-icons gray">extension</i>, ' ']
.concat(unsupportedComment(values, this.formatMessage(messages.drafty_unknown)));
break;
}
if (!el) {
+7 -3
View File
@@ -17,9 +17,13 @@ export function shortDateFormat(then, locale) {
}
// Convert seconds to minutes:seconds, i.e. 156 sec -> 2:36.
export function secondsToTime(seconds) {
const min = Math.floor(seconds / 60);
let sec = seconds % 60;
// If <code>fixedMins</code> is true, then minutes are represented by at least two digits.
export function secondsToTime(seconds, fixedMin) {
let min = Math.floor(seconds / 60);
if (fixedMin) {
min = min < 10 ? `0${min}` : min;
}
let sec = (seconds % 60) | 0;
sec = sec < 10 ? `0${sec}` : sec;
return `${min}:${sec}`;
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Odds and ends
import Tinode from 'tinode-sdk';
import { Tinode } from 'tinode-sdk';
// Make shortcut icon appear with a green dot + show unread count in title.
export function updateFavicon(count) {
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,3 +1,3 @@
// This is a generated file. Don't edit.
export const PACKAGE_VERSION = "0.18.2-beta1";
export const PACKAGE_VERSION = "0.19.0-alpha1";
+1 -1
View File
@@ -124,7 +124,7 @@ class AccSecurityView extends React.Component {
<div>
<label className="small">
<FormattedMessage id="label_default_access_mode" defaultMessage="Default access mode:"
description="Label for default access mode" />
description="Label for default access mode" />
</label>
</div>
<div className="quoted">
+1 -1
View File
@@ -2,7 +2,7 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Tinode from 'tinode-sdk';
import { Tinode } from 'tinode-sdk';
import { APP_NAME, LINK_CONTACT_US, LINK_PRIVACY_POLICY, LINK_TERMS_OF_SERVICE } from '../config.js';
+1 -1
View File
@@ -2,7 +2,7 @@
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import Tinode from 'tinode-sdk';
import { Tinode } from 'tinode-sdk';
import AvatarUpload from '../widgets/avatar-upload.jsx';
import BadgeList from '../widgets/badge-list.jsx';
+1 -1
View File
@@ -2,7 +2,7 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Tinode from 'tinode-sdk';
import { Tinode } from 'tinode-sdk';
import { APP_NAME } from '../config.js';
+65 -12
View File
@@ -1,8 +1,9 @@
// Panel with a chat.
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import Tinode from 'tinode-sdk';
const Drafty = Tinode.Drafty;
import { Drafty, Tinode } from 'tinode-sdk';
import ChatMessage from '../widgets/chat-message.jsx';
import ContactBadges from '../widgets/contact-badges.jsx';
@@ -90,6 +91,7 @@ class MessagesView extends React.Component {
this.retrySend = this.retrySend.bind(this);
this.sendImageAttachment = this.sendImageAttachment.bind(this);
this.sendFileAttachment = this.sendFileAttachment.bind(this);
this.sendAudioAttachment = this.sendAudioAttachment.bind(this);
this.sendKeyPress = this.sendKeyPress.bind(this);
this.subscribe = this.subscribe.bind(this);
this.handleScrollReference = this.handleScrollReference.bind(this);
@@ -185,8 +187,13 @@ class MessagesView extends React.Component {
this.postReadNotification(0);
}
if ((this.state.topic != prevState.topic) || !prevProps.ready) {
this.subscribe(topic);
if (topic && ((this.state.topic != prevState.topic) || !prevProps.ready)) {
if (topic._new) {
console.log('DEBUG: Fetching new topic description');
// topic.getMeta(topic.startMetaQuery().withDesc().build());
} else {
this.subscribe(topic);
}
}
}
@@ -204,6 +211,7 @@ class MessagesView extends React.Component {
isVerified: false,
isStaff: false,
isDangerous: false,
deleted: false,
docPreview: null,
imagePreview: null,
imagePostview: null,
@@ -237,7 +245,8 @@ class MessagesView extends React.Component {
scrollPosition: 0,
fetchingMessages: false,
reply: reply,
showGoToLastButton: false
showGoToLastButton: false,
deleted: topic._deleted
};
if (topic) {
@@ -338,7 +347,7 @@ class MessagesView extends React.Component {
}
subscribe(topic) {
if (!topic || topic.isSubscribed() || !this.props.ready) {
if (topic.isSubscribed() || !this.props.ready) {
return;
}
@@ -735,6 +744,10 @@ class MessagesView extends React.Component {
if (file.size > maxInbandAttachmentSize) {
// Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper();
if (!uploader) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return;
}
const uploadCompletionPromise = uploader.upload(file);
const msg = Drafty.attachFile(null, {
mime: file.type,
@@ -752,7 +765,7 @@ class MessagesView extends React.Component {
}
}
// handleAttachFile method is called when [Attach file] button is clicked.
// handleAttachFile method is called when [Attach file] button is clicked: launch attachment preview.
handleAttachFile(file) {
const maxExternAttachmentSize = this.props.tinode.getServerLimit('maxFileUploadSize', MAX_EXTERN_ATTACHMENT_SIZE);
@@ -837,7 +850,7 @@ class MessagesView extends React.Component {
});
}
// handleAttachImage method is called when [Attach image] button is clicked.
// handleAttachImage method is called when [Attach image] button is clicked: launch image preview.
handleAttachImage(file) {
const maxExternAttachmentSize = this.props.tinode.getServerLimit('maxFileUploadSize', MAX_EXTERN_ATTACHMENT_SIZE);
@@ -858,6 +871,41 @@ class MessagesView extends React.Component {
});
}
// sendAudioAttachment sends audio bits inband as Drafty message (no preview).
sendAudioAttachment(url, duration) {
// Server-provided limit reduced for base64 encoding and overhead.
const maxInbandAttachmentSize = (this.props.tinode.getServerLimit(
'maxMessageSize', MAX_INBAND_ATTACHMENT_SIZE) * 0.75 - 1024) | 0;
fetch(url).then(result => {
return result.blob();
}).then(blob => {
if (blob.size > maxInbandAttachmentSize) {
// Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper();
if (!uploader) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return;
}
const uploadCompletionPromise = uploader.upload(blob);
const msg = Drafty.appendAudio(null, {
mime: blob.type,
size: blob.size,
duration: duration,
urlPromise: uploadCompletionPromise
});
// Pass data and the uploader to the TinodeWeb.
this.sendMessage(msg, uploadCompletionPromise, uploader);
} else {
// Small enough to send inband.
blobToBase64(blob)
.then(b64 => {
this.sendMessage(Drafty.appendAudio(null, {mime: b64.mime, data: b64.bits, duration: duration}))
})
.catch(err => {this.props.onError(err)});
}
});
}
handleCancelUpload(seq, uploader) {
const topic = this.props.tinode.getTopic(this.state.topic);
const found = topic.findMessage(seq);
@@ -1077,7 +1125,10 @@ class MessagesView extends React.Component {
}
}
const avatar = this.state.avatar || true;
const online = this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline';
const online = this.state.deleted ? null :
this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline';
const titleClass = 'panel-title' + (this.state.deleted ? ' deleted' : '');
component2 = (
<>
@@ -1093,11 +1144,12 @@ class MessagesView extends React.Component {
tinode={this.props.tinode}
avatar={avatar}
topic={this.state.topic}
title={this.state.title} />
title={this.state.title}
deleted={this.state.deleted} />
{!isChannel ? <span className={online} /> : null}
</div>
<div id="topic-title-group">
<div id="topic-title" className="panel-title">{
<div id="topic-title" className={titleClass}>{
this.state.title ||
<i><FormattedMessage id="unnamed_topic" defaultMessage="Unnamed"
description="Title shown when the topic has no name" /></i>
@@ -1158,11 +1210,12 @@ class MessagesView extends React.Component {
<SendMessage
tinode={this.props.tinode}
noInput={!!this.props.forwardMessage}
disabled={!this.state.isWriter || topic.deleted}
disabled={!this.state.isWriter || this.state.deleted}
onKeyPress={this.sendKeyPress}
onSendMessage={this.sendMessage}
onAttachFile={this.props.forwardMessage ? null : this.handleAttachFile}
onAttachImage={this.props.forwardMessage ? null : this.handleAttachImage}
onAttachAudio={this.sendAudioAttachment}
onError={this.props.onError}
reply={this.state.reply}
onQuoteClick={this.handleQuoteClick}
+5 -5
View File
@@ -2,7 +2,7 @@
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import Tinode from 'tinode-sdk';
import { Tinode } from 'tinode-sdk';
import ContactList from '../widgets/contact-list.jsx';
import NewTopicById from '../widgets/new-topic-by-id.jsx';
@@ -41,7 +41,7 @@ class NewTopicView extends React.Component {
this.handleTabClick = this.handleTabClick.bind(this);
this.handleSearchContacts = this.handleSearchContacts.bind(this);
this.handleContactSelected = this.handleContactSelected.bind(this);
this.handleSearchResultSelected = this.handleSearchResultSelected.bind(this);
this.handleNewGroupSubmit = this.handleNewGroupSubmit.bind(this);
this.handleGroupByID = this.handleGroupByID.bind(this);
}
@@ -61,10 +61,10 @@ class NewTopicView extends React.Component {
this.setState({searchQuery: Tinode.isNullValue(query) ? null : query});
}
handleContactSelected(uid) {
handleSearchResultSelected(name) {
if (this.state.tabSelected == 'find') {
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
this.props.onCreateTopic(uid);
this.props.onCreateTopic(name);
}
}
@@ -126,7 +126,7 @@ class NewTopicView extends React.Component {
showOnline={false}
showUnread={false}
showContextMenu={false}
onTopicSelected={this.handleContactSelected} />
onTopicSelected={this.handleSearchResultSelected} />
</div>}
</div>
);
+111 -99
View File
@@ -2,10 +2,11 @@
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import * as firebase from 'firebase/app';
import 'firebase/messaging';
import { initializeApp as firebaseInitApp } from 'firebase/app';
import { getMessaging as firebaseGetMessaging, getToken as firebaseGetToken,
deleteToken as firebaseDelToken, onMessage as firebaseOnMessage } from 'firebase/messaging';
import Tinode from 'tinode-sdk';
import { Drafty, Tinode } from 'tinode-sdk';
import Alert from '../widgets/alert.jsx';
import ContextMenu from '../widgets/context-menu.jsx';
@@ -114,8 +115,7 @@ class TinodeWeb extends React.Component {
this.handleCredDelete = this.handleCredDelete.bind(this);
this.handleCredConfirm = this.handleCredConfirm.bind(this);
this.initFCMessaging = this.initFCMessaging.bind(this);
this.togglePushToken = this.togglePushToken.bind(this);
this.requestPushToken = this.requestPushToken.bind(this);
this.toggleFCMToken = this.toggleFCMToken.bind(this);
this.handlePushMessage = this.handlePushMessage.bind(this);
this.handleSidepanelCancel = this.handleSidepanelCancel.bind(this);
this.handleStartTopicRequest = this.handleStartTopicRequest.bind(this);
@@ -164,9 +164,11 @@ class TinodeWeb extends React.Component {
// "On" is the default, so saving the "off" state.
messageSounds: !settings.messageSoundsOff,
incognitoMode: false,
// Persistent request to enable alerts.
desktopAlerts: persist && !!settings.desktopAlerts,
// Enable / disable checkbox.
desktopAlertsEnabled: (isSecureConnection() || isLocalHost()) &&
(typeof firebase != 'undefined') && (typeof navigator != 'undefined') &&
(typeof firebaseInitApp != 'undefined') && (typeof navigator != 'undefined') &&
(typeof FIREBASE_INIT != 'undefined'),
firebaseToken: persist ? LocalStorageUtil.getObject('firebase-token') : null,
@@ -257,11 +259,7 @@ class TinodeWeb extends React.Component {
if (this.state.desktopAlertsEnabled) {
this.initFCMessaging().then(() => {
if (this.state.desktopAlerts) {
if (!this.state.firebaseToken) {
this.togglePushToken(true);
} else {
this.tinode.setDeviceToken(this.state.firebaseToken);
}
this.tinode.setDeviceToken(this.state.firebaseToken);
}
}).catch(() => {
// do nothing: handled earlier.
@@ -317,11 +315,22 @@ class TinodeWeb extends React.Component {
return tinode;
}
// Notifiy Tinode that a push message was received from the server.
handlePushMessage(data) {
if (data.what == 'msg' && Tinode.isChannelTopicName(data.topic)) {
// The last argument is a fake user Id: otherwise the update is seen as one from the current user.
this.tinode.oobNotification(data.topic, data.seq, 'fake-uid');
// Tinode received a push notification from the server.
handlePushMessage(payload) {
const data = payload.data || {};
switch (data.what) {
case 'msg':
if (Tinode.isChannelTopicName(data.topic)) {
// The last argument is a fake user Id: otherwise the update is seen as one from the current user.
this.tinode.oobNotification('msg', data.topic, data.seq, 'fake-uid');
}
break;
case 'read':
// Message read on another device, clear it here.
this.tinode.oobNotification('read', data.topic, data.seq);
break;
default:
console.warn("Unknown push type ignored", data.what, data);
}
}
@@ -330,40 +339,77 @@ class TinodeWeb extends React.Component {
const onError = (msg, err) => {
console.error(msg, err);
this.handleError(formatMessage(messages.push_init_failed), 'err');
this.setState({desktopAlertsEnabled: false});
this.setState({desktopAlertsEnabled: false, firebaseToken: null});
LocalStorageUtil.updateObject('settings', {desktopAlerts: false});
}
try {
this.fbPush = firebase.initializeApp(FIREBASE_INIT, APP_NAME).messaging();
this.fbPush.usePublicVapidKey(FIREBASE_INIT.messagingVapidKey);
this.fcm = firebaseGetMessaging(firebaseInitApp(FIREBASE_INIT, APP_NAME));
return navigator.serviceWorker.register('/service-worker.js').then((reg) => {
this.checkForAppUpdate(reg);
this.fbPush.useServiceWorker(reg);
reg.active.postMessage(JSON.stringify({locale: locale, version: PACKAGE_VERSION}));
// Google could not be bothered to mention that onTokenRefresh is never called.
this.fbPush.onTokenRefresh(() => {
this.requestPushToken();
});
return reg;
}).then((reg) => {
// Request token.
return TinodeWeb.requestFCMToken(this.fcm, reg);
}).then((token) => {
const persist = LocalStorageUtil.getObject('keep-logged-in');
if (token != this.state.firebaseToken) {
this.tinode.setDeviceToken(token);
if (persist) {
LocalStorageUtil.setObject('firebase-token', token);
}
}
this.setState({firebaseToken: token, desktopAlerts: true});
if (persist) {
LocalStorageUtil.updateObject('settings', {desktopAlerts: true});
}
// Handhe FCM pushes
// (a) for channels always,
// (b) pushes when the app is in foreground but has not focus.
this.fbPush.onMessage(payload => { this.handlePushMessage(payload.data); });
return reg;
// (b) pushes when the app is in foreground but has no focus.
firebaseOnMessage(this.fcm, (payload) => { this.handlePushMessage(payload); });
}).catch((err) => {
// registration failed :(
onError("Failed to register service worker:", err);
return Promise.reject(err);
// SW registration or FCM has failed :(
onError(err);
throw err;
});
} catch (err) {
onError("Failed to initialize push notifications", err);
onError(err);
return Promise.reject(err);
}
}
// Google's FCM API is idiotic.
static requestFCMToken(fcm, sw) {
return firebaseGetToken(fcm, {
serviceWorkerRegistration: sw,
vapidKey: FIREBASE_INIT.messagingVapidKey
}).then((token) => {
if (token) {
return token;
} else {
// Try to request permissions.
return Notification.requestPermission().then((permission) => {
if (permission === 'granted') {
return firebaseGetToken(fcm, {
serviceWorkerRegistration: reg,
vapidKey: FIREBASE_INIT.messagingVapidKey
}).then((token) => {
if (token) {
return token;
} else {
throw new Error("Failed to initialize notifications");
}
});
} else {
throw new Error("No permission to send notifications: " + permission);
}
});
}
});
}
handleResize() {
const mobile = document.documentElement.clientWidth <= MEDIA_BREAKPOINT;
this.setState({
@@ -414,14 +460,10 @@ class TinodeWeb extends React.Component {
// Clear invalid topic name.
topicName = null;
}
const newState = {
topicSelected: topicName
};
const acs = this.tinode.getTopicAccessMode(topicName);
if (acs) {
newState.topicSelectedAcs = acs;
}
this.setState(newState);
this.setState({
topicSelected: topicName,
topicSelectedAcs: this.tinode.getTopicAccessMode(topicName)
});
}
} else {
// Empty hashpath
@@ -865,7 +907,7 @@ class TinodeWeb extends React.Component {
});
}
// User clicked on a contact in the side panel or deleted a contact.
// User clicked on a topic in the side panel or deleted a topic.
handleTopicSelected(topicName) {
// Clear newTopicParams after use.
if (this.state.newTopicParams && this.state.newTopicParams._topicName != topicName) {
@@ -881,7 +923,7 @@ class TinodeWeb extends React.Component {
mobilePanel: 'topic-view',
infoPanel: undefined
});
// Different contact selected.
// Different topic selected.
if (this.state.topicSelected != topicName) {
this.setState({
topicSelectedOnline: this.tinode.isTopicOnline(topicName),
@@ -945,7 +987,7 @@ class TinodeWeb extends React.Component {
// If there are unsent messages, try sending them now.
topic.queuedMessages((pub) => {
if (!pub._sending && topic.isSubscribed()) {
this.sendMessage(pub);
topic.publishMessage(pub);
}
});
});
@@ -1101,31 +1143,19 @@ class TinodeWeb extends React.Component {
this.state.myUserId ? 'blocked' : ''));
}
togglePushToken(enabled) {
toggleFCMToken(enabled) {
if (enabled) {
this.setState({desktopAlerts: null});
if (!this.state.firebaseToken) {
const fcm = this.fbPush ?
Promise.resolve() :
this.initFCMessaging();
fcm.then(() => {
return this.fbPush.requestPermission();
}).then(() => {
this.requestPushToken();
}).catch((err) => {
console.error("Failed to get notification permission.", err);
this.handleError(err.message, 'err');
this.setState({desktopAlerts: false, firebaseToken: null});
LocalStorageUtil.updateObject('settings', {desktopAlerts: false});
});
this.initFCMessaging();
} else {
this.setState({desktopAlerts: true});
if (LocalStorageUtil.getObject('keep-logged-in')) {
LocalStorageUtil.updateObject('settings', {desktopAlerts: true});
}
}
} else if (this.state.firebaseToken && this.fbPush) {
this.fbPush.deleteToken(this.state.firebaseToken).catch((err) => {
} else if (this.state.firebaseToken && this.fcm) {
firebaseDelToken(this.fcm).catch((err) => {
console.error("Unable to delete token.", err);
}).finally(() => {
LocalStorageUtil.updateObject('settings', {desktopAlerts: false});
@@ -1140,25 +1170,6 @@ class TinodeWeb extends React.Component {
}
}
requestPushToken() {
this.fbPush.getToken().then((refreshedToken) => {
const persist = LocalStorageUtil.getObject('keep-logged-in');
if (refreshedToken != this.state.firebaseToken) {
this.tinode.setDeviceToken(refreshedToken);
if (persist) {
LocalStorageUtil.setObject('firebase-token', refreshedToken);
}
}
this.setState({firebaseToken: refreshedToken, desktopAlerts: true});
if (persist) {
LocalStorageUtil.updateObject('settings', {desktopAlerts: true});
}
}).catch((err) => {
this.handleError(err.message, 'err');
console.error("Failed to retrieve firebase token", err);
});
}
handleToggleMessageSounds(enabled) {
this.setState({messageSounds: enabled});
LocalStorageUtil.updateObject('settings', {
@@ -1338,24 +1349,30 @@ class TinodeWeb extends React.Component {
localStorage.removeItem('firebase-token');
localStorage.removeItem('settings');
if (this.state.firebaseToken) {
this.fbPush.deleteToken(this.state.firebaseToken)
firebaseDelToken(this.fcm);
}
clearInterval(this.reconnectCountdown);
let cleared;
if (this.tinode) {
this.tinode.clearStorage();
cleared = this.tinode.clearStorage();
this.tinode.onDisconnect = undefined;
this.tinode.disconnect();
} else {
cleared = Promose.resolve();
}
this.setState(this.getBlankState());
this.tinode = TinodeWeb.tnSetup(this.state.serverAddress,
this.state.transport, this.props.intl.locale, LocalStorageUtil.getObject('keep-logged-in'));
this.tinode.onConnect = this.handleConnected;
this.tinode.onDisconnect = this.handleDisconnect;
this.tinode.onAutoreconnectIteration = this.handleAutoreconnectIteration;
HashNavigation.navigateTo('');
cleared.then(() => {
this.tinode = TinodeWeb.tnSetup(this.state.serverAddress,
this.state.transport, this.props.intl.locale, LocalStorageUtil.getObject('keep-logged-in'), () => {
this.tinode.onConnect = this.handleConnected;
this.tinode.onDisconnect = this.handleDisconnect;
this.tinode.onAutoreconnectIteration = this.handleAutoreconnectIteration;
HashNavigation.navigateTo('');
})
});
}
handleDeleteAccount() {
@@ -1426,7 +1443,7 @@ class TinodeWeb extends React.Component {
}
// Publish spam report.
this.tinode.publish(Tinode.TOPIC_SYS, Tinode.Drafty.attachJSON(null, {
this.tinode.publish(Tinode.TOPIC_SYS, Drafty.attachJSON(null, {
'action': 'report',
'target': topicName
}));
@@ -1457,16 +1474,11 @@ class TinodeWeb extends React.Component {
this.handleSidepanelCancel();
}
const header = '➦ ' + params.userName;
const content = typeof params.content == 'string' ?
Tinode.Drafty.init(params.content) : Tinode.Drafty.forwardedContent(params.content);
const preview = Tinode.Drafty.preview(content, FORWARDED_PREVIEW_LENGTH,
undefined, params.forwarded != null);
const msg = Tinode.Drafty.append(
Tinode.Drafty.appendLineBreak(
Tinode.Drafty.mention(header, params.userFrom)),
content);
const content = typeof params.content == 'string' ? Drafty.init(params.content) : Drafty.forwardedContent(params.content);
const preview = Drafty.preview(content, FORWARDED_PREVIEW_LENGTH, undefined, params.forwarded != null);
const msg = Drafty.append(Drafty.appendLineBreak(Drafty.mention(header, params.userFrom)), content);
const msgPreview = Tinode.Drafty.quote(header, params.userFrom, preview);
const msgPreview = Drafty.quote(header, params.userFrom, preview);
const head = {
forwarded: params.topicName + ':' + params.seq
};
@@ -1718,7 +1730,7 @@ class TinodeWeb extends React.Component {
onUpdateAccountDesc={this.handleTopicUpdateRequest}
onUpdatePassword={this.handleUpdatePasswordRequest}
onUpdateAccountTags={this.handleUpdateAccountTagsRequest}
onTogglePushNotifications={this.togglePushToken}
onTogglePushNotifications={this.toggleFCMToken}
onToggleMessageSounds={this.handleToggleMessageSounds}
onToggleIncognitoMode={this.handleToggleIncognitoMode}
onCredAdd={this.handleCredAdd}
+252
View File
@@ -0,0 +1,252 @@
// Audio recorder widget.
import React from 'react';
import { secondsToTime } from '../lib/strformat';
// FFT resolution.
const BUFFER_SIZE = 256;
// Thickness of a visualization bar.
const LINE_WIDTH = 4;
// Spacing between two visualization bars.
const SPACING = 2;
// Duration represented by one visualization bar.
const MILLIS_PER_BAR = 100;
// Color of histogram bars
const BAR_COLOR = '#8fbed6';
// Scaling for visualization bars.
const BAR_SCALE = 96.0;
// Background color
const BKG_COLOR = '#eeeeee';
// Minimum duration of a recording in milliseconds.
const MIN_DURATION = 200;
// Maximum duration of a recording in milliseconds (10 min).
const MAX_DURATION = 600000;
export default class AudioRecorder extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
enabled: true,
audioRecord: null,
recording: true,
paused: false,
duration: '0:00'
};
this.visualize = this.visualize.bind(this);
this.initMediaRecording = this.initMediaRecording.bind(this);
this.cleanUp = this.cleanUp.bind(this);
this.handleResume = this.handleResume.bind(this);
this.handlePause = this.handlePause.bind(this);
this.handleDelete = this.handleDelete.bind(this);
this.handleDone = this.handleDone.bind(this);
this.durationMillis = 0;
this.startedOn = null;
this.viewBuffer = [];
this.canvasRef = React.createRef();
}
componentDidMount() {
this.canvasContext = this.canvasRef.current.getContext('2d');
this.canvasContext.lineCap = 'round';
// To reduce line blurring.
this.canvasContext.translate(0.5, 0.5);
this.stream = null;
this.mediaRecorder = null;
this.audioContext = null;
this.audioInput = null;
this.analyser = null;
this.audioChunks = [];
// Start recorder right away.
try {
navigator.mediaDevices.getUserMedia({audio: true, video: false}).then(this.initMediaRecording, this.props.onError);
} catch (err) {
this.props.onError(err);
}
}
// Draw amplitude of sound.
visualize() {
const pcmData = new Uint8Array(this.analyser.frequencyBinCount);
const width = this.canvasRef.current.width;
const height = this.canvasRef.current.height;
const viewLength = (width / (LINE_WIDTH + SPACING)) | 0;
const viewDuration = MILLIS_PER_BAR * viewLength;
this.canvasContext.fillStyle = BKG_COLOR;
this.canvasContext.lineWidth = LINE_WIDTH;
this.canvasContext.strokeStyle = BAR_COLOR;
let prevBarCount = 0;
const drawFrame = () => {
if (!this.startedOn) {
return;
}
window.requestAnimationFrame(drawFrame);
const duration = this.durationMillis + (Date.now() - this.startedOn);
// Update record length timer.
this.setState({duration: secondsToTime(duration / 1000)});
// Check if record is too long.
if (duration > MAX_DURATION) {
this.mediaRecorder.pause();
this.durationMillis += Date.now() - this.startedOn;
this.startedOn = null;
this.setState({enabled: false, recording: false, duration: secondsToTime(this.durationMillis / 1000)});
}
// Draw histogram.
// Get current waveform and calculate its amplitude.
this.analyser.getByteTimeDomainData(pcmData);
let volume = 0.0;
for (const amplitude of pcmData) {
volume += (amplitude - 127) ** 2;
}
volume = Math.sqrt(volume/pcmData.length);
let barCount = (duration / MILLIS_PER_BAR) | 0;
// Shift of the histogram along x-axis to make scrolling smooth. No need to shift if recording is too short.
const dx = viewDuration > duration ? 0 :
(duration - MILLIS_PER_BAR * barCount) / MILLIS_PER_BAR * (LINE_WIDTH + SPACING);
if (prevBarCount != barCount) {
prevBarCount = barCount;
// Add new amplitude visualization bar.
this.viewBuffer.push(volume);
if (this.viewBuffer.length > viewLength) {
// Keep at most 'viewLength' amplitude bars.
this.viewBuffer.shift();
}
}
// Clear canvas.
this.canvasContext.fillRect(0, 0, width, height);
// Draw amplitude bars.
this.canvasContext.beginPath();
for (let i = 0; i < this.viewBuffer.length; i++) {
let x = i * (LINE_WIDTH + SPACING) - dx;
let y = this.viewBuffer[i] / BAR_SCALE * height;
this.canvasContext.moveTo(x, (height - y) * 0.5);
this.canvasContext.lineTo(x, height * 0.5 + y * 0.5);
}
// Actually draw the bars on canvas.
this.canvasContext.stroke();
}
drawFrame();
}
handlePause(e) {
e.preventDefault();
this.mediaRecorder.pause();
this.durationMillis += Date.now() - this.startedOn;
this.startedOn = null;
this.setState({recording: false, duration: secondsToTime(this.durationMillis / 1000)});
}
handleResume(e) {
e.preventDefault();
if (this.state.enabled) {
this.startedOn = Date.now();
this.mediaRecorder.resume();
this.setState({recording: true}, this.visualize);
}
}
handleDelete(e) {
e.preventDefault();
this.durationMillis = 0;
this.startedOn = null;
this.mediaRecorder.stop();
this.cleanUp();
this.setState({recording: false});
}
handleDone(e) {
e.preventDefault();
this.setState({recording: false});
if (this.startedOn) {
this.durationMillis += Date.now() - this.startedOn;
this.startedOn = null;
}
// Stop recording and return data.
if (this.mediaRecorder) {
this.mediaRecorder.stop();
}
}
initMediaRecording(stream) {
this.stream = stream;
this.mediaRecorder = new MediaRecorder(stream);
// The following code is needed for visualization.
this.audioContext = new AudioContext();
this.audioInput = this.audioContext.createMediaStreamSource(stream);
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = BUFFER_SIZE;
this.audioInput.connect(this.analyser);
this.mediaRecorder.onstop = _ => {
const blob = new Blob(this.audioChunks, { 'type' : 'audio/mp3; codecs=mp3' });
this.audioChunks = [];
const url = window.URL.createObjectURL(blob);
this.cleanUp();
if (this.durationMillis > MIN_DURATION) {
this.props.onFinished(url, this.durationMillis);
} else {
this.props.onDeleted();
}
}
this.mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
this.audioChunks.push(e.data);
}
}
this.durationMillis = 0;
this.startedOn = Date.now();
this.mediaRecorder.start();
this.visualize();
}
cleanUp() {
this.audioInput.disconnect();
this.stream.getTracks().forEach(track => track.stop());
}
render() {
const resumeClass = 'material-icons ' + (this.state.enabled ? 'red' : 'gray');
return (
<div className="audio">
<a href="#" onClick={this.handleDelete} title="Delete">
<i className="material-icons">delete_outline</i>
</a>
<canvas className="visualiser" width="200" height="60" ref={this.canvasRef} />
<div className="duration">{this.state.duration}</div>
{this.state.recording ?
<a href="#" onClick={this.handlePause} title="Pause">
<i className="material-icons">pause_circle_outline</i>
</a> :
<a href="#" onClick={this.handleResume} title="Resume">
<i className={resumeClass}>radio_button_checked</i>
</a>
}
<a href="#" onClick={this.handleDone} title="Send">
<i className="material-icons">send</i>
</a>
</div>
);
}
}
+2 -3
View File
@@ -3,8 +3,7 @@
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { Drafty } from 'tinode-sdk';
import Tinode from 'tinode-sdk';
import { Drafty, Tinode } from 'tinode-sdk';
import Contact from './contact.jsx';
import ContactAction from './contact-action.jsx';
@@ -115,7 +114,7 @@ class ContactList extends React.Component {
isVerified={c.trusted && c.trusted.verified}
isStaff={c.trusted && c.trusted.staff}
isDangerous={c.trusted && c.trusted.danger}
deleted={c.deleted}
deleted={c._deleted}
item={key}
index={contactNodes.length}
key={key} />
+5 -3
View File
@@ -86,6 +86,7 @@ class Contact extends React.Component {
const icon = deliveryMarker(this.props.received);
const marker = icon ? <i className={'material-icons small space-right' +
(icon.color ? ' ' + icon.color : '')}>{icon.name}</i> : null;
const titleClass = 'contact-title' + (this.props.deleted ? ' deleted' : '');
return (
<li className={!this.props.showCheckmark && this.props.selected ? 'selected' : null} onClick={this.handleClick}>
@@ -95,7 +96,7 @@ class Contact extends React.Component {
avatar={avatar}
title={this.props.title}
topic={this.props.item}
disabled={this.props.deleted} />
deleted={this.props.deleted} />
{this.props.deleted ? <i className="deleted material-icons">cancel</i> :
this.props.showOnline ? <span className={online} /> :
(this.props.showCheckmark && this.props.selected) ?
@@ -103,9 +104,10 @@ class Contact extends React.Component {
: null}
</div>
<div className="text-box">
<div><span className="contact-title">{title}</span>
<div><span className={titleClass}>{title}</span>
{this.props.isChannel ? <img src="/img/channel.png" className="channel" alt="channel" /> : null}
<ContactBadges badges={icon_badges} /><UnreadBadge count={this.props.unread} />
<ContactBadges badges={icon_badges} />
{!this.props.deleted ? <UnreadBadge count={this.props.unread} /> : null}
</div>
{this.props.showMode ?
<span><ContactBadges badges={badges} /></span> :
+6 -6
View File
@@ -237,7 +237,7 @@ class ContextMenu extends React.Component {
(() => {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: ", params.topicName);
console.warn("Topic not found: ", params.topicName);
return;
}
return topic.delTopic(true).catch((err) => {
@@ -258,7 +258,7 @@ class ContextMenu extends React.Component {
handler: (params, errorHandler) => {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: ", params.topicName);
console.warn("Topic not found: ", params.topicName);
return;
}
return topic.archive(true).catch((err) => {
@@ -274,7 +274,7 @@ class ContextMenu extends React.Component {
handler: (params, errorHandler) => {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: ", params.topicName);
console.warn("Topic not found: ", params.topicName);
return;
}
return topic.archive(false).catch((err) => {
@@ -296,7 +296,7 @@ class ContextMenu extends React.Component {
handler: (params, errorHandler) => {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic || !params.user) {
console.log("Topic or user not found: '" + params.topicName + "', '" + params.user + "'");
console.warn("Topic or user not found: '" + params.topicName + "', '" + params.user + "'");
return;
}
return topic.delSubscription(params.user).catch((err) => {
@@ -378,7 +378,7 @@ class ContextMenu extends React.Component {
deleteMessages(all, hard, params, errorHandler) {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: ", params.topicName);
console.warn("Topic not found: ", params.topicName);
return;
}
@@ -420,7 +420,7 @@ class ContextMenu extends React.Component {
topicPermissionSetter(mode, params, errorHandler) {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found", params.topicName);
console.warn("Topic not found", params.topicName);
return;
}
+1 -1
View File
@@ -2,7 +2,7 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Tinode from 'tinode-sdk';
import { Tinode } from 'tinode-sdk';
import ContactList from './contact-list.jsx';
import SearchContacts from './search-contacts.jsx';
+4 -4
View File
@@ -1,6 +1,6 @@
import React from 'react';
import Tinode from 'tinode-sdk';
import { Tinode } from 'tinode-sdk';
import { idToColorClass } from '../lib/strformat.js';
import { sanitizeImageUrl } from '../lib/utils.js';
@@ -13,16 +13,16 @@ export default class LetterTile extends React.PureComponent {
const iconColor = idToColorClass(this.props.topic, isGroup);
if (this.props.topic && this.props.title && this.props.title.trim()) {
const letter = this.props.title.trim().charAt(0);
const className = 'lettertile ' + iconColor + (this.props.disabled ? ' disabled' : '');
const className = 'lettertile ' + iconColor + (this.props.deleted ? ' disabled' : '');
avatar = (<div className={className}><div>{letter}</div></div>)
} else {
const className = 'material-icons ' + iconColor + (this.props.disabled ? ' disabled' : '');
const className = 'material-icons ' + iconColor + (this.props.deleted ? ' disabled' : '');
avatar = isGroup ? <i className={className}>group</i> : <i className={className}>person</i>;
}
} else if (this.props.avatar) {
const url = this.props.tinode.authorizeURL(sanitizeImageUrl(this.props.avatar));
// If avatar image is invalid, show a placeholder.
const className = 'avatar' + (this.props.disabled ? ' disabled' : '');
const className = 'avatar' + (this.props.deleted ? ' deleted' : '');
avatar = <img className={className} alt="avatar" src={url}
onError={(e)=>{e.target.onerror = null; e.target.src="../img/broken_image.png"}} />;
} else {
+1 -1
View File
@@ -2,7 +2,7 @@
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import Tinode from 'tinode-sdk';
import { Tinode } from 'tinode-sdk';
import { deliveryMarker } from '../lib/utils.js';
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import Tinode from 'tinode-sdk';
import { Tinode } from 'tinode-sdk';
export default class SearchContacts extends React.PureComponent {
constructor(props) {
+37 -10
View File
@@ -3,6 +3,8 @@ import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { Drafty } from 'tinode-sdk';
import AudioRecorder from './audio-recorder.jsx';
import { KEYPRESS_DELAY } from '../config.js';
import { filePasted } from '../lib/blob-helpers.js';
import { replyFormatter } from '../lib/formatters.js';
@@ -42,6 +44,8 @@ class SendMessage extends React.PureComponent {
this.state = {
quote: null,
message: '',
audioRec: false,
audioEnabled: !!navigator.mediaDevices.getUserMedia,
// Make initial keypress time as if it happened 5001 milliseconds in the past.
keypressTimestamp: new Date().getTime() - KEYPRESS_DELAY - 1
};
@@ -49,6 +53,7 @@ class SendMessage extends React.PureComponent {
this.handlePasteEvent = this.handlePasteEvent.bind(this);
this.handleAttachImage = this.handleAttachImage.bind(this);
this.handleAttachFile = this.handleAttachFile.bind(this);
this.handleAttachAudio = this.handleAttachAudio.bind(this);
this.handleSend = this.handleSend.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this);
this.handleMessageTyping = this.handleMessageTyping.bind(this);
@@ -118,7 +123,6 @@ class SendMessage extends React.PureComponent {
}
handleAttachFile(e) {
const {formatMessage} = this.props.intl;
if (e.target.files && e.target.files.length > 0) {
this.props.onAttachFile(e.target.files[0]);
}
@@ -126,6 +130,11 @@ class SendMessage extends React.PureComponent {
e.target.value = '';
}
handleAttachAudio(url, duration) {
this.setState({audioRec: false});
this.props.onAttachAudio(url, duration);
}
handleSend(e) {
e.preventDefault();
const message = this.state.message.trim();
@@ -137,6 +146,13 @@ class SendMessage extends React.PureComponent {
/* Send on Enter key */
handleKeyPress(e) {
if (this.state.audioRec) {
// Ignore key presses while audio is being recorded.
e.preventDefault();
e.stopPropagation();
return;
}
// Remove this if you don't want Enter to trigger send
if (e.key === 'Enter') {
// Have Shift-Enter insert a line break instead
@@ -192,7 +208,7 @@ class SendMessage extends React.PureComponent {
<div id="send-message-panel">
{!this.props.disabled ?
<>
{this.props.onAttachFile ?
{this.props.onAttachFile && !this.state.audioRec ?
<>
<a href="#" onClick={(e) => {e.preventDefault(); this.attachImage.click();}} title="Add image">
<i className="material-icons secondary">photo</i>
@@ -205,14 +221,25 @@ class SendMessage extends React.PureComponent {
null}
{this.props.noInput ?
(this.state.quote ? quote : <div className="hr thin" />) :
<textarea id="sendMessage" placeholder={prompt}
value={this.state.message} onChange={this.handleMessageTyping}
onKeyPress={this.handleKeyPress}
ref={(ref) => {this.messageEditArea = ref;}}
autoFocus />}
<a href="#" onClick={this.handleSend} title="Send">
<i className="material-icons">send</i>
</a>
(this.state.audioRec ?
<AudioRecorder
onDeleted={_ => this.setState({audioRec: false})}
onFinished={this.handleAttachAudio}/> :
<textarea id="sendMessage" placeholder={prompt}
value={this.state.message} onChange={this.handleMessageTyping}
onKeyPress={this.handleKeyPress}
ref={(ref) => {this.messageEditArea = ref;}}
autoFocus />)}
{this.state.message || !this.state.audioEnabled ?
<a href="#" onClick={this.handleSend} title="Send">
<i className="material-icons">send</i>
</a> :
!this.state.audioRec ?
<a href="#" onClick={e => {e.preventDefault(); this.setState({audioRec: true})}} title="Voice">
<i className="material-icons">mic</i>
</a> :
null
}
<input type="file" ref={(ref) => {this.attachFile = ref;}}
onChange={this.handleAttachFile} style={{display: 'none'}} />
<input type="file" ref={(ref) => {this.attachImage = ref;}} accept="image/*"
+1 -1
View File
@@ -3,7 +3,7 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Tinode from 'tinode-sdk';
import { Tinode } from 'tinode-sdk';
import AvatarCrop from './avatar-crop.jsx';
import AvatarUpload from './avatar-upload.jsx';
+6757 -270
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
+1561 -1262
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
@@ -1,3 +1,3 @@
// This is a generated file. Don't edit.
const PACKAGE_VERSION = "0.18.2-beta1";
const PACKAGE_VERSION = "0.19.0-alpha1";
+2 -4
View File
@@ -52,8 +52,8 @@ module.exports = (env, argv) => {
]
},
performance: {
maxEntrypointSize: 294912,
maxAssetSize: 294912
maxEntrypointSize: 320000,
maxAssetSize: 320000
},
plugins: [
new CopyPlugin({
@@ -66,8 +66,6 @@ module.exports = (env, argv) => {
'react': 'React',
'react-dom': 'ReactDOM',
'react-intl': 'ReactIntl',
'firebase/app': 'firebase',
'firebase/messaging': ['firebase', 'messaging'],
'tinode-sdk': 'Tinode',
},
};