Compare commits
20
Commits
0598a42164
...
e42a7ca6de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e42a7ca6de | ||
|
|
609d3e3a9f | ||
|
|
038f62d535 | ||
|
|
37451b9aff | ||
|
|
00a1b656e4 | ||
|
|
e0c412a33c | ||
|
|
a2ca095c24 | ||
|
|
42ae953055 | ||
|
|
9c2d3f3693 | ||
|
|
9d8a1fdfc4 | ||
|
|
c6a9df9fe9 | ||
|
|
470d38e583 | ||
|
|
65dff7d168 | ||
|
|
d2d83a0865 | ||
|
|
aa498a7d1f | ||
|
|
d106f6382a | ||
|
|
cdff2ee697 | ||
|
|
d76756c67a | ||
|
|
4edb48fd24 | ||
|
|
860ae42513 |
@@ -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
@@ -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.
|
||||
*/
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -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
@@ -36,5 +36,10 @@
|
||||
"platform": "itunes",
|
||||
"url": "https://apps.apple.com/us/app/tinode/id1483763538"
|
||||
}
|
||||
]
|
||||
],
|
||||
"permissions": {
|
||||
"audio-capture": {
|
||||
"description": "Required for recording voice messages"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1484
-1011
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
+1
-1
@@ -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";
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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> :
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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/*"
|
||||
|
||||
@@ -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
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
+1561
-1262
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user