merge next
This commit is contained in:
@@ -8,7 +8,7 @@ Overall, it's a lot like open source WhatsApp or Telegram web apps.
|
||||
|
||||
Although the app is generally usable, keep in mind that this is work in progress. Some bugs probably exist, some features are missing. The app was tested in the latest Chrome & Firefox only. NPM package is [available](https://www.npmjs.com/package/tinode-webapp).
|
||||
|
||||
Try possibly newer or older version live at https://web.tinode.co/.
|
||||
Try possibly newer or older version live at https://sandbox.tinode.co/.
|
||||
|
||||
<a href="https://web.tinode.co/"><img src="web-app.svg" height=36></a>
|
||||
|
||||
@@ -44,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.
|
||||
|
||||
+192
-44
@@ -46,36 +46,6 @@ a.gray:focus {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.flat-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0.15rem 0.35rem;
|
||||
height: 2em;
|
||||
line-height: 2em;
|
||||
font-weight: normal;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
touch-action: manipulation;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.flat-button:hover,
|
||||
.flat-button:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.flat-button.faux {
|
||||
display: inline-block;
|
||||
height: 1.4em;
|
||||
line-height: 1.4em;
|
||||
text-transform: none;
|
||||
padding: 0 .35rem;
|
||||
margin: 0.15rem;
|
||||
border: 1px solid #999;
|
||||
border-radius: 3px;
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
form {
|
||||
font-size: 1.1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
@@ -147,6 +117,7 @@ button.round.small {
|
||||
line-height: 2rem;
|
||||
box-shadow: 0 2px 0.35rem 0 rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
button:focus, button:hover, button:active {
|
||||
color: #fff;
|
||||
}
|
||||
@@ -252,6 +223,7 @@ textarea {
|
||||
|
||||
textarea.inplace-edit {
|
||||
height: unset;
|
||||
font-size: unset;
|
||||
}
|
||||
|
||||
/* END of input and buttons */
|
||||
@@ -433,6 +405,7 @@ div.trusted-badge {
|
||||
font-size: 120%;
|
||||
}
|
||||
|
||||
/* Other universal style components */
|
||||
.small {
|
||||
font-size: 90%;
|
||||
}
|
||||
@@ -462,6 +435,76 @@ div.trusted-badge {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.flat-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0.15rem 0.35rem;
|
||||
height: 2em;
|
||||
line-height: 2em;
|
||||
font-weight: normal;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
touch-action: manipulation;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.flat-button:hover,
|
||||
.flat-button:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.flat-button.faux {
|
||||
display: inline-block;
|
||||
height: 1.4em;
|
||||
line-height: 1.4em;
|
||||
text-transform: none;
|
||||
padding: 0 .35rem;
|
||||
margin: 0.15rem;
|
||||
border: 1px solid #999;
|
||||
border-radius: 3px;
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: block;
|
||||
position: absolute;
|
||||
color: #999;
|
||||
font-size: 1.8rem;
|
||||
background-color: #fafafa;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
border-radius: 50%;
|
||||
width: 3.25rem;
|
||||
height: 3.25rem;
|
||||
min-width: unset;
|
||||
min-height: unset;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
outline: none; /* otherwise a square outline is shown */
|
||||
box-shadow: 0 2px 0.5rem 0 rgba(0, 0, 0, 0.25);
|
||||
transition: all 150ms;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.action-button:focus, .action-button:hover {
|
||||
color: #888;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
.action-button:active {
|
||||
color: #777;
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
.action-button.hidden {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
font-size: 1px;
|
||||
line-height: 1px;
|
||||
right: 2.625rem;
|
||||
bottom: 2.625rem;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Element which can take focus but shows no outline */
|
||||
.group-focus {
|
||||
outline: none;
|
||||
@@ -671,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;
|
||||
@@ -913,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;
|
||||
@@ -964,6 +1017,11 @@ i.search {
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.avatar.deleted {
|
||||
opacity: 0.75;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.contact-box .avatar-box > .material-icons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1044,15 +1102,28 @@ img.channel {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
font-size: 1.5rem;
|
||||
line-height: 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: #999;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: white;
|
||||
padding-left: 0.5px;
|
||||
}
|
||||
|
||||
/* END of avatar box */
|
||||
|
||||
#add-topic {
|
||||
@@ -1379,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 {
|
||||
@@ -1515,12 +1587,6 @@ img.channel {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chat-box .center {
|
||||
justify-content: center;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -1530,6 +1596,7 @@ img.channel {
|
||||
padding: 0.75rem 0.5rem 0.75rem 0.5rem;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
color: #212121;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 0.5px rgba(0, 0, 0, 0.12);
|
||||
transition: ease-out background-color 0.5s;
|
||||
@@ -1582,11 +1649,28 @@ img.channel {
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.center .bubble {
|
||||
.chat-box .meta {
|
||||
justify-content: center;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.chat-box .meta .bubble {
|
||||
color: #666;
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.meta .bubble.deleted {
|
||||
background-color: #e3f2fd;
|
||||
padding: 0.75rem 0.75rem 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.meta .bubble.date {
|
||||
font-size: smaller;
|
||||
padding: 0.5rem 0.5rem 0.5rem 0.5rem;
|
||||
background-color: rgb(244 244 244 / 66%);
|
||||
}
|
||||
|
||||
/* Left triangle placed bottom left flush. */
|
||||
.left .bubble.tip::before {
|
||||
content: '';
|
||||
@@ -1649,7 +1733,6 @@ img.channel {
|
||||
|
||||
.bubble div.message-content {
|
||||
position: relative;
|
||||
color: #212121;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
max-width: 36rem;
|
||||
@@ -1959,6 +2042,10 @@ ul.tabbar li.active > a {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.lettertile.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Colors of letter tiles */
|
||||
.lt-bg-0 {
|
||||
background-color: #ef9a9a;
|
||||
@@ -2254,7 +2341,7 @@ ul.tabbar li.active > a {
|
||||
|
||||
.chip > a {
|
||||
display: inline-block;
|
||||
color: #ccc;
|
||||
color: #eee;
|
||||
background-color: #aaa;
|
||||
border-radius: 50%;
|
||||
margin: 0.3rem;
|
||||
@@ -2268,7 +2355,7 @@ ul.tabbar li.active > a {
|
||||
.chip > a:focus,
|
||||
.chip > a:hover {
|
||||
text-decoration: none;
|
||||
color: #ddd;
|
||||
color: #fff;
|
||||
background-color: #bbb;
|
||||
}
|
||||
|
||||
@@ -2553,6 +2640,67 @@ 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 {
|
||||
width: 14rem;
|
||||
height: 2.6rem;
|
||||
margin-right: 2rem;
|
||||
}
|
||||
|
||||
.audio canvas.playback {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.audio .duration {
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
.audio-player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.audio-player .material-icons.large {
|
||||
font-size: 2.4rem;
|
||||
color: #667575;
|
||||
}
|
||||
|
||||
.audio-player .material-icons.disabled {
|
||||
color: #999;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.audio-player .playback {
|
||||
width: 14rem;
|
||||
height: 2.6rem;
|
||||
}
|
||||
|
||||
.audio-player canvas.playback {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.audio-player div.playback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #757575;
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.audio-player .timer {
|
||||
font-size: smaller;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/*
|
||||
Mobile styles.
|
||||
*/
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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
+1119
-561
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tinode-webapp",
|
||||
"description": "Tinode messenger for the web",
|
||||
"version": "0.18.3",
|
||||
"version": "0.20.0-alpha1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tinode/example-react-js.git"
|
||||
@@ -66,12 +66,13 @@
|
||||
"author": "Tinode Authors <info@tinode.co>",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"firebase": "^8.10.0",
|
||||
"firebase": "^9.6.7",
|
||||
"fix-webm-duration": "^1.0.5",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-intl": "^5.20.2",
|
||||
"react-intl-formatted-duration": "^4.0.0",
|
||||
"tinode-sdk": "^0.18.3"
|
||||
"tinode-sdk": "^0.20.0-alpha1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.14.5",
|
||||
@@ -81,7 +82,7 @@
|
||||
"babel-loader": "^8.2.2",
|
||||
"babel-plugin-formatjs": "^10.2.19",
|
||||
"browserslist": "^4.16.5",
|
||||
"copy-webpack-plugin": "^9.0.0",
|
||||
"copy-webpack-plugin": "^10.0.0",
|
||||
"cssnano": "^5.0.6",
|
||||
"glob": "^7.1.7",
|
||||
"mkdirp": "^1.0.4",
|
||||
|
||||
+19
-7
@@ -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,8 +71,10 @@ 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 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').
|
||||
@@ -78,6 +85,7 @@ fbMessaging.onBackgroundMessage((payload) => {
|
||||
}
|
||||
};
|
||||
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 {
|
||||
|
||||
+52
-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,54 @@
|
||||
"description": "Label for entering a new password",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"translation": "Nicht unterstützt",
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"preview_unavailable": {
|
||||
"translation": "nicht verfügbar",
|
||||
"defaultMessage": "unavailable",
|
||||
"description": "Message shown when media preview is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Incoming Call",
|
||||
"description": "Incoming call label",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming_title": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Incoming Call",
|
||||
"description": "Incoming call title (Incoming call view)",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_outgoing": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Outgoing Call",
|
||||
"description": "Outgoing call label",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_you_label": {
|
||||
"translation": "",
|
||||
"defaultMessage": "You",
|
||||
"description": "Shown over the local video screen",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_video_call": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Video Call",
|
||||
"description": "Start video call",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+52
-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,54 @@
|
||||
"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
|
||||
},
|
||||
"preview_unavailable": {
|
||||
"translation": "unavailable",
|
||||
"defaultMessage": "unavailable",
|
||||
"description": "Message shown when media preview is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming": {
|
||||
"translation": "Incoming Call",
|
||||
"defaultMessage": "Incoming Call",
|
||||
"description": "Incoming call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming_title": {
|
||||
"translation": "Incoming Call",
|
||||
"defaultMessage": "Incoming Call",
|
||||
"description": "Incoming call title (Incoming call view)",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_outgoing": {
|
||||
"translation": "Outgoing Call",
|
||||
"defaultMessage": "Outgoing Call",
|
||||
"description": "Outgoing call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_you_label": {
|
||||
"translation": "You",
|
||||
"defaultMessage": "You",
|
||||
"description": "Shown over the local video screen",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_video_call": {
|
||||
"translation": "Video Call",
|
||||
"defaultMessage": "Video Call",
|
||||
"description": "Start video call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+52
-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,54 @@
|
||||
"description": "Label for entering a new password",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"translation": "No soportado",
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"preview_unavailable": {
|
||||
"translation": "indisponible",
|
||||
"defaultMessage": "unavailable",
|
||||
"description": "Message shown when media preview is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Incoming Call",
|
||||
"description": "Incoming call label",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming_title": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Incoming Call",
|
||||
"description": "Incoming call title (Incoming call view)",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_outgoing": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Outgoing Call",
|
||||
"description": "Outgoing call label",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_you_label": {
|
||||
"translation": "",
|
||||
"defaultMessage": "You",
|
||||
"description": "Shown over the local video screen",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_video_call": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Video Call",
|
||||
"description": "Start video call",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,22 @@
|
||||
"defaultMessage": "Update",
|
||||
"description": "Button [Update]"
|
||||
},
|
||||
"calls_incoming": {
|
||||
"defaultMessage": "Incoming Call",
|
||||
"description": "Incoming call label"
|
||||
},
|
||||
"calls_incoming_title": {
|
||||
"defaultMessage": "Incoming Call",
|
||||
"description": "Incoming call title (Incoming call view)"
|
||||
},
|
||||
"calls_outgoing": {
|
||||
"defaultMessage": "Outgoing Call",
|
||||
"description": "Outgoing call label"
|
||||
},
|
||||
"calls_you_label": {
|
||||
"defaultMessage": "You",
|
||||
"description": "Shown over the local video screen"
|
||||
},
|
||||
"cannot_initiate_file_upload": {
|
||||
"defaultMessage": "Cannot initiate file upload.",
|
||||
"description": "Generic error messagewhen attachment fails"
|
||||
@@ -193,15 +209,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",
|
||||
@@ -476,6 +496,10 @@
|
||||
"defaultMessage": "Unmute",
|
||||
"description": "Turn notifications on"
|
||||
},
|
||||
"menu_item_video_call": {
|
||||
"defaultMessage": "Video Call",
|
||||
"description": "Start video call"
|
||||
},
|
||||
"message_sending": {
|
||||
"defaultMessage": "sending...",
|
||||
"description": "Message being sent, in place of time stamp"
|
||||
@@ -604,6 +628,10 @@
|
||||
"defaultMessage": "phone",
|
||||
"description": "Dative case of 'phone', i.e. 'phone' in 'by phone'"
|
||||
},
|
||||
"preview_unavailable": {
|
||||
"defaultMessage": "unavailable",
|
||||
"description": "Message shown when media preview is not available"
|
||||
},
|
||||
"private_editing_placeholder": {
|
||||
"defaultMessage": "Visible to you only",
|
||||
"description": "Placeholder for editing 'private'"
|
||||
|
||||
+52
-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,54 @@
|
||||
"description": "Label for entering a new password",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"translation": "지원되지 않음",
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"preview_unavailable": {
|
||||
"translation": "없는",
|
||||
"defaultMessage": "unavailable",
|
||||
"description": "Message shown when media preview is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Incoming Call",
|
||||
"description": "Incoming call label",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming_title": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Incoming Call",
|
||||
"description": "Incoming call title (Incoming call view)",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_outgoing": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Outgoing Call",
|
||||
"description": "Outgoing call label",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_you_label": {
|
||||
"translation": "",
|
||||
"defaultMessage": "You",
|
||||
"description": "Shown over the local video screen",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_video_call": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Video Call",
|
||||
"description": "Start video call",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+52
-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,54 @@
|
||||
"description": "Label for entering a new password",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"translation": "Neacceptat",
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"preview_unavailable": {
|
||||
"translation": "indisponibil",
|
||||
"defaultMessage": "unavailable",
|
||||
"description": "Message shown when media preview is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Incoming Call",
|
||||
"description": "Incoming call label",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming_title": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Incoming Call",
|
||||
"description": "Incoming call title (Incoming call view)",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_outgoing": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Outgoing Call",
|
||||
"description": "Outgoing call label",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_you_label": {
|
||||
"translation": "",
|
||||
"defaultMessage": "You",
|
||||
"description": "Shown over the local video screen",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_video_call": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Video Call",
|
||||
"description": "Start video call",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+52
-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,54 @@
|
||||
"description": "Label for entering a new password",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"translation": "Не поддерживается",
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"preview_unavailable": {
|
||||
"translation": "недоступно",
|
||||
"defaultMessage": "unavailable",
|
||||
"description": "Message shown when media preview is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Incoming Call",
|
||||
"description": "Incoming call label",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming_title": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Incoming Call",
|
||||
"description": "Incoming call title (Incoming call view)",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_outgoing": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Outgoing Call",
|
||||
"description": "Outgoing call label",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_you_label": {
|
||||
"translation": "",
|
||||
"defaultMessage": "You",
|
||||
"description": "Shown over the local video screen",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_video_call": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Video Call",
|
||||
"description": "Start video call",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+52
-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,54 @@
|
||||
"description": "Label for entering a new password",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"translation": "不支持",
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"preview_unavailable": {
|
||||
"translation": "不可用",
|
||||
"defaultMessage": "unavailable",
|
||||
"description": "Message shown when media preview is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Incoming Call",
|
||||
"description": "Incoming call label",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming_title": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Incoming Call",
|
||||
"description": "Incoming call title (Incoming call view)",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_outgoing": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Outgoing Call",
|
||||
"description": "Outgoing call label",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_you_label": {
|
||||
"translation": "",
|
||||
"defaultMessage": "You",
|
||||
"description": "Shown over the local video screen",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_video_call": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Video Call",
|
||||
"description": "Start video call",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+52
-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,54 @@
|
||||
"description": "Label for entering a new password",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"translation": "不支持",
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"preview_unavailable": {
|
||||
"translation": "不可用",
|
||||
"defaultMessage": "unavailable",
|
||||
"description": "Message shown when media preview is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Incoming Call",
|
||||
"description": "Incoming call label",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming_title": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Incoming Call",
|
||||
"description": "Incoming call title (Incoming call view)",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_outgoing": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Outgoing Call",
|
||||
"description": "Outgoing call label",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_you_label": {
|
||||
"translation": "",
|
||||
"defaultMessage": "You",
|
||||
"description": "Shown over the local video screen",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_video_call": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Video Call",
|
||||
"description": "Start video call",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+25
-1
@@ -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 = () => {
|
||||
@@ -281,3 +281,27 @@ export function base64ToBlob(str, mime) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function intArrayToBase64(arr) {
|
||||
if (!Array.isArray(arr)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
let bin = '';
|
||||
new Uint8Array(arr).forEach(b => bin += String.fromCharCode(b));
|
||||
return window.btoa(bin);
|
||||
} catch (err) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function base64ToIntArray(b64) {
|
||||
const arr = [];
|
||||
try {
|
||||
const bin = window.atob(b64);
|
||||
[...bin].forEach(c => {
|
||||
arr.push(c.charCodeAt(0));
|
||||
});
|
||||
return arr;
|
||||
} catch (err) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
+48
-59
@@ -3,33 +3,39 @@ import { defineMessages } from 'react-intl';
|
||||
|
||||
import { Drafty } from 'tinode-sdk';
|
||||
|
||||
import AudioPlayer from '../widgets/audio-player.jsx'
|
||||
import LazyImage from '../widgets/lazy-image.jsx'
|
||||
import UploadingImage from '../widgets/uploading-image.jsx'
|
||||
|
||||
import { IMAGE_THUMBNAIL_DIM, BROKEN_IMAGE_SIZE, REM_SIZE } from '../config.js';
|
||||
import { base64ToBlob, blobToBase64, fitImageSize, imageScaled } from './blob-helpers.js';
|
||||
import { idToColorClass, shortenFileName } from './strformat.js';
|
||||
import { cancelablePromise, sanitizeImageUrl } from './utils.js';
|
||||
import { idToColorClass, secondsToTime, shortenFileName } from './strformat.js';
|
||||
import { cancelablePromise, sanitizeUrlForMime } from './utils.js';
|
||||
|
||||
const messages = defineMessages({
|
||||
drafty_form: {
|
||||
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'
|
||||
}
|
||||
});
|
||||
|
||||
// Size the already scaled image.
|
||||
// Additional processing of image data.
|
||||
function handleImageData(el, data, attr) {
|
||||
if (!data) {
|
||||
attr.src = 'img/broken_image.png';
|
||||
@@ -53,7 +59,7 @@ function handleImageData(el, data, attr) {
|
||||
minHeight: dim.dstHeight + 'px'
|
||||
};
|
||||
if (!Drafty.isProcessing(data)) {
|
||||
attr.src = this.authorizeURL(sanitizeImageUrl(attr.src));
|
||||
attr.src = this.authorizeURL(sanitizeUrlForMime(attr.src, 'image'));
|
||||
attr.alt = data.name;
|
||||
if (attr.src) {
|
||||
if (Math.max(data.width || 0, data.height || 0) > IMAGE_THUMBNAIL_DIM) {
|
||||
@@ -73,50 +79,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;
|
||||
@@ -135,12 +97,27 @@ export function fullFormatter(style, data, values, key, stack) {
|
||||
}
|
||||
|
||||
let el = Drafty.tagName(style);
|
||||
const attr = Drafty.attrValue(style, data) || {};
|
||||
let attr = Drafty.attrValue(style, data) || {};
|
||||
attr.key = key;
|
||||
switch (style) {
|
||||
case 'AU':
|
||||
// Show audio player.
|
||||
if (attr.src) {
|
||||
attr.src = this.authorizeURL(sanitizeUrlForMime(attr.src, 'audio'));
|
||||
attr.duration = data.duration > 0 ? (data.duration | 0) : undefined;
|
||||
attr.preview = data.preview;
|
||||
attr.loading = 'lazy';
|
||||
}
|
||||
el = AudioPlayer;
|
||||
// Audio element cannot have content.
|
||||
values = null;
|
||||
break;
|
||||
case 'BR':
|
||||
values = null;
|
||||
break;
|
||||
case 'EX':
|
||||
// Ignore.
|
||||
break;
|
||||
case 'HL':
|
||||
// Highlighted text. Assign class name.
|
||||
attr.className = 'highlight';
|
||||
@@ -187,10 +164,16 @@ export function fullFormatter(style, data, values, key, stack) {
|
||||
attr.onClick = this.onQuoteClick;
|
||||
break;
|
||||
default:
|
||||
if (el == '_UNKN') {
|
||||
if (!el) {
|
||||
// Unknown element.
|
||||
el = React.Fragment;
|
||||
values = [<i className="material-icons gray">extension</i>, ' '].concat(values || []);
|
||||
attr = {key: key};
|
||||
// Generate comment for unknown element.
|
||||
let body = values;
|
||||
if (!Array.isArray(values) || !values.join('').trim()) {
|
||||
body = [<span key="x1" className="gray">{this.formatMessage(messages.drafty_unknown)}</span>];
|
||||
}
|
||||
values = [<i key="x0" className="material-icons gray">extension</i>, ' '].concat(body);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -213,6 +196,11 @@ export function previewFormatter(style, data, values, key) {
|
||||
let el = Drafty.tagName(style);
|
||||
const attr = { key: key };
|
||||
switch (style) {
|
||||
case 'AU':
|
||||
// Voicemail as '[mic] 0:00'.
|
||||
el = React.Fragment;
|
||||
values = [<i key="au" className="material-icons">mic</i>, ' ', secondsToTime(data.duration/1000)];
|
||||
break;
|
||||
case 'BR':
|
||||
// Replace new line with a space.
|
||||
el = React.Fragment;
|
||||
@@ -230,7 +218,7 @@ export function previewFormatter(style, data, values, key) {
|
||||
case 'IM':
|
||||
// Replace image with '[icon] Image'.
|
||||
el = React.Fragment;
|
||||
values = [<i key="im" className="material-icons">photo</i>, this.formatMessage(messages.drafty_image)];
|
||||
values = [<i key="im" className="material-icons">photo</i>, ' ', this.formatMessage(messages.drafty_image)];
|
||||
break;
|
||||
case 'BN':
|
||||
el = 'span';
|
||||
@@ -255,7 +243,7 @@ export function previewFormatter(style, data, values, key) {
|
||||
delete data.ref;
|
||||
}
|
||||
el = React.Fragment;
|
||||
values = [<i key="ex" className="material-icons">attachment</i>, this.formatMessage(messages.drafty_attachment)];
|
||||
values = [<i key="ex" className="material-icons">attachment</i>, ' ', this.formatMessage(messages.drafty_attachment)];
|
||||
break;
|
||||
case 'QQ':
|
||||
case 'HD':
|
||||
@@ -263,9 +251,10 @@ export function previewFormatter(style, data, values, key) {
|
||||
values = null;
|
||||
break;
|
||||
default:
|
||||
if (el == '_UNKN') {
|
||||
if (!el) {
|
||||
// Unknown element.
|
||||
el = React.Fragment;
|
||||
values = [<i key="unkn" className="material-icons">extension</i>, ' '].concat(values || []);
|
||||
values = [<i key="x0" className="material-icons gray">extension</i>, ' ', this.formatMessage(messages.drafty_unknown)];
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -354,7 +343,7 @@ function quoteImage(data) {
|
||||
}
|
||||
promise = Promise.resolve(blob);
|
||||
} else if (data.ref) {
|
||||
promise = fetch(this.authorizeURL(sanitizeImageUrl(data.ref)))
|
||||
promise = fetch(this.authorizeURL(sanitizeUrlForMime(data.ref, 'image')))
|
||||
.then(evt => {
|
||||
if (evt.ok) {
|
||||
return evt.blob();
|
||||
|
||||
@@ -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) | 0;
|
||||
if (fixedMin) {
|
||||
min = min < 10 ? `0${min}` : min;
|
||||
}
|
||||
let sec = (seconds % 60) | 0;
|
||||
sec = sec < 10 ? `0${sec}` : sec;
|
||||
return `${min}:${sec}`;
|
||||
}
|
||||
|
||||
+7
-6
@@ -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) {
|
||||
@@ -144,10 +144,10 @@ export function sanitizeUrl(url, allowedSchemes) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// Ensure URL is suitable for <img src="url"> field: the URL must be a relative URL or
|
||||
// Ensure URL is suitable as a source like <img src="url"> field: the URL must be a relative URL or
|
||||
// have http:, https:, blob: or data: scheme.
|
||||
// In case of data: scheme, the URL must start with a 'data:image/XXXX;base64,'.
|
||||
export function sanitizeImageUrl(url) {
|
||||
// In case of data: scheme, the URL must must be of the right MIME type such as 'data:{mimeMajor}/XXXX;base64,'.
|
||||
export function sanitizeUrlForMime(url, mimeMajor) {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
@@ -157,8 +157,9 @@ export function sanitizeImageUrl(url) {
|
||||
return sanitizedUrl;
|
||||
}
|
||||
|
||||
// Is this a data: URL of an image?
|
||||
if (/data:image\/[a-z0-9.-]+;base64,/i.test(url.trim())) {
|
||||
// Is this a data: URL of the appropriate mime type?
|
||||
const re = new RegExp(`data:${mimeMajor}\/[a-z0-9.-]+;base64,`, 'i');
|
||||
if (re.test(url.trim())) {
|
||||
return url;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,3 +1,3 @@
|
||||
// This is a generated file. Don't edit.
|
||||
|
||||
export const PACKAGE_VERSION = "0.18.3";
|
||||
export const PACKAGE_VERSION = "0.20.0-alpha1";
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
+140
-30
@@ -1,9 +1,11 @@
|
||||
// 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 CallPanel from '../widgets/call-panel.jsx';
|
||||
import ChatMessage from '../widgets/chat-message.jsx';
|
||||
import ContactBadges from '../widgets/contact-badges.jsx';
|
||||
import DocPreview from '../widgets/doc-preview.jsx';
|
||||
@@ -14,20 +16,20 @@ import Invitation from '../widgets/invitation.jsx';
|
||||
import LetterTile from '../widgets/letter-tile.jsx';
|
||||
import LoadSpinner from '../widgets/load-spinner.jsx';
|
||||
import LogoView from './logo-view.jsx';
|
||||
import CallPanel from '../widgets/call-panel.jsx';
|
||||
import MetaMessage from '../widgets/meta-message.jsx';
|
||||
import SendMessage from '../widgets/send-message.jsx';
|
||||
|
||||
import { DEFAULT_P2P_ACCESS_MODE, IMAGE_PREVIEW_DIM, KEYPRESS_DELAY,
|
||||
MESSAGES_PAGE, MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE,
|
||||
READ_DELAY, QUOTED_REPLY_LENGTH,
|
||||
CALL_STATE_OUTGOING_INITATED, CALL_STATE_IN_PROGRESS } from '../config.js';
|
||||
import { blobToBase64, fileToBase64,
|
||||
imageScaled, makeImageUrl } from '../lib/blob-helpers.js';
|
||||
READ_DELAY, QUOTED_REPLY_LENGTH, CALL_STATE_OUTGOING_INITATED, CALL_STATE_IN_PROGRESS } from '../config.js';
|
||||
import { blobToBase64, fileToBase64, imageScaled, makeImageUrl } from '../lib/blob-helpers.js';
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import { bytesToHumanSize, shortDateFormat } from '../lib/strformat.js';
|
||||
|
||||
// Run timer with this frequency (ms) for checking notification queue.
|
||||
const NOTIFICATION_EXEC_INTERVAL = 300;
|
||||
// Scroll distance before [go to latest message] button is shown.
|
||||
const SHOW_GO_TO_LAST_DIST = 100;
|
||||
|
||||
const messages = defineMessages({
|
||||
online_now: {
|
||||
@@ -97,6 +99,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);
|
||||
@@ -118,6 +121,7 @@ class MessagesView extends React.Component {
|
||||
this.handleCancelUpload = this.handleCancelUpload.bind(this);
|
||||
this.postReadNotification = this.postReadNotification.bind(this);
|
||||
this.clearNotificationQueue = this.clearNotificationQueue.bind(this);
|
||||
this.goToLatestMessage = this.goToLatestMessage.bind(this);
|
||||
|
||||
this.handlePickReply = this.handlePickReply.bind(this);
|
||||
this.handleCancelReply = this.handleCancelReply.bind(this);
|
||||
@@ -158,13 +162,13 @@ class MessagesView extends React.Component {
|
||||
// Scroll last message into view on component update e.g. on message received
|
||||
// or vertical shrinking.
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (this.messagesScroller) {
|
||||
if (prevState.topic != this.state.topic || prevState.messageCount != this.state.messageCount) {
|
||||
// New message
|
||||
this.messagesScroller.scrollTop = this.messagesScroller.scrollHeight - this.state.scrollPosition;
|
||||
} else if (prevProps.viewportHeight > this.props.viewportHeight) {
|
||||
// Componet changed height.
|
||||
this.messagesScroller.scrollTop += prevProps.viewportHeight - this.props.viewportHeight;
|
||||
if (this.messagesScroller &&
|
||||
(prevState.topic != this.state.topic || prevState.messageCount != this.state.messageCount)) {
|
||||
// New message.
|
||||
if (this.state.scrollPosition < SHOW_GO_TO_LAST_DIST) {
|
||||
this.messagesScroller.scrollTop = this.messagesScroller.scrollHeight -
|
||||
this.state.scrollPosition -
|
||||
this.messagesScroller.offsetHeight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,10 +200,15 @@ class MessagesView extends React.Component {
|
||||
this.postReadNotification(0);
|
||||
}
|
||||
|
||||
if ((this.state.topic != prevState.topic) || !prevProps.ready) {
|
||||
if (topic && ((this.state.topic != prevState.topic) || !prevProps.ready)) {
|
||||
// Don't immediately subscribe to a new p2p topic, wait for the first message.
|
||||
if (topic._new && topic.isP2PType()) {
|
||||
topic.getMeta(topic.startMetaQuery().withDesc().build());
|
||||
} else {
|
||||
this.subscribe(topic);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(nextProps, prevState) {
|
||||
let nextState = {};
|
||||
@@ -215,6 +224,7 @@ class MessagesView extends React.Component {
|
||||
isVerified: false,
|
||||
isStaff: false,
|
||||
isDangerous: false,
|
||||
deleted: false,
|
||||
docPreview: null,
|
||||
imagePreview: null,
|
||||
imagePostview: null,
|
||||
@@ -224,7 +234,8 @@ class MessagesView extends React.Component {
|
||||
fetchingMessages: false,
|
||||
peerMessagingDisabled: false,
|
||||
channel: false,
|
||||
reply: null
|
||||
reply: null,
|
||||
showGoToLastButton: false
|
||||
};
|
||||
} else if (nextProps.topic != prevState.topic) {
|
||||
const topic = nextProps.tinode.getTopic(nextProps.topic);
|
||||
@@ -248,7 +259,9 @@ class MessagesView extends React.Component {
|
||||
typingIndicator: false,
|
||||
scrollPosition: 0,
|
||||
fetchingMessages: false,
|
||||
reply: reply
|
||||
reply: reply,
|
||||
showGoToLastButton: false,
|
||||
deleted: topic._deleted
|
||||
};
|
||||
|
||||
if (topic) {
|
||||
@@ -363,7 +376,7 @@ class MessagesView extends React.Component {
|
||||
}
|
||||
|
||||
subscribe(topic) {
|
||||
if (!topic || topic.isSubscribed() || !this.props.ready) {
|
||||
if (topic.isSubscribed() || !this.props.ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -397,13 +410,16 @@ class MessagesView extends React.Component {
|
||||
this.props.onNewTopicCreated(this.props.topic, ctrl.topic);
|
||||
// If there are unsent messages, try sending them now.
|
||||
topic.queuedMessages((pub) => {
|
||||
if (!pub._sending && topic.isSubscribed()) {
|
||||
if (pub._sending) {
|
||||
return;
|
||||
}
|
||||
if (topic.isSubscribed()) {
|
||||
this.retrySend(pub);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed subscription to", this.state.topic);
|
||||
console.error("Failed subscription to", this.state.topic, err);
|
||||
this.props.onError(err.message, 'err');
|
||||
const blankState = MessagesView.getDerivedStateFromProps({}, {});
|
||||
blankState.title = this.props.intl.formatMessage(messages.not_found);
|
||||
@@ -434,6 +450,7 @@ class MessagesView extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
// Don't use React.createRef as the ref.current is not available in componentDidMount in this component.
|
||||
handleScrollReference(node) {
|
||||
if (node) {
|
||||
node.addEventListener('scroll', this.handleScrollEvent);
|
||||
@@ -442,9 +459,14 @@ class MessagesView extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
// Get older messages
|
||||
// Get older messages and show/hide [go to latest message] button.
|
||||
handleScrollEvent(event) {
|
||||
this.setState({scrollPosition: event.target.scrollHeight - event.target.scrollTop});
|
||||
const pos = event.target.scrollHeight - event.target.scrollTop - event.target.offsetHeight;
|
||||
this.setState({
|
||||
scrollPosition: pos,
|
||||
showGoToLastButton: pos > SHOW_GO_TO_LAST_DIST,
|
||||
});
|
||||
|
||||
if (this.state.fetchingMessages) {
|
||||
return;
|
||||
}
|
||||
@@ -461,6 +483,13 @@ class MessagesView extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
goToLatestMessage() {
|
||||
this.setState({scrollPosition: 0});
|
||||
if (this.messagesScroller) {
|
||||
this.messagesScroller.scrollTop = this.messagesScroller.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
handleDescChange(desc) {
|
||||
if (desc.public) {
|
||||
this.setState({
|
||||
@@ -586,8 +615,12 @@ class MessagesView extends React.Component {
|
||||
// Scroll to the bottom if the message is added to the end of the message list.
|
||||
// TODO: This should be replaced by showing a "scroll to bottom" button.
|
||||
if (topic.isNewMessage(msg.seq)) {
|
||||
if (this.state.scrollPosition > SHOW_GO_TO_LAST_DIST) {
|
||||
this.setState({showGoToLastButton: true});
|
||||
} else {
|
||||
this.setState({scrollPosition: 0});
|
||||
}
|
||||
}
|
||||
|
||||
// Aknowledge messages except own messages. They are
|
||||
// automatically assumed to be read and recived.
|
||||
@@ -744,6 +777,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,
|
||||
@@ -761,7 +798,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);
|
||||
|
||||
@@ -854,7 +891,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);
|
||||
|
||||
@@ -875,6 +912,47 @@ class MessagesView extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
// sendAudioAttachment sends audio bits inband as Drafty message (no preview).
|
||||
sendAudioAttachment(url, preview, duration) {
|
||||
fetch(url)
|
||||
.then(result => result.blob())
|
||||
.then(blob => {
|
||||
// Server-provided limit reduced for base64 encoding and overhead.
|
||||
const maxInbandAttachmentSize = this.props.tinode.getServerLimit('maxMessageSize', MAX_INBAND_ATTACHMENT_SIZE) * 0.75 - 1024;
|
||||
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,
|
||||
preview: preview,
|
||||
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,
|
||||
size: blob.size,
|
||||
data: b64.bits,
|
||||
duration: duration,
|
||||
preview: preview,
|
||||
}))
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(err => {this.props.onError(err)});;
|
||||
}
|
||||
|
||||
handleCancelUpload(seq, uploader) {
|
||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||
const found = topic.findMessage(seq);
|
||||
@@ -1009,9 +1087,12 @@ class MessagesView extends React.Component {
|
||||
icon_badges.push({icon: 'dangerous', color: 'badge-inv'});
|
||||
}
|
||||
}
|
||||
let messageNodes = [];
|
||||
|
||||
const messageNodes = [];
|
||||
let previousFrom = null;
|
||||
let prevDate = null;
|
||||
let chatBoxClass = null;
|
||||
const dateFmt = new Intl.DateTimeFormat(this.props.intl.locale);
|
||||
topic.messages((msg, prev, next, i) => {
|
||||
let nextFrom = next ? (next.from || 'chan') : null;
|
||||
|
||||
@@ -1046,11 +1127,29 @@ class MessagesView extends React.Component {
|
||||
replyToSeq = null;
|
||||
}
|
||||
|
||||
if (msg.hi) {
|
||||
// Deleted message.
|
||||
messageNodes.push(
|
||||
<MetaMessage
|
||||
deleted={true}
|
||||
key={msg.seq} />
|
||||
);
|
||||
} else {
|
||||
const thisDate = new Date(msg.ts);
|
||||
// This message was sent on a different date than the previous.
|
||||
if (!prevDate || prevDate.toDateString() != thisDate.toDateString()) {
|
||||
messageNodes.push(
|
||||
<MetaMessage
|
||||
date={dateFmt.format(msg.ts)}
|
||||
locale={this.props.intl.locale}
|
||||
key={'date-' + msg.seq} />
|
||||
);
|
||||
prevDate = thisDate;
|
||||
}
|
||||
messageNodes.push(
|
||||
<ChatMessage
|
||||
tinode={this.props.tinode}
|
||||
content={msg.content}
|
||||
deleted={msg.hi}
|
||||
mimeType={msg.head ? msg.head.mime : null}
|
||||
timestamp={msg.ts}
|
||||
duration={msg.origTs ? msg.ts - msg.origTs : null}
|
||||
@@ -1077,6 +1176,7 @@ class MessagesView extends React.Component {
|
||||
userIsWriter={this.state.isWriter}
|
||||
key={msg.seq} />
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let lastSeen = null;
|
||||
@@ -1095,7 +1195,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 = (
|
||||
<>
|
||||
@@ -1111,11 +1214,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>
|
||||
@@ -1142,6 +1246,10 @@ class MessagesView extends React.Component {
|
||||
: null}
|
||||
<LoadSpinner show={this.state.fetchingMessages} />
|
||||
<div id="messages-container">
|
||||
<button className={'action-button' + (this.state.showGoToLastButton ? '' : ' hidden')}
|
||||
onClick={this.goToLatestMessage}>
|
||||
<i className="material-icons">arrow_downward</i>
|
||||
</button>
|
||||
<div id="messages-panel" ref={this.handleScrollReference}>
|
||||
<ul id="scroller" className={chatBoxClass}>
|
||||
{messageNodes}
|
||||
@@ -1171,12 +1279,14 @@ class MessagesView extends React.Component {
|
||||
:
|
||||
<SendMessage
|
||||
tinode={this.props.tinode}
|
||||
topicName={this.state.topic}
|
||||
noInput={!!this.props.forwardMessage}
|
||||
disabled={!this.state.isWriter}
|
||||
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.props.forwardMessage ? null : 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>
|
||||
);
|
||||
|
||||
+125
-111
@@ -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';
|
||||
@@ -122,8 +123,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);
|
||||
@@ -185,9 +185,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,
|
||||
|
||||
@@ -286,12 +288,8 @@ 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);
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
// do nothing: handled earlier.
|
||||
// catch needed to pervent unnecessary logging of error.
|
||||
@@ -346,11 +344,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)) {
|
||||
// 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(data.topic, data.seq, 'fake-uid');
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,40 +368,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({
|
||||
@@ -443,14 +489,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
|
||||
@@ -900,7 +942,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) {
|
||||
@@ -916,7 +958,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),
|
||||
@@ -953,14 +995,15 @@ class TinodeWeb extends React.Component {
|
||||
// with attachments.
|
||||
// - msg - Drafty message with content
|
||||
// - promise - Promise to be resolved when the upload is completed
|
||||
// - uploader - for tracking progress
|
||||
// - uploadCompletionPromise - for tracking progress
|
||||
// - head - head dictionary to be attached to the message
|
||||
handleSendMessage(msg, promise, uploader, head) {
|
||||
handleSendMessage(msg, uploadCompletionPromise, uploader, head) {
|
||||
const topic = this.tinode.getTopic(this.state.topicSelected);
|
||||
return this.sendMessageToTopic(topic, msg, promise, uploader, head);
|
||||
/* TODO: check if return is required */
|
||||
return this.sendMessageToTopic(topic, msg, uploadCompletionPromise, uploader, head);
|
||||
}
|
||||
|
||||
sendMessageToTopic(topic, msg, promise, uploader, head) {
|
||||
sendMessageToTopic(topic, msg, uploadCompletionPromise, uploader, head) {
|
||||
msg = topic.createMessage(msg, false);
|
||||
// The uploader is used to show progress.
|
||||
msg._uploader = uploader;
|
||||
@@ -969,33 +1012,34 @@ class TinodeWeb extends React.Component {
|
||||
msg.head = Object.assign(msg.head || {}, head);
|
||||
}
|
||||
|
||||
const completion = [];
|
||||
if (uploadCompletionPromise) {
|
||||
completion.push(uploadCompletionPromise);
|
||||
}
|
||||
|
||||
if (!topic.isSubscribed()) {
|
||||
// Topic is not subscribed yet. Subscribe.
|
||||
if (!promise) {
|
||||
promise = Promise.resolve();
|
||||
}
|
||||
promise = promise
|
||||
.then(() => topic.subscribe())
|
||||
const subscribePromise =
|
||||
topic.subscribe()
|
||||
.then(() => {
|
||||
// If there are unsent messages, try sending them now.
|
||||
topic.queuedMessages((pub) => {
|
||||
if (!pub._sending && topic.isSubscribed()) {
|
||||
this.sendMessage(pub);
|
||||
topic.queuedMessages(pub => {
|
||||
if (pub._sending || pub.seq == msg.seq) {
|
||||
return;
|
||||
}
|
||||
if (topic.isSubscribed()) {
|
||||
topic.publishMessage(pub);
|
||||
}
|
||||
});
|
||||
});
|
||||
completion.push(subscribePromise);
|
||||
}
|
||||
|
||||
if (promise) {
|
||||
promise = promise.catch((err) => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
}
|
||||
|
||||
return topic.publishDraft(msg, promise)
|
||||
.then((ctrl) => {
|
||||
// TODO: check if return is required.
|
||||
return topic.publishDraft(msg, Promise.all(completion))
|
||||
.then(_ => {
|
||||
if (topic.isArchived()) {
|
||||
return topic.archive(false);
|
||||
topic.archive(false);
|
||||
}
|
||||
return ctrl;
|
||||
})
|
||||
@@ -1137,31 +1181,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});
|
||||
});
|
||||
} 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});
|
||||
@@ -1176,25 +1208,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', {
|
||||
@@ -1383,29 +1396,34 @@ 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());
|
||||
|
||||
cleared.then(() => {
|
||||
this.tinode = TinodeWeb.tnSetup(this.state.serverAddress,
|
||||
this.state.transport, this.props.intl.locale, LocalStorageUtil.getObject('keep-logged-in'));
|
||||
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;
|
||||
this.tinode.onInfoMessage = this.handleInfoMessage;
|
||||
HashNavigation.navigateTo('');
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
handleDeleteAccount() {
|
||||
this.tinode.delCurrentUser(true).then((ctrl) => {
|
||||
this.tinode.delCurrentUser(true).then(_ => {
|
||||
this.handleLogout();
|
||||
});
|
||||
}
|
||||
@@ -1500,15 +1518,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, true);
|
||||
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, true);
|
||||
const msg = Drafty.append(Drafty.appendLineBreak(Drafty.mention(header, params.userFrom)), content);
|
||||
const msgPreview = Drafty.quote(header, params.userFrom, preview);
|
||||
|
||||
const msgPreview = Tinode.Drafty.quote(header, params.userFrom, preview);
|
||||
const head = {
|
||||
forwarded: params.topicName + ':' + params.seq
|
||||
};
|
||||
@@ -1911,7 +1925,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,265 @@
|
||||
// Audio recorder widget.
|
||||
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { secondsToTime } from '../lib/strformat';
|
||||
import { base64ToIntArray } from '../lib/blob-helpers';
|
||||
|
||||
// Make canvas bigger than the element size to reduce blurring.
|
||||
const CANVAS_UPSCALING = 2.0;
|
||||
// Thickness of a visualization bar.
|
||||
const LINE_WIDTH = 3 * CANVAS_UPSCALING;
|
||||
// Spacing between two visualization bars.
|
||||
const SPACING = 2 * CANVAS_UPSCALING;
|
||||
// Color of histogram bars.
|
||||
const BAR_COLOR = '#bbb';
|
||||
const BAR_COLOR_DARK = '#888';
|
||||
const THUMB_COLOR = '#666';
|
||||
// Minimum number of amplitude bars to draw.
|
||||
const MIN_PREVIEW_LENGTH = 16;
|
||||
|
||||
export default class AudioPlayer extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
let preview = base64ToIntArray(this.props.preview);
|
||||
if (Array.isArray(preview) && preview.length < MIN_PREVIEW_LENGTH) {
|
||||
preview = null;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
canPlay: false,
|
||||
playing: false,
|
||||
currentTime: '0:00',
|
||||
duration: this.props.duration > 0 ? secondsToTime(this.props.duration / 1000) : '-:--',
|
||||
longMin: this.props.duration >= 600000,
|
||||
preview: preview
|
||||
};
|
||||
|
||||
this.initAudio = this.initAudio.bind(this);
|
||||
this.initCanvas = this.initCanvas.bind(this);
|
||||
this.resampleBars = this.resampleBars.bind(this);
|
||||
this.visualize = this.visualize.bind(this);
|
||||
|
||||
this.handlePlay = this.handlePlay.bind(this);
|
||||
this.handleSeek = this.handleSeek.bind(this);
|
||||
this.handleError = this.handleError.bind(this);
|
||||
|
||||
this.audioPlayer = null;
|
||||
|
||||
this.viewBuffer = [];
|
||||
this.canvasRef = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.src) {
|
||||
this.initAudio();
|
||||
}
|
||||
|
||||
if (this.state.preview) {
|
||||
this.initCanvas();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.audioPlayer) {
|
||||
this.audioPlayer.oncanplay = null;
|
||||
this.audioPlayer.ontimeupdate = null;
|
||||
this.audioPlayer.onended = null;
|
||||
this.audioPlayer.pause();
|
||||
this.audioPlayer = null;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.src != prevProps.src) {
|
||||
this.initAudio();
|
||||
}
|
||||
|
||||
if (this.props.preview != prevProps.preview) {
|
||||
let preview = base64ToIntArray(this.props.preview);
|
||||
if (Array.isArray(preview) && preview.length < MIN_PREVIEW_LENGTH) {
|
||||
preview = null;
|
||||
}
|
||||
this.setState({preview: preview}, this.initCanvas);
|
||||
}
|
||||
}
|
||||
|
||||
initAudio() {
|
||||
this.audioPlayer = new Audio(this.props.src);
|
||||
this.audioPlayer.oncanplay = _ => this.setState({canPlay: true});
|
||||
this.audioPlayer.ontimeupdate = _ => this.setState({
|
||||
currentTime: secondsToTime(this.audioPlayer.currentTime, this.state.longMin)
|
||||
});
|
||||
this.audioPlayer.onended = _ => {
|
||||
this.audioPlayer.currentTime = 0;
|
||||
this.setState({playing: false, currentTime: secondsToTime(0, this.state.longMin)})
|
||||
}
|
||||
}
|
||||
|
||||
initCanvas() {
|
||||
// Force canvas aspect ration to match one of the element + upscale canvas to reduce blurring.
|
||||
this.canvasRef.current.width = this.canvasRef.current.offsetWidth * CANVAS_UPSCALING;
|
||||
this.canvasRef.current.height = this.canvasRef.current.offsetHeight * CANVAS_UPSCALING;
|
||||
|
||||
this.canvasContext = this.canvasRef.current.getContext('2d');
|
||||
this.canvasContext.lineCap = 'round';
|
||||
|
||||
this.viewBuffer = this.resampleBars(this.state.preview);
|
||||
this.visualize();
|
||||
}
|
||||
|
||||
// Draw amplitude of sound.
|
||||
visualize() {
|
||||
if (!this.canvasRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = this.effectiveWidth;
|
||||
const height = this.canvasRef.current.height;
|
||||
|
||||
this.canvasContext.lineWidth = LINE_WIDTH;
|
||||
|
||||
const drawFrame = () => {
|
||||
if (!this.canvasRef.current) {
|
||||
// The component is unmounted.
|
||||
return;
|
||||
}
|
||||
|
||||
this.canvasContext.clearRect(0, 0, this.canvasRef.current.width, height);
|
||||
|
||||
if (this.viewBuffer) {
|
||||
if (this.state.playing) {
|
||||
window.requestAnimationFrame(drawFrame);
|
||||
}
|
||||
|
||||
// Current pplayback position.
|
||||
const thumbAt = this.props.duration ?
|
||||
Math.max(0, Math.min(this.audioPlayer.currentTime * 1000 / this.props.duration, 1)) * (width - LINE_WIDTH * 2) : -1;
|
||||
|
||||
// Draw amplitude bars.
|
||||
this.canvasContext.beginPath();
|
||||
this.canvasContext.strokeStyle = BAR_COLOR_DARK;
|
||||
for (let i = 0; i < this.viewBuffer.length; i++) {
|
||||
let x = 1 + i * (LINE_WIDTH + SPACING) + LINE_WIDTH * 0.5;
|
||||
let y = this.viewBuffer[i] * height * 0.9;
|
||||
|
||||
const color = x < thumbAt ? BAR_COLOR_DARK : BAR_COLOR;
|
||||
if (this.canvasContext.strokeStyle != color) {
|
||||
this.canvasContext.stroke();
|
||||
this.canvasContext.beginPath();
|
||||
this.canvasContext.strokeStyle = color;
|
||||
}
|
||||
|
||||
this.canvasContext.moveTo(x, (height - y) * 0.5);
|
||||
this.canvasContext.lineTo(x, height * 0.5 + y * 0.5);
|
||||
}
|
||||
// Actually draw the bars on canvas.
|
||||
this.canvasContext.stroke();
|
||||
|
||||
// Draw thumb.
|
||||
if (this.props.duration) {
|
||||
this.canvasContext.beginPath();
|
||||
this.canvasContext.arc(thumbAt + LINE_WIDTH * 2, height * 0.5, LINE_WIDTH * 2, 0, 2 * Math.PI);
|
||||
this.canvasContext.fillStyle = THUMB_COLOR;
|
||||
this.canvasContext.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawFrame();
|
||||
}
|
||||
|
||||
// Quick and dirty downsampling of the original preview bars into a smaller (or equal) number of bars we can display here.
|
||||
resampleBars(original) {
|
||||
const barCount = Math.min(original.length, ((this.canvasRef.current.width - SPACING) / (LINE_WIDTH + SPACING)) | 0);
|
||||
const factor = original.length / barCount;
|
||||
let amps = [];
|
||||
let max = -1;
|
||||
for (let i=0;i<barCount;i++) {
|
||||
let lo = (i * factor) | 0; // low bound;
|
||||
let hi = ((i + 1) * factor) | 0; // high bound;
|
||||
let amp = 0.0;
|
||||
for (let j=lo; j<hi; j++) {
|
||||
amp += original[j]
|
||||
}
|
||||
amps[i] = Math.max(0, amp / (hi - lo));
|
||||
max = Math.max(amps[i], max);
|
||||
}
|
||||
|
||||
if (max > 0) {
|
||||
// Remove extra padding on the right due to fractional bar which is not drawn.
|
||||
this.effectiveWidth = barCount * (LINE_WIDTH + SPACING) + SPACING;
|
||||
return amps.map(a => a / max);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
handlePlay(e) {
|
||||
e.preventDefault();
|
||||
if (!this.state.canPlay) {
|
||||
return;
|
||||
}
|
||||
if (this.state.playing) {
|
||||
this.audioPlayer.pause();
|
||||
this.setState({playing: false});
|
||||
} else {
|
||||
this.audioPlayer.play();
|
||||
this.setState({playing: true}, this.visualize);
|
||||
}
|
||||
}
|
||||
|
||||
handleError(err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
handleSeek(e) {
|
||||
e.preventDefault();
|
||||
if (e.target && this.props.duration) {
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
const offset = (e.clientX - rect.left) / this.effectiveWidth * CANVAS_UPSCALING;
|
||||
this.audioPlayer.currentTime = this.props.duration * offset / 1000;
|
||||
this.setState({currentTime: secondsToTime(this.audioPlayer.currentTime, this.state.longMin)});
|
||||
if (!this.state.playing && this.state.preview) {
|
||||
this.visualize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const playClass = 'material-icons' +
|
||||
(this.props.short ? '' : ' large') +
|
||||
(this.state.canPlay ? '' : ' disabled');
|
||||
const play = (<a href="#" onClick={this.handlePlay} title="Play">
|
||||
<i className={playClass}>{this.state.playing ? 'pause_circle' :
|
||||
(this.state.canPlay ? 'play_circle' : 'not_interested')}</i>
|
||||
</a>);
|
||||
return (<div className="audio-player">{this.props.short ?
|
||||
<>
|
||||
<div>
|
||||
{this.state.preview ?
|
||||
<canvas className="playback" ref={this.canvasRef} onClick={this.handleSeek} /> :
|
||||
<div className="playback"> - </div>
|
||||
}
|
||||
</div>
|
||||
{play}
|
||||
</>
|
||||
:
|
||||
<>
|
||||
{play}
|
||||
<div>
|
||||
{this.state.preview ?
|
||||
<canvas className="playback" ref={this.canvasRef} onClick={this.handleSeek} /> :
|
||||
<div className="playback">
|
||||
<FormattedMessage
|
||||
id="preview_unavailable" defaultMessage="unavailable" description="Message shown when media preview is not available" />
|
||||
</div>
|
||||
}
|
||||
<div className="timer">{this.state.currentTime}/{this.state.duration}</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// Audio recorder widget.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import AudioPlayer from './audio-player.jsx';
|
||||
// Workaround for https://bugs.chromium.org/p/chromium/issues/detail?id=642012
|
||||
import fixWebmDuration from 'fix-webm-duration';
|
||||
|
||||
import { intArrayToBase64 } from '../lib/blob-helpers.js'
|
||||
import { secondsToTime } from '../lib/strformat';
|
||||
|
||||
// FFT resolution.
|
||||
const BUFFER_SIZE = 256;
|
||||
// Make canvas bigger than the element size to reduce blurring.
|
||||
const CANVAS_UPSCALING = 2.0;
|
||||
// Thickness of a visualization bar.
|
||||
const LINE_WIDTH = 3 * CANVAS_UPSCALING;
|
||||
// Spacing between two visualization bars.
|
||||
const SPACING = 2 * CANVAS_UPSCALING;
|
||||
// Duration represented by one visualization bar.
|
||||
const MILLIS_PER_BAR = 100;
|
||||
// Color of histogram bars
|
||||
const BAR_COLOR = '#bbb';
|
||||
// Vertical scaling for visualization bars.
|
||||
const BAR_SCALE = 64.0;
|
||||
// Minimum duration of a recording in milliseconds.
|
||||
const MIN_DURATION = 200;
|
||||
// Maximum duration of a recording in milliseconds (10 min).
|
||||
const MAX_DURATION = 600000;
|
||||
// Number of bars in preview.
|
||||
const VISUALIZATION_BARS = 96;
|
||||
// Maximum number of samples per bar.
|
||||
const MAX_SAMPLES_PER_BAR = 10;
|
||||
|
||||
// Recording format.
|
||||
const AUDIO_MIME_TYPE = 'audio/webm';
|
||||
|
||||
export default class AudioRecorder extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
enabled: true,
|
||||
audioRecord: null,
|
||||
recording: true,
|
||||
paused: false,
|
||||
duration: '0:00',
|
||||
blobUrl: null,
|
||||
preview: null
|
||||
};
|
||||
|
||||
this.visualize = this.visualize.bind(this);
|
||||
this.initMediaRecording = this.initMediaRecording.bind(this);
|
||||
this.initCanvas = this.initCanvas.bind(this);
|
||||
this.getRecording = this.getRecording.bind(this);
|
||||
this.cleanUp = this.cleanUp.bind(this);
|
||||
|
||||
this.handleResume = this.handleResume.bind(this);
|
||||
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.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);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.startedOn = null;
|
||||
if (this.stream) {
|
||||
this.cleanUp();
|
||||
}
|
||||
}
|
||||
|
||||
// Draw amplitude of sound.
|
||||
visualize() {
|
||||
this.initCanvas();
|
||||
|
||||
const pcmData = new Uint8Array(this.analyser.frequencyBinCount);
|
||||
const width = this.canvasWidth;
|
||||
const height = this.canvasHeight;
|
||||
// Number of bars.
|
||||
const viewLength = (width / (LINE_WIDTH + SPACING)) | 0;
|
||||
// Duration of audio which fits onto the screen.
|
||||
const viewDuration = MILLIS_PER_BAR * viewLength;
|
||||
|
||||
this.canvasContext.lineWidth = LINE_WIDTH;
|
||||
this.canvasContext.strokeStyle = BAR_COLOR;
|
||||
|
||||
let prevBarCount = 0;
|
||||
let volume = 0.0;
|
||||
let countPerBar = 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.startedOn = null;
|
||||
this.mediaRecorder.pause();
|
||||
this.durationMillis += Date.now() - this.startedOn;
|
||||
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 amp = 0.0;
|
||||
for (const amplitude of pcmData) {
|
||||
amp += (amplitude - 127) ** 2;
|
||||
}
|
||||
|
||||
// Sum the amplitude.
|
||||
volume += Math.sqrt(amp/pcmData.length);
|
||||
countPerBar ++;
|
||||
|
||||
let barCount = (duration / MILLIS_PER_BAR) | 0;
|
||||
// Shift of the histogram along x-axis to make scrolling smooth. No need to shift if recording is too short.
|
||||
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/countPerBar);
|
||||
volume = 0.0;
|
||||
countPerBar = 0;
|
||||
if (this.viewBuffer.length > viewLength) {
|
||||
// Keep at most 'viewLength' amplitude bars.
|
||||
this.viewBuffer.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Clear canvas.
|
||||
this.canvasContext.clearRect(0, 0, width, height);
|
||||
|
||||
// Draw amplitude bars.
|
||||
this.canvasContext.beginPath();
|
||||
for (let i = 0; i < this.viewBuffer.length; i++) {
|
||||
let x = i * (LINE_WIDTH + SPACING) - dx;
|
||||
let y = Math.min(this.viewBuffer[i] / BAR_SCALE, 0.9) * height;
|
||||
|
||||
this.canvasContext.moveTo(x, (height - y) * 0.5);
|
||||
this.canvasContext.lineTo(x, height * 0.5 + y * 0.5);
|
||||
}
|
||||
// Actually draw the bars on canvas.
|
||||
this.canvasContext.stroke();
|
||||
}
|
||||
|
||||
drawFrame();
|
||||
}
|
||||
|
||||
handlePause(e) {
|
||||
e.preventDefault();
|
||||
this.mediaRecorder.pause();
|
||||
this.mediaRecorder.requestData();
|
||||
this.durationMillis += Date.now() - this.startedOn;
|
||||
this.startedOn = null;
|
||||
this.setState({recording: false});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
initCanvas() {
|
||||
this.canvasRef.current.width = this.canvasRef.current.offsetWidth * CANVAS_UPSCALING;
|
||||
this.canvasRef.current.height = this.canvasRef.current.offsetHeight * CANVAS_UPSCALING;
|
||||
|
||||
this.canvasContext = this.canvasRef.current.getContext('2d');
|
||||
this.canvasContext.lineCap = 'round';
|
||||
// To reduce line blurring.
|
||||
this.canvasContext.translate(0.5, 0.5);
|
||||
this.canvasWidth = this.canvasRef.current.width;
|
||||
this.canvasHeight = this.canvasRef.current.height;
|
||||
}
|
||||
|
||||
initMediaRecording(stream) {
|
||||
this.stream = stream;
|
||||
this.mediaRecorder = new MediaRecorder(stream, {mimeType: AUDIO_MIME_TYPE});
|
||||
|
||||
// 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 = _ => {
|
||||
if (this.durationMillis > MIN_DURATION) {
|
||||
this.getRecording(this.mediaRecorder.mimeType, this.durationMillis)
|
||||
.then(result => this.props.onFinished(result.url, result.preview, this.durationMillis));
|
||||
} else {
|
||||
this.props.onDeleted();
|
||||
}
|
||||
this.cleanUp();
|
||||
}
|
||||
|
||||
this.mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) {
|
||||
this.audioChunks.push(e.data);
|
||||
}
|
||||
|
||||
if (this.mediaRecorder.state != 'inactive') {
|
||||
this.getRecording(this.mediaRecorder.mimeType).then(result => {
|
||||
this.setState({
|
||||
blobUrl: result.url,
|
||||
preview: result.preview
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.durationMillis = 0;
|
||||
this.startedOn = Date.now();
|
||||
this.mediaRecorder.start();
|
||||
this.visualize();
|
||||
}
|
||||
|
||||
// Obtain data in a form sutable for sending or playing back.
|
||||
// If duration is valid, apply fix for Chrome's WebM duration bug.
|
||||
getRecording(mimeType, duration) {
|
||||
let blob = new Blob(this.audioChunks, {type: mimeType || AUDIO_MIME_TYPE});
|
||||
return (duration > 0 ? fixWebmDuration(blob, duration, {logger: false}) : Promise.resolve(blob))
|
||||
.then(fixedBlob => { blob = fixedBlob; return fixedBlob.arrayBuffer(); })
|
||||
.then(arrayBuff => this.audioContext.decodeAudioData(arrayBuff))
|
||||
.then(decoded => this.createPreview(decoded))
|
||||
.then(preview => ({url: window.URL.createObjectURL(blob), preview: intArrayToBase64(preview)}));
|
||||
}
|
||||
|
||||
// Preview must be calculated at the source: Chrome does not allow background AudioContext.
|
||||
createPreview(audio) {
|
||||
const data = audio.getChannelData(0);
|
||||
// Number of amplitude bars in preview.
|
||||
const viewLength = Math.min(data.length, VISUALIZATION_BARS);
|
||||
// The number of samples in each bar.
|
||||
const totalSPB = (data.length / viewLength) | 0;
|
||||
// Distance between samples: we are going to take just a fracton of samples.
|
||||
const samplingRate = Math.max(1, (totalSPB / MAX_SAMPLES_PER_BAR) | 0);
|
||||
|
||||
let buffer = [];
|
||||
let max = -1;
|
||||
for (let i = 0; i < viewLength; i++) {
|
||||
let amplitude = 0;
|
||||
let count = 0;
|
||||
for (let j = 0; j < totalSPB; j += samplingRate) {
|
||||
amplitude += data[totalSPB * i + j] ** 2;
|
||||
count ++;
|
||||
}
|
||||
const val = Math.sqrt(amplitude / count);
|
||||
buffer.push(val);
|
||||
max = Math.max(max, val);
|
||||
}
|
||||
// Normalize amplitude to 0..100.
|
||||
if (max > 0) {
|
||||
buffer = buffer.map(a => (100 * a / max) | 0);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
cleanUp() {
|
||||
this.audioInput.disconnect();
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
|
||||
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 gray">delete_outline</i>
|
||||
</a>
|
||||
{this.state.recording ?
|
||||
<canvas ref={this.canvasRef} />
|
||||
:
|
||||
<AudioPlayer
|
||||
src={this.state.blobUrl}
|
||||
preview={this.state.preview}
|
||||
duration={this.durationMillis}
|
||||
short={true} />
|
||||
}
|
||||
<div className="duration">{this.state.duration}</div>
|
||||
{this.state.recording ?
|
||||
<a href="#" onClick={this.handlePause} title="Pause">
|
||||
<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,7 +3,7 @@ import React from 'react';
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
import LoadSpinner from './load-spinner.jsx';
|
||||
|
||||
import { sanitizeImageUrl } from '../lib/utils.js';
|
||||
import { sanitizeUrlForMime } from '../lib/utils.js';
|
||||
|
||||
import { AVATAR_SIZE } from '../config.js';
|
||||
|
||||
@@ -44,7 +44,7 @@ export default class AvatarUpload extends React.Component {
|
||||
<i className="material-icons">clear</i>
|
||||
</a>}
|
||||
{this.state.source ?
|
||||
<img src={this.props.tinode.authorizeURL(sanitizeImageUrl(this.state.source))} className="preview" /> :
|
||||
<img src={this.props.tinode.authorizeURL(sanitizeUrlForMime(this.state.source, 'image'))} className="preview" /> :
|
||||
this.props.readOnly && this.props.uid ?
|
||||
<div className="avatar-box">
|
||||
<LetterTile
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Single message, sent or received.
|
||||
// Single chat message bubble, sent or received.
|
||||
|
||||
import React from 'react';
|
||||
import { FormattedMessage, injectIntl } from 'react-intl';
|
||||
import FormattedDuration from 'react-intl-formatted-duration';
|
||||
import { Drafty } from 'tinode-sdk';
|
||||
import { Drafty, Tinode } from 'tinode-sdk';
|
||||
|
||||
import Attachment from './attachment.jsx';
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
@@ -111,11 +112,10 @@ class BaseChatMessage extends React.PureComponent {
|
||||
}
|
||||
|
||||
render() {
|
||||
const sideClass = this.props.deleted ? 'center' :
|
||||
(this.props.sequence + ' ' + (this.props.response ? 'left' : 'right'));
|
||||
const sideClass = this.props.sequence + ' ' + (this.props.response ? 'left' : 'right');
|
||||
const bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ? 'bubble tip' : 'bubble';
|
||||
const avatar = this.props.deleted ? null : (this.props.userAvatar || true);
|
||||
const fullDisplay = (this.props.isGroup && this.props.response && !this.props.deleted &&
|
||||
const avatar = this.props.userAvatar || true;
|
||||
const fullDisplay = (this.props.isGroup && this.props.response &&
|
||||
(this.props.sequence == 'single' || this.props.sequence == 'last'));
|
||||
|
||||
let content = this.props.content;
|
||||
@@ -193,13 +193,12 @@ class BaseChatMessage extends React.PureComponent {
|
||||
received={this.props.received} />
|
||||
: null}
|
||||
</div>
|
||||
{this.props.deleted || !this.props.showContextMenu ?
|
||||
null :
|
||||
{this.props.showContextMenu ?
|
||||
<span className="menuTrigger">
|
||||
<a href="#" onClick={this.handleContextClick}>
|
||||
<i className="material-icons">expand_more</i>
|
||||
</a>
|
||||
</span>
|
||||
</span> : null
|
||||
}
|
||||
</div>
|
||||
{fullDisplay ?
|
||||
|
||||
@@ -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';
|
||||
@@ -122,6 +121,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}
|
||||
item={key}
|
||||
index={contactNodes.length}
|
||||
key={key} />
|
||||
|
||||
+10
-7
@@ -4,7 +4,6 @@ import { FormattedMessage, injectIntl } from 'react-intl';
|
||||
|
||||
import ContactBadges from './contact-badges.jsx';
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
import ReceivedMarker from './received-marker.jsx'
|
||||
import UnreadBadge from './unread-badge.jsx';
|
||||
|
||||
import { Drafty } from 'tinode-sdk';
|
||||
@@ -90,6 +89,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}>
|
||||
@@ -98,16 +98,19 @@ class Contact extends React.Component {
|
||||
tinode={this.props.tinode}
|
||||
avatar={avatar}
|
||||
title={this.props.title}
|
||||
topic={this.props.item} />
|
||||
{this.props.showOnline ? <span className={online} /> :
|
||||
(this.props.showCheckmark && this.props.selected ?
|
||||
topic={this.props.item}
|
||||
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) ?
|
||||
<i className="checkmark material-icons">check_circle</i>
|
||||
: null)}
|
||||
: 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,29 +1,29 @@
|
||||
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';
|
||||
import { sanitizeUrlForMime } from '../lib/utils.js';
|
||||
|
||||
export default class LetterTile extends React.PureComponent {
|
||||
render() {
|
||||
let avatar;
|
||||
if (this.props.avatar === true) {
|
||||
const isGroup = (Tinode.topicType(this.props.topic) == 'grp');
|
||||
const isGroup = Tinode.isGroupTopicName(this.props.topic);
|
||||
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;
|
||||
const className = 'lettertile ' + iconColor + (this.props.deleted ? ' disabled' : '');
|
||||
avatar = (<div className={className}><div>{letter}</div></div>)
|
||||
} else {
|
||||
const className = 'material-icons ' + iconColor;
|
||||
avatar = isGroup ?
|
||||
<i className={className}>group</i> : <i className={className}>person</i>;
|
||||
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));
|
||||
const url = this.props.tinode.authorizeURL(sanitizeUrlForMime(this.props.avatar, 'image'));
|
||||
// If avatar image is invalid, show a placeholder.
|
||||
avatar = <img className="avatar" alt="avatar" src={url}
|
||||
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 {
|
||||
avatar = null;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Message bubble with non-payload content, such as an indicator of deleted content or a date stamp.
|
||||
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export default class MetaMessage extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
render() {
|
||||
let content = null;
|
||||
let bubbleClass = 'bubble';
|
||||
if (this.props.deleted) {
|
||||
// Message represents a range of deleted messages.
|
||||
content = <><i className="material-icons gray">block</i> <i className="gray">
|
||||
<FormattedMessage id="deleted_content"
|
||||
defaultMessage="content deleted" description="Shown when messages are deleted" />
|
||||
</i></>
|
||||
bubbleClass += ' deleted';
|
||||
} else if (this.props.date) {
|
||||
// A date badge.
|
||||
content = <>{this.props.date}</>
|
||||
bubbleClass += ' date';
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return <>{null}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="meta">
|
||||
<div className={bubbleClass}>
|
||||
<div className="message-content">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -2,9 +2,8 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import Tinode from 'tinode-sdk';
|
||||
import { Tinode } from 'tinode-sdk';
|
||||
|
||||
import { shortDateFormat } from '../lib/strformat.js';
|
||||
import { deliveryMarker } from '../lib/utils.js';
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -29,7 +28,7 @@ class ReceivedMarker extends React.PureComponent {
|
||||
} else if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
|
||||
timestamp = formatMessage(messages.message_sending_failed);
|
||||
} else {
|
||||
timestamp = shortDateFormat(this.props.timestamp, this.props.intl.locale);
|
||||
timestamp = this.props.timestamp.toLocaleTimeString(this.props.intl.locale, { timeStyle: 'short' });
|
||||
}
|
||||
|
||||
const icon = deliveryMarker(this.props.received);
|
||||
|
||||
@@ -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,
|
||||
audioAvailable: !!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);
|
||||
@@ -77,7 +82,9 @@ class SendMessage extends React.PureComponent {
|
||||
this.messageEditArea.focus();
|
||||
}
|
||||
|
||||
if (prevProps.reply != this.props.reply) {
|
||||
if (prevProps.topicName != this.props.topicName) {
|
||||
this.setState({message: '', audioRec: false, quote: null});
|
||||
} else if (prevProps.reply != this.props.reply) {
|
||||
this.setState({quote: this.formatReply()});
|
||||
}
|
||||
}
|
||||
@@ -118,7 +125,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 +132,11 @@ class SendMessage extends React.PureComponent {
|
||||
e.target.value = '';
|
||||
}
|
||||
|
||||
handleAttachAudio(url, preview, duration) {
|
||||
this.setState({audioRec: false});
|
||||
this.props.onAttachAudio(url, preview, duration);
|
||||
}
|
||||
|
||||
handleSend(e) {
|
||||
e.preventDefault();
|
||||
const message = this.state.message.trim();
|
||||
@@ -137,6 +148,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
|
||||
@@ -185,14 +203,14 @@ class SendMessage extends React.PureComponent {
|
||||
</div>
|
||||
{this.state.quote}
|
||||
</div>);
|
||||
|
||||
const audioEnabled = this.state.audioAvailable && this.props.onAttachAudio;
|
||||
return (
|
||||
<div id="send-message-wrapper">
|
||||
{this.state.quote && !this.props.noInput ? quote : null}
|
||||
<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 +223,25 @@ class SendMessage extends React.PureComponent {
|
||||
null}
|
||||
{this.props.noInput ?
|
||||
(this.state.quote ? quote : <div className="hr thin" />) :
|
||||
(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 />}
|
||||
autoFocus />)}
|
||||
{this.state.message || !audioEnabled ?
|
||||
<a href="#" onClick={this.handleSend} title="Send">
|
||||
<i className="material-icons">send</i>
|
||||
</a>
|
||||
</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/*"
|
||||
|
||||
@@ -6,7 +6,7 @@ import ContactBadges from './contact-badges.jsx';
|
||||
import MenuContacts from './menu-contacts.jsx';
|
||||
import MenuStart from './menu-start.jsx';
|
||||
|
||||
import { sanitizeImageUrl } from '../lib/utils.js';
|
||||
import { sanitizeUrlForMime } from '../lib/utils.js';
|
||||
|
||||
export default class SideNavbar extends React.PureComponent {
|
||||
render() {
|
||||
@@ -18,7 +18,7 @@ export default class SideNavbar extends React.PureComponent {
|
||||
}
|
||||
let avatar = null;
|
||||
if (this.props.tinode) {
|
||||
avatar = this.props.tinode.authorizeURL(sanitizeImageUrl(this.props.avatar));
|
||||
avatar = this.props.tinode.authorizeURL(sanitizeUrlForMime(this.props.avatar, 'image'));
|
||||
}
|
||||
return (
|
||||
<div id="side-caption-panel" className="caption-panel">
|
||||
|
||||
@@ -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';
|
||||
@@ -109,7 +109,7 @@ export default class TopicDescEdit extends React.Component {
|
||||
// for uploading it to the server out of band.
|
||||
uploadAvatar(mime, blob, width, height) {
|
||||
const readyToUpload = (image) => {
|
||||
let {mime, blob, width, height} = image;
|
||||
let {mime, blob} = image;
|
||||
if (blob.size > MAX_AVATAR_BYTES) {
|
||||
// Too large to send inband - uploading out of band and sending as a link.
|
||||
const uploader = this.props.tinode.getLargeFileHelper();
|
||||
|
||||
+12563
-499
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
+3539
-5524
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.3";
|
||||
const PACKAGE_VERSION = "0.20.0-alpha1";
|
||||
|
||||
+2
-4
@@ -52,8 +52,8 @@ module.exports = (env, argv) => {
|
||||
]
|
||||
},
|
||||
performance: {
|
||||
maxEntrypointSize: 294912,
|
||||
maxAssetSize: 294912
|
||||
maxEntrypointSize: 360000,
|
||||
maxAssetSize: 360000
|
||||
},
|
||||
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