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).
|
Although the app is generally usable, keep in mind that this is work in progress. Some bugs probably exist, some features are missing. The app was tested in the latest Chrome & Firefox only. NPM package is [available](https://www.npmjs.com/package/tinode-webapp).
|
||||||
|
|
||||||
Try possibly newer or older version live at https://web.tinode.co/.
|
Try possibly newer or older version live at https://sandbox.tinode.co/.
|
||||||
|
|
||||||
<a href="https://web.tinode.co/"><img src="web-app.svg" height=36></a>
|
<a href="https://web.tinode.co/"><img src="web-app.svg" height=36></a>
|
||||||
|
|
||||||
@@ -44,11 +44,11 @@ More translations are welcome. See [instructions](https://github.com/tinode/chat
|
|||||||
|
|
||||||
## Not done yet
|
## Not done yet
|
||||||
|
|
||||||
* Voice or video messages, video or audio calling.
|
* Video or audio calling.
|
||||||
* End-to-End encryption.
|
* End-to-End encryption.
|
||||||
* Emoji support is weak.
|
* Emoji support is weak.
|
||||||
* Mentions, hashtags.
|
* Mentions, hashtags.
|
||||||
* Previews not generated for videos, audio, links or docs.
|
* Previews not generated for videos, links or docs.
|
||||||
|
|
||||||
## Other
|
## Other
|
||||||
|
|
||||||
|
|||||||
+192
-44
@@ -46,36 +46,6 @@ a.gray:focus {
|
|||||||
color: #999;
|
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 {
|
form {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
@@ -147,6 +117,7 @@ button.round.small {
|
|||||||
line-height: 2rem;
|
line-height: 2rem;
|
||||||
box-shadow: 0 2px 0.35rem 0 rgba(0, 0, 0, 0.25);
|
box-shadow: 0 2px 0.35rem 0 rgba(0, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:focus, button:hover, button:active {
|
button:focus, button:hover, button:active {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
@@ -252,6 +223,7 @@ textarea {
|
|||||||
|
|
||||||
textarea.inplace-edit {
|
textarea.inplace-edit {
|
||||||
height: unset;
|
height: unset;
|
||||||
|
font-size: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* END of input and buttons */
|
/* END of input and buttons */
|
||||||
@@ -433,6 +405,7 @@ div.trusted-badge {
|
|||||||
font-size: 120%;
|
font-size: 120%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Other universal style components */
|
||||||
.small {
|
.small {
|
||||||
font-size: 90%;
|
font-size: 90%;
|
||||||
}
|
}
|
||||||
@@ -462,6 +435,76 @@ div.trusted-badge {
|
|||||||
text-overflow: ellipsis;
|
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 */
|
/* Element which can take focus but shows no outline */
|
||||||
.group-focus {
|
.group-focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -671,6 +714,10 @@ table.permission-editor td.checkbox {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-title.deleted {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
.caption-panel {
|
.caption-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -913,6 +960,12 @@ i.search {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact-box .contact-title.deleted {
|
||||||
|
opacity: 0.66;
|
||||||
|
text-decoration: line-through;
|
||||||
|
text-decoration-thickness: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
.contact-box .contact-comment {
|
.contact-box .contact-comment {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@@ -964,6 +1017,11 @@ i.search {
|
|||||||
height: 2rem;
|
height: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar.deleted {
|
||||||
|
opacity: 0.75;
|
||||||
|
filter: grayscale(1);
|
||||||
|
}
|
||||||
|
|
||||||
.contact-box .avatar-box > .material-icons {
|
.contact-box .avatar-box > .material-icons {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1044,15 +1102,28 @@ img.channel {
|
|||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
line-height: 1.5rem;
|
|
||||||
color: #0097A7;
|
color: #0097A7;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0.05rem;
|
right: 0;
|
||||||
bottom: 0.05rem;
|
bottom: 0;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
border: 0.0px solid transparent;
|
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 */
|
/* END of avatar box */
|
||||||
|
|
||||||
#add-topic {
|
#add-topic {
|
||||||
@@ -1379,7 +1450,8 @@ img.channel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#send-message-panel .material-icons {
|
#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 {
|
#send-message-panel .material-icons.secondary, .material-icons.disabled {
|
||||||
@@ -1515,12 +1587,6 @@ img.channel {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-box .center {
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble {
|
.bubble {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1530,6 +1596,7 @@ img.channel {
|
|||||||
padding: 0.75rem 0.5rem 0.75rem 0.5rem;
|
padding: 0.75rem 0.5rem 0.75rem 0.5rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
color: #212121;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
box-shadow: 0 1px 0.5px rgba(0, 0, 0, 0.12);
|
box-shadow: 0 1px 0.5px rgba(0, 0, 0, 0.12);
|
||||||
transition: ease-out background-color 0.5s;
|
transition: ease-out background-color 0.5s;
|
||||||
@@ -1582,11 +1649,28 @@ img.channel {
|
|||||||
border-bottom-right-radius: 0;
|
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;
|
background-color: #e3f2fd;
|
||||||
padding: 0.75rem 0.75rem 0.5rem 0.75rem;
|
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 triangle placed bottom left flush. */
|
||||||
.left .bubble.tip::before {
|
.left .bubble.tip::before {
|
||||||
content: '';
|
content: '';
|
||||||
@@ -1649,7 +1733,6 @@ img.channel {
|
|||||||
|
|
||||||
.bubble div.message-content {
|
.bubble div.message-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
color: #212121;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 36rem;
|
max-width: 36rem;
|
||||||
@@ -1959,6 +2042,10 @@ ul.tabbar li.active > a {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lettertile.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
/* Colors of letter tiles */
|
/* Colors of letter tiles */
|
||||||
.lt-bg-0 {
|
.lt-bg-0 {
|
||||||
background-color: #ef9a9a;
|
background-color: #ef9a9a;
|
||||||
@@ -2254,7 +2341,7 @@ ul.tabbar li.active > a {
|
|||||||
|
|
||||||
.chip > a {
|
.chip > a {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
color: #ccc;
|
color: #eee;
|
||||||
background-color: #aaa;
|
background-color: #aaa;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin: 0.3rem;
|
margin: 0.3rem;
|
||||||
@@ -2268,7 +2355,7 @@ ul.tabbar li.active > a {
|
|||||||
.chip > a:focus,
|
.chip > a:focus,
|
||||||
.chip > a:hover {
|
.chip > a:hover {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #ddd;
|
color: #fff;
|
||||||
background-color: #bbb;
|
background-color: #bbb;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2553,6 +2640,67 @@ ul.tabbar li.active > a {
|
|||||||
height: 80%;
|
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: #667575CC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player .material-icons.disabled {
|
||||||
|
color: #999C;
|
||||||
|
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: #757575CC;
|
||||||
|
font-size: small;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player .timer {
|
||||||
|
font-size: smaller;
|
||||||
|
color: #666C;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Mobile styles.
|
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>
|
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@17/umd/react-dom.development.js"></script>
|
||||||
<!-- React-Intl for translations -->
|
<!-- React-Intl for translations -->
|
||||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@5/react-intl.iife.js"></script>
|
<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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<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@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-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/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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
|
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
|
||||||
|
|||||||
+6
-1
@@ -36,5 +36,10 @@
|
|||||||
"platform": "itunes",
|
"platform": "itunes",
|
||||||
"url": "https://apps.apple.com/us/app/tinode/id1483763538"
|
"url": "https://apps.apple.com/us/app/tinode/id1483763538"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"permissions": {
|
||||||
|
"audio-capture": {
|
||||||
|
"description": "Required for recording voice messages"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1847
-1245
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "tinode-webapp",
|
"name": "tinode-webapp",
|
||||||
"description": "Tinode messenger for the web",
|
"description": "Tinode messenger for the web",
|
||||||
"version": "0.18.6",
|
"version": "0.19.0-beta1",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/tinode/example-react-js.git"
|
"url": "https://github.com/tinode/example-react-js.git"
|
||||||
@@ -66,11 +66,12 @@
|
|||||||
"author": "Tinode Authors <info@tinode.co>",
|
"author": "Tinode Authors <info@tinode.co>",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"firebase": "^8.10.0",
|
"firebase": "^9.6.7",
|
||||||
|
"fix-webm-duration": "^1.0.5",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"react-intl": "^5.20.2",
|
"react-intl": "^5.20.2",
|
||||||
"tinode-sdk": "^0.18.3"
|
"tinode-sdk": "^0.19.0-beta1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.14.5",
|
"@babel/core": "^7.14.5",
|
||||||
@@ -80,7 +81,7 @@
|
|||||||
"babel-loader": "^8.2.2",
|
"babel-loader": "^8.2.2",
|
||||||
"babel-plugin-formatjs": "^10.2.19",
|
"babel-plugin-formatjs": "^10.2.19",
|
||||||
"browserslist": "^4.16.5",
|
"browserslist": "^4.16.5",
|
||||||
"copy-webpack-plugin": "^9.0.0",
|
"copy-webpack-plugin": "^10.0.0",
|
||||||
"cssnano": "^5.0.6",
|
"cssnano": "^5.0.6",
|
||||||
"glob": "^7.1.7",
|
"glob": "^7.1.7",
|
||||||
"mkdirp": "^1.0.4",
|
"mkdirp": "^1.0.4",
|
||||||
|
|||||||
+29
-17
@@ -1,6 +1,7 @@
|
|||||||
// Must be located at the root.
|
// Must be located at the root.
|
||||||
importScripts('https://cdn.jsdelivr.net/npm/firebase@8.9.1/firebase-app.js');
|
// FIXME: update Firebase version.
|
||||||
importScripts('https://cdn.jsdelivr.net/npm/firebase@8.9.1/firebase-messaging.js');
|
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('firebase-init.js');
|
||||||
importScripts('version.js');
|
importScripts('version.js');
|
||||||
|
|
||||||
@@ -43,7 +44,11 @@ const i18n = {
|
|||||||
'new_chat': "新聊天",
|
'new_chat': "新聊天",
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
self.i18nMessage = function(id) {
|
self.i18nMessage = function(id) {
|
||||||
|
if (!id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
// Choose translations: given something like 'de-CH', try 'de-CH' then 'de' then 'en'.
|
// Choose translations: given something like 'de-CH', try 'de-CH' then 'de' then 'en'.
|
||||||
const lang = i18n[self.locale] || i18n[self.baseLocale] || i18n['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
|
// Try finding string by id in the specified language, if missing try English, otherwise use the id itself
|
||||||
@@ -66,18 +71,21 @@ fbMessaging.onBackgroundMessage((payload) => {
|
|||||||
webAppChannel.postMessage(payload.data);
|
webAppChannel.postMessage(payload.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const titles = {'msg': 'new_message', 'sub': 'new_chat'};
|
||||||
const pushType = payload.data.what || 'msg';
|
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]);
|
||||||
const options = {
|
if (title) {
|
||||||
body: payload.data.content || '', // TODO: content for 'sub' should be topic's or user's title.
|
const options = {
|
||||||
icon: '/img/logo96.png', // TODO: use topic's or user's avatar (would have to fetch for 'sub', read from db for 'msg').
|
body: payload.data.content || '', // TODO: content for 'sub' should be topic's or user's title.
|
||||||
badge: '/img/badge96.png',
|
icon: '/img/logo96.png', // TODO: use topic's or user's avatar (would have to fetch for 'sub', read from db for 'msg').
|
||||||
tag: payload.data.topic || undefined,
|
badge: '/img/badge96.png',
|
||||||
data: {
|
tag: payload.data.topic || undefined,
|
||||||
topic: payload.data.topic
|
data: {
|
||||||
}
|
topic: payload.data.topic
|
||||||
};
|
}
|
||||||
return self.registration.showNotification(title, options);
|
};
|
||||||
|
return self.registration.showNotification(title, options);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update service worker immediately for both the current client
|
// 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
|
// This code handles a click on notification: takes
|
||||||
// the user to the browser tab with the chat or opens a new tab.
|
// the user to the browser tab with the chat or opens a new tab.
|
||||||
self.addEventListener('notificationclick', event => {
|
self.addEventListener('notificationclick', event => {
|
||||||
const notification = event.notification;
|
const data = event.notification.data;
|
||||||
notification.close();
|
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({
|
event.waitUntil(self.clients.matchAll({
|
||||||
type: 'window',
|
type: 'window',
|
||||||
@@ -101,7 +113,7 @@ self.addEventListener('notificationclick', event => {
|
|||||||
let anyClient = null;
|
let anyClient = null;
|
||||||
for (let i = 0; i < windowClients.length; i++) {
|
for (let i = 0; i < windowClients.length; i++) {
|
||||||
const url = new URL(windowClients[i].url);
|
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.
|
// Found the Tinode tab with the right topic open.
|
||||||
return windowClients[i].focus();
|
return windowClients[i].focus();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+7
-2
@@ -16,7 +16,7 @@ export const DEFAULT_HOST = KNOWN_HOSTS.hosted;
|
|||||||
export const LOGGING_ENABLED = true;
|
export const LOGGING_ENABLED = true;
|
||||||
|
|
||||||
// Minimum time between two keypress notifications, milliseconds.
|
// Minimum time between two keypress notifications, milliseconds.
|
||||||
export const KEYPRESS_DELAY = 3*1000;
|
export const KEYPRESS_DELAY = 3_000;
|
||||||
// Delay before sending a {note} for reciving a message, milliseconds.
|
// Delay before sending a {note} for reciving a message, milliseconds.
|
||||||
export const RECEIVED_DELAY = 500;
|
export const RECEIVED_DELAY = 500;
|
||||||
// Delay before sending a read notification, milliseconds.
|
// Delay before sending a read notification, milliseconds.
|
||||||
@@ -64,7 +64,7 @@ export const MESSAGES_PAGE = 24;
|
|||||||
// for instance 1024 bytes.
|
// for instance 1024 bytes.
|
||||||
// Increase this limit to a greater value in production, if desired. Also increase
|
// Increase this limit to a greater value in production, if desired. Also increase
|
||||||
// max_message_size in the server config.
|
// max_message_size in the server config.
|
||||||
export const MAX_INBAND_ATTACHMENT_SIZE = 262144;
|
export const MAX_INBAND_ATTACHMENT_SIZE = 262_144;
|
||||||
|
|
||||||
// Default absolute maximum attachment size to be used with the server = 8MB.
|
// Default absolute maximum attachment size to be used with the server = 8MB.
|
||||||
// Used when the server-provided value is unavailable. Increase to something like 100MB in production.
|
// Used when the server-provided value is unavailable. Increase to something like 100MB in production.
|
||||||
@@ -98,6 +98,11 @@ export const QUOTED_REPLY_LENGTH = 30;
|
|||||||
// Length of a preview of a forwarded message.
|
// Length of a preview of a forwarded message.
|
||||||
export const FORWARDED_PREVIEW_LENGTH = 84;
|
export const FORWARDED_PREVIEW_LENGTH = 84;
|
||||||
|
|
||||||
|
// Minimum duration of an audio recording in milliseconds (2 sec).
|
||||||
|
export const MIN_DURATION = 2_000;
|
||||||
|
// Maximum duration of an audio recording in milliseconds (10 min).
|
||||||
|
export const MAX_DURATION = 600_000;
|
||||||
|
|
||||||
// Link for "Contact Us".
|
// Link for "Contact Us".
|
||||||
export const LINK_CONTACT_US = 'mailto:support@tinode.co';
|
export const LINK_CONTACT_US = 'mailto:support@tinode.co';
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -324,21 +324,21 @@
|
|||||||
"drafty_attachment": {
|
"drafty_attachment": {
|
||||||
"translation": "Dateianhang",
|
"translation": "Dateianhang",
|
||||||
"defaultMessage": "Attachment",
|
"defaultMessage": "Attachment",
|
||||||
"description": "Comment for attachment in drafty preview",
|
"description": "Comment for attachment in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
"drafty_form": {
|
"drafty_form": {
|
||||||
"translation": "Formular:",
|
"translation": "Formular:",
|
||||||
"defaultMessage": "Form:",
|
"defaultMessage": "Form:",
|
||||||
"description": "Comment for form in drafty preview",
|
"description": "Comment for form in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
"drafty_image": {
|
"drafty_image": {
|
||||||
"translation": "Bild",
|
"translation": "Bild",
|
||||||
"defaultMessage": "Picture",
|
"defaultMessage": "Picture",
|
||||||
"description": "Comment for embedded images in drafty preview",
|
"description": "Comment for embedded images in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
@@ -1360,5 +1360,12 @@
|
|||||||
"description": "Label for entering a new password",
|
"description": "Label for entering a new password",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
|
},
|
||||||
|
"drafty_unknown": {
|
||||||
|
"translation": "Nicht unterstützt",
|
||||||
|
"defaultMessage": "Unsupported",
|
||||||
|
"description": "Unsupported entity in drafty",
|
||||||
|
"missing": false,
|
||||||
|
"obsolete": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -324,21 +324,21 @@
|
|||||||
"drafty_attachment": {
|
"drafty_attachment": {
|
||||||
"translation": "Attachment",
|
"translation": "Attachment",
|
||||||
"defaultMessage": "Attachment",
|
"defaultMessage": "Attachment",
|
||||||
"description": "Comment for attachment in drafty preview",
|
"description": "Comment for attachment in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
"drafty_form": {
|
"drafty_form": {
|
||||||
"translation": "Form:",
|
"translation": "Form:",
|
||||||
"defaultMessage": "Form:",
|
"defaultMessage": "Form:",
|
||||||
"description": "Comment for form in drafty preview",
|
"description": "Comment for form in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
"drafty_image": {
|
"drafty_image": {
|
||||||
"translation": "Picture",
|
"translation": "Picture",
|
||||||
"defaultMessage": "Picture",
|
"defaultMessage": "Picture",
|
||||||
"description": "Comment for embedded images in drafty preview",
|
"description": "Comment for embedded images in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
@@ -1360,5 +1360,12 @@
|
|||||||
"description": "Label for entering a new password",
|
"description": "Label for entering a new password",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
|
},
|
||||||
|
"drafty_unknown": {
|
||||||
|
"translation": "Unsupported",
|
||||||
|
"defaultMessage": "Unsupported",
|
||||||
|
"description": "Unsupported entity in drafty",
|
||||||
|
"missing": false,
|
||||||
|
"obsolete": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -324,21 +324,21 @@
|
|||||||
"drafty_attachment": {
|
"drafty_attachment": {
|
||||||
"translation": "Archivo",
|
"translation": "Archivo",
|
||||||
"defaultMessage": "Attachment",
|
"defaultMessage": "Attachment",
|
||||||
"description": "Comment for attachment in drafty preview",
|
"description": "Comment for attachment in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
"drafty_form": {
|
"drafty_form": {
|
||||||
"translation": "Formulario:",
|
"translation": "Formulario:",
|
||||||
"defaultMessage": "Form:",
|
"defaultMessage": "Form:",
|
||||||
"description": "Comment for form in drafty preview",
|
"description": "Comment for form in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
"drafty_image": {
|
"drafty_image": {
|
||||||
"translation": "Imagen",
|
"translation": "Imagen",
|
||||||
"defaultMessage": "Picture",
|
"defaultMessage": "Picture",
|
||||||
"description": "Comment for embedded images in drafty preview",
|
"description": "Comment for embedded images in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
@@ -1360,5 +1360,12 @@
|
|||||||
"description": "Label for entering a new password",
|
"description": "Label for entering a new password",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
|
},
|
||||||
|
"drafty_unknown": {
|
||||||
|
"translation": "No soportado",
|
||||||
|
"defaultMessage": "Unsupported",
|
||||||
|
"description": "Unsupported entity in drafty",
|
||||||
|
"missing": false,
|
||||||
|
"obsolete": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,15 +193,19 @@
|
|||||||
},
|
},
|
||||||
"drafty_attachment": {
|
"drafty_attachment": {
|
||||||
"defaultMessage": "Attachment",
|
"defaultMessage": "Attachment",
|
||||||
"description": "Comment for attachment in drafty preview"
|
"description": "Comment for attachment in drafty"
|
||||||
},
|
},
|
||||||
"drafty_form": {
|
"drafty_form": {
|
||||||
"defaultMessage": "Form:",
|
"defaultMessage": "Form:",
|
||||||
"description": "Comment for form in drafty preview"
|
"description": "Comment for form in drafty"
|
||||||
},
|
},
|
||||||
"drafty_image": {
|
"drafty_image": {
|
||||||
"defaultMessage": "Picture",
|
"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": {
|
"email_dative": {
|
||||||
"defaultMessage": "email",
|
"defaultMessage": "email",
|
||||||
|
|||||||
+10
-3
@@ -324,21 +324,21 @@
|
|||||||
"drafty_attachment": {
|
"drafty_attachment": {
|
||||||
"translation": "부착",
|
"translation": "부착",
|
||||||
"defaultMessage": "Attachment",
|
"defaultMessage": "Attachment",
|
||||||
"description": "Comment for attachment in drafty preview",
|
"description": "Comment for attachment in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
"drafty_form": {
|
"drafty_form": {
|
||||||
"translation": "형태:",
|
"translation": "형태:",
|
||||||
"defaultMessage": "Form:",
|
"defaultMessage": "Form:",
|
||||||
"description": "Comment for form in drafty preview",
|
"description": "Comment for form in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
"drafty_image": {
|
"drafty_image": {
|
||||||
"translation": "이미지",
|
"translation": "이미지",
|
||||||
"defaultMessage": "Picture",
|
"defaultMessage": "Picture",
|
||||||
"description": "Comment for embedded images in drafty preview",
|
"description": "Comment for embedded images in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
@@ -1360,5 +1360,12 @@
|
|||||||
"description": "Label for entering a new password",
|
"description": "Label for entering a new password",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
|
},
|
||||||
|
"drafty_unknown": {
|
||||||
|
"translation": "지원되지 않음",
|
||||||
|
"defaultMessage": "Unsupported",
|
||||||
|
"description": "Unsupported entity in drafty",
|
||||||
|
"missing": false,
|
||||||
|
"obsolete": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -324,21 +324,21 @@
|
|||||||
"drafty_attachment": {
|
"drafty_attachment": {
|
||||||
"translation": "Atasament",
|
"translation": "Atasament",
|
||||||
"defaultMessage": "Attachment",
|
"defaultMessage": "Attachment",
|
||||||
"description": "Comment for attachment in drafty preview",
|
"description": "Comment for attachment in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
"drafty_form": {
|
"drafty_form": {
|
||||||
"translation": "Formă:",
|
"translation": "Formă:",
|
||||||
"defaultMessage": "Form:",
|
"defaultMessage": "Form:",
|
||||||
"description": "Comment for form in drafty preview",
|
"description": "Comment for form in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
"drafty_image": {
|
"drafty_image": {
|
||||||
"translation": "Imagine",
|
"translation": "Imagine",
|
||||||
"defaultMessage": "Picture",
|
"defaultMessage": "Picture",
|
||||||
"description": "Comment for embedded images in drafty preview",
|
"description": "Comment for embedded images in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
@@ -1360,5 +1360,12 @@
|
|||||||
"description": "Label for entering a new password",
|
"description": "Label for entering a new password",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
|
},
|
||||||
|
"drafty_unknown": {
|
||||||
|
"translation": "Neacceptat",
|
||||||
|
"defaultMessage": "Unsupported",
|
||||||
|
"description": "Unsupported entity in drafty",
|
||||||
|
"missing": false,
|
||||||
|
"obsolete": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -324,21 +324,21 @@
|
|||||||
"drafty_attachment": {
|
"drafty_attachment": {
|
||||||
"translation": "Аттачмент",
|
"translation": "Аттачмент",
|
||||||
"defaultMessage": "Attachment",
|
"defaultMessage": "Attachment",
|
||||||
"description": "Comment for attachment in drafty preview",
|
"description": "Comment for attachment in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
"drafty_form": {
|
"drafty_form": {
|
||||||
"translation": "Форма:",
|
"translation": "Форма:",
|
||||||
"defaultMessage": "Form:",
|
"defaultMessage": "Form:",
|
||||||
"description": "Comment for form in drafty preview",
|
"description": "Comment for form in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
"drafty_image": {
|
"drafty_image": {
|
||||||
"translation": "Картинка",
|
"translation": "Картинка",
|
||||||
"defaultMessage": "Picture",
|
"defaultMessage": "Picture",
|
||||||
"description": "Comment for embedded images in drafty preview",
|
"description": "Comment for embedded images in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
@@ -1360,5 +1360,12 @@
|
|||||||
"description": "Label for entering a new password",
|
"description": "Label for entering a new password",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
|
},
|
||||||
|
"drafty_unknown": {
|
||||||
|
"translation": "Не поддерживается",
|
||||||
|
"defaultMessage": "Unsupported",
|
||||||
|
"description": "Unsupported entity in drafty",
|
||||||
|
"missing": false,
|
||||||
|
"obsolete": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -324,21 +324,21 @@
|
|||||||
"drafty_attachment": {
|
"drafty_attachment": {
|
||||||
"translation": "附加檔案",
|
"translation": "附加檔案",
|
||||||
"defaultMessage": "Attachment",
|
"defaultMessage": "Attachment",
|
||||||
"description": "Comment for attachment in drafty preview",
|
"description": "Comment for attachment in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
"drafty_form": {
|
"drafty_form": {
|
||||||
"translation": "表單:",
|
"translation": "表單:",
|
||||||
"defaultMessage": "Form:",
|
"defaultMessage": "Form:",
|
||||||
"description": "Comment for form in drafty preview",
|
"description": "Comment for form in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
"drafty_image": {
|
"drafty_image": {
|
||||||
"translation": "圖片",
|
"translation": "圖片",
|
||||||
"defaultMessage": "Picture",
|
"defaultMessage": "Picture",
|
||||||
"description": "Comment for embedded images in drafty preview",
|
"description": "Comment for embedded images in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
@@ -1360,5 +1360,12 @@
|
|||||||
"description": "Label for entering a new password",
|
"description": "Label for entering a new password",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
|
},
|
||||||
|
"drafty_unknown": {
|
||||||
|
"translation": "不支持",
|
||||||
|
"defaultMessage": "Unsupported",
|
||||||
|
"description": "Unsupported entity in drafty",
|
||||||
|
"missing": false,
|
||||||
|
"obsolete": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -324,21 +324,21 @@
|
|||||||
"drafty_attachment": {
|
"drafty_attachment": {
|
||||||
"translation": "附件",
|
"translation": "附件",
|
||||||
"defaultMessage": "Attachment",
|
"defaultMessage": "Attachment",
|
||||||
"description": "Comment for attachment in drafty preview",
|
"description": "Comment for attachment in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
"drafty_form": {
|
"drafty_form": {
|
||||||
"translation": "形式:",
|
"translation": "形式:",
|
||||||
"defaultMessage": "Form:",
|
"defaultMessage": "Form:",
|
||||||
"description": "Comment for form in drafty preview",
|
"description": "Comment for form in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
"drafty_image": {
|
"drafty_image": {
|
||||||
"translation": "图像",
|
"translation": "图像",
|
||||||
"defaultMessage": "Picture",
|
"defaultMessage": "Picture",
|
||||||
"description": "Comment for embedded images in drafty preview",
|
"description": "Comment for embedded images in drafty",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
@@ -1360,5 +1360,12 @@
|
|||||||
"description": "Label for entering a new password",
|
"description": "Label for entering a new password",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
|
},
|
||||||
|
"drafty_unknown": {
|
||||||
|
"translation": "不支持",
|
||||||
|
"defaultMessage": "Unsupported",
|
||||||
|
"description": "Unsupported entity in drafty",
|
||||||
|
"missing": false,
|
||||||
|
"obsolete": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-1
@@ -186,7 +186,7 @@ export function fileToBase64(file) {
|
|||||||
export function blobToBase64(blob) {
|
export function blobToBase64(blob) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onerror = (evt) => {
|
reader.onerror = _ => {
|
||||||
reject(reader.error);
|
reject(reader.error);
|
||||||
};
|
};
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
@@ -281,3 +281,27 @@ export function base64ToBlob(str, mime) {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function intArrayToBase64(arr) {
|
||||||
|
if (!Array.isArray(arr)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let bin = '';
|
||||||
|
new Uint8Array(arr).forEach(b => bin += String.fromCharCode(b));
|
||||||
|
return window.btoa(bin);
|
||||||
|
} catch (err) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base64ToIntArray(b64) {
|
||||||
|
const arr = [];
|
||||||
|
try {
|
||||||
|
const bin = window.atob(b64);
|
||||||
|
[...bin].forEach(c => {
|
||||||
|
arr.push(c.charCodeAt(0));
|
||||||
|
});
|
||||||
|
return arr;
|
||||||
|
} catch (err) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
+48
-59
@@ -3,33 +3,39 @@ import { defineMessages } from 'react-intl';
|
|||||||
|
|
||||||
import { Drafty } from 'tinode-sdk';
|
import { Drafty } from 'tinode-sdk';
|
||||||
|
|
||||||
|
import AudioPlayer from '../widgets/audio-player.jsx'
|
||||||
import LazyImage from '../widgets/lazy-image.jsx'
|
import LazyImage from '../widgets/lazy-image.jsx'
|
||||||
import UploadingImage from '../widgets/uploading-image.jsx'
|
import UploadingImage from '../widgets/uploading-image.jsx'
|
||||||
|
|
||||||
import { IMAGE_THUMBNAIL_DIM, BROKEN_IMAGE_SIZE, REM_SIZE } from '../config.js';
|
import { IMAGE_THUMBNAIL_DIM, BROKEN_IMAGE_SIZE, REM_SIZE } from '../config.js';
|
||||||
import { base64ToBlob, blobToBase64, fitImageSize, imageScaled } from './blob-helpers.js';
|
import { base64ToBlob, blobToBase64, fitImageSize, imageScaled } from './blob-helpers.js';
|
||||||
import { idToColorClass, shortenFileName } from './strformat.js';
|
import { idToColorClass, secondsToTime, shortenFileName } from './strformat.js';
|
||||||
import { cancelablePromise, sanitizeImageUrl } from './utils.js';
|
import { cancelablePromise, sanitizeUrlForMime } from './utils.js';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
drafty_form: {
|
drafty_form: {
|
||||||
id: 'drafty_form',
|
id: 'drafty_form',
|
||||||
defaultMessage: 'Form: ',
|
defaultMessage: 'Form: ',
|
||||||
description: 'Comment for form in drafty preview'
|
description: 'Comment for form in drafty'
|
||||||
},
|
},
|
||||||
drafty_attachment: {
|
drafty_attachment: {
|
||||||
id: 'drafty_attachment',
|
id: 'drafty_attachment',
|
||||||
defaultMessage: 'Attachment',
|
defaultMessage: 'Attachment',
|
||||||
description: 'Comment for attachment in drafty preview'
|
description: 'Comment for attachment in drafty'
|
||||||
},
|
},
|
||||||
drafty_image: {
|
drafty_image: {
|
||||||
id: 'drafty_image',
|
id: 'drafty_image',
|
||||||
defaultMessage: 'Picture',
|
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) {
|
function handleImageData(el, data, attr) {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
attr.src = 'img/broken_image.png';
|
attr.src = 'img/broken_image.png';
|
||||||
@@ -53,7 +59,7 @@ function handleImageData(el, data, attr) {
|
|||||||
minHeight: dim.dstHeight + 'px'
|
minHeight: dim.dstHeight + 'px'
|
||||||
};
|
};
|
||||||
if (!Drafty.isProcessing(data)) {
|
if (!Drafty.isProcessing(data)) {
|
||||||
attr.src = this.authorizeURL(sanitizeImageUrl(attr.src));
|
attr.src = this.authorizeURL(sanitizeUrlForMime(attr.src, 'image'));
|
||||||
attr.alt = data.name;
|
attr.alt = data.name;
|
||||||
if (attr.src) {
|
if (attr.src) {
|
||||||
if (Math.max(data.width || 0, data.height || 0) > IMAGE_THUMBNAIL_DIM) {
|
if (Math.max(data.width || 0, data.height || 0) > IMAGE_THUMBNAIL_DIM) {
|
||||||
@@ -73,50 +79,6 @@ function handleImageData(el, data, attr) {
|
|||||||
return el;
|
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.
|
// The main Drafty formatter: converts Drafty elements into React classes. 'this' is set by the caller.
|
||||||
// 'this' must contain:
|
// 'this' must contain:
|
||||||
// viewportWidth: this.props.viewportWidth;
|
// viewportWidth: this.props.viewportWidth;
|
||||||
@@ -135,12 +97,27 @@ export function fullFormatter(style, data, values, key, stack) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let el = Drafty.tagName(style);
|
let el = Drafty.tagName(style);
|
||||||
const attr = Drafty.attrValue(style, data) || {};
|
let attr = Drafty.attrValue(style, data) || {};
|
||||||
attr.key = key;
|
attr.key = key;
|
||||||
switch (style) {
|
switch (style) {
|
||||||
|
case 'AU':
|
||||||
|
// Show audio player.
|
||||||
|
if (attr.src) {
|
||||||
|
attr.src = this.authorizeURL(sanitizeUrlForMime(attr.src, 'audio'));
|
||||||
|
attr.duration = data.duration > 0 ? (data.duration | 0) : undefined;
|
||||||
|
attr.preview = data.preview;
|
||||||
|
attr.loading = 'lazy';
|
||||||
|
}
|
||||||
|
el = AudioPlayer;
|
||||||
|
// Audio element cannot have content.
|
||||||
|
values = null;
|
||||||
|
break;
|
||||||
case 'BR':
|
case 'BR':
|
||||||
values = null;
|
values = null;
|
||||||
break;
|
break;
|
||||||
|
case 'EX':
|
||||||
|
// Ignore.
|
||||||
|
break;
|
||||||
case 'HL':
|
case 'HL':
|
||||||
// Highlighted text. Assign class name.
|
// Highlighted text. Assign class name.
|
||||||
attr.className = 'highlight';
|
attr.className = 'highlight';
|
||||||
@@ -187,10 +164,16 @@ export function fullFormatter(style, data, values, key, stack) {
|
|||||||
attr.onClick = this.onQuoteClick;
|
attr.onClick = this.onQuoteClick;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
if (el == '_UNKN') {
|
if (!el) {
|
||||||
// Unknown element.
|
// Unknown element.
|
||||||
el = React.Fragment;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
@@ -213,6 +196,11 @@ export function previewFormatter(style, data, values, key) {
|
|||||||
let el = Drafty.tagName(style);
|
let el = Drafty.tagName(style);
|
||||||
const attr = { key: key };
|
const attr = { key: key };
|
||||||
switch (style) {
|
switch (style) {
|
||||||
|
case 'AU':
|
||||||
|
// Voicemail as '[mic] 0:00'.
|
||||||
|
el = React.Fragment;
|
||||||
|
values = [<i key="au" className="material-icons">mic</i>, ' ', secondsToTime(data.duration/1000)];
|
||||||
|
break;
|
||||||
case 'BR':
|
case 'BR':
|
||||||
// Replace new line with a space.
|
// Replace new line with a space.
|
||||||
el = React.Fragment;
|
el = React.Fragment;
|
||||||
@@ -230,7 +218,7 @@ export function previewFormatter(style, data, values, key) {
|
|||||||
case 'IM':
|
case 'IM':
|
||||||
// Replace image with '[icon] Image'.
|
// Replace image with '[icon] Image'.
|
||||||
el = React.Fragment;
|
el = React.Fragment;
|
||||||
values = [<i key="im" className="material-icons">photo</i>, this.formatMessage(messages.drafty_image)];
|
values = [<i key="im" className="material-icons">photo</i>, ' ', this.formatMessage(messages.drafty_image)];
|
||||||
break;
|
break;
|
||||||
case 'BN':
|
case 'BN':
|
||||||
el = 'span';
|
el = 'span';
|
||||||
@@ -255,7 +243,7 @@ export function previewFormatter(style, data, values, key) {
|
|||||||
delete data.ref;
|
delete data.ref;
|
||||||
}
|
}
|
||||||
el = React.Fragment;
|
el = React.Fragment;
|
||||||
values = [<i key="ex" className="material-icons">attachment</i>, this.formatMessage(messages.drafty_attachment)];
|
values = [<i key="ex" className="material-icons">attachment</i>, ' ', this.formatMessage(messages.drafty_attachment)];
|
||||||
break;
|
break;
|
||||||
case 'QQ':
|
case 'QQ':
|
||||||
case 'HD':
|
case 'HD':
|
||||||
@@ -263,9 +251,10 @@ export function previewFormatter(style, data, values, key) {
|
|||||||
values = null;
|
values = null;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
if (el == '_UNKN') {
|
if (!el) {
|
||||||
|
// Unknown element.
|
||||||
el = React.Fragment;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
@@ -354,7 +343,7 @@ function quoteImage(data) {
|
|||||||
}
|
}
|
||||||
promise = Promise.resolve(blob);
|
promise = Promise.resolve(blob);
|
||||||
} else if (data.ref) {
|
} else if (data.ref) {
|
||||||
promise = fetch(this.authorizeURL(sanitizeImageUrl(data.ref)))
|
promise = fetch(this.authorizeURL(sanitizeUrlForMime(data.ref, 'image')))
|
||||||
.then(evt => {
|
.then(evt => {
|
||||||
if (evt.ok) {
|
if (evt.ok) {
|
||||||
return evt.blob();
|
return evt.blob();
|
||||||
|
|||||||
@@ -17,9 +17,13 @@ export function shortDateFormat(then, locale) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert seconds to minutes:seconds, i.e. 156 sec -> 2:36.
|
// Convert seconds to minutes:seconds, i.e. 156 sec -> 2:36.
|
||||||
export function secondsToTime(seconds) {
|
// If <code>fixedMins</code> is true, then minutes are represented by at least two digits.
|
||||||
const min = Math.floor(seconds / 60);
|
export function secondsToTime(seconds, fixedMin) {
|
||||||
let sec = seconds % 60;
|
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;
|
sec = sec < 10 ? `0${sec}` : sec;
|
||||||
return `${min}:${sec}`;
|
return `${min}:${sec}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-6
@@ -1,6 +1,6 @@
|
|||||||
// Odds and ends
|
// 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.
|
// Make shortcut icon appear with a green dot + show unread count in title.
|
||||||
export function updateFavicon(count) {
|
export function updateFavicon(count) {
|
||||||
@@ -144,10 +144,10 @@ export function sanitizeUrl(url, allowedSchemes) {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure URL is suitable for <img src="url"> field: the URL must be a relative URL or
|
// Ensure URL is suitable as a source like <img src="url"> field: the URL must be a relative URL or
|
||||||
// have http:, https:, blob: or data: scheme.
|
// have http:, https:, blob: or data: scheme.
|
||||||
// In case of data: scheme, the URL must start with a 'data:image/XXXX;base64,'.
|
// In case of data: scheme, the URL must must be of the right MIME type such as 'data:{mimeMajor}/XXXX;base64,'.
|
||||||
export function sanitizeImageUrl(url) {
|
export function sanitizeUrlForMime(url, mimeMajor) {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -157,8 +157,9 @@ export function sanitizeImageUrl(url) {
|
|||||||
return sanitizedUrl;
|
return sanitizedUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Is this a data: URL of an image?
|
// Is this a data: URL of the appropriate mime type?
|
||||||
if (/data:image\/[a-z0-9.-]+;base64,/i.test(url.trim())) {
|
const re = new RegExp(`data:${mimeMajor}\/[a-z0-9.-]+;base64,`, 'i');
|
||||||
|
if (re.test(url.trim())) {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
// This is a generated file. Don't edit.
|
// This is a generated file. Don't edit.
|
||||||
|
|
||||||
export const PACKAGE_VERSION = "0.18.6";
|
export const PACKAGE_VERSION = "0.19.0-beta1";
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ class AccSecurityView extends React.Component {
|
|||||||
<div>
|
<div>
|
||||||
<label className="small">
|
<label className="small">
|
||||||
<FormattedMessage id="label_default_access_mode" defaultMessage="Default access mode:"
|
<FormattedMessage id="label_default_access_mode" defaultMessage="Default access mode:"
|
||||||
description="Label for default access mode" />
|
description="Label for default access mode" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="quoted">
|
<div className="quoted">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
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';
|
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 React from 'react';
|
||||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
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 AvatarUpload from '../widgets/avatar-upload.jsx';
|
||||||
import BadgeList from '../widgets/badge-list.jsx';
|
import BadgeList from '../widgets/badge-list.jsx';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import Tinode from 'tinode-sdk';
|
import { Tinode } from 'tinode-sdk';
|
||||||
|
|
||||||
import { APP_NAME } from '../config.js';
|
import { APP_NAME } from '../config.js';
|
||||||
|
|
||||||
|
|||||||
+169
-58
@@ -1,8 +1,9 @@
|
|||||||
|
// Panel with a chat.
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||||
|
|
||||||
import Tinode from 'tinode-sdk';
|
import { Drafty, Tinode } from 'tinode-sdk';
|
||||||
const Drafty = Tinode.Drafty;
|
|
||||||
|
|
||||||
import ChatMessage from '../widgets/chat-message.jsx';
|
import ChatMessage from '../widgets/chat-message.jsx';
|
||||||
import ContactBadges from '../widgets/contact-badges.jsx';
|
import ContactBadges from '../widgets/contact-badges.jsx';
|
||||||
@@ -14,18 +15,20 @@ import Invitation from '../widgets/invitation.jsx';
|
|||||||
import LetterTile from '../widgets/letter-tile.jsx';
|
import LetterTile from '../widgets/letter-tile.jsx';
|
||||||
import LoadSpinner from '../widgets/load-spinner.jsx';
|
import LoadSpinner from '../widgets/load-spinner.jsx';
|
||||||
import LogoView from './logo-view.jsx';
|
import LogoView from './logo-view.jsx';
|
||||||
|
import MetaMessage from '../widgets/meta-message.jsx';
|
||||||
import SendMessage from '../widgets/send-message.jsx';
|
import SendMessage from '../widgets/send-message.jsx';
|
||||||
|
|
||||||
import { DEFAULT_P2P_ACCESS_MODE, IMAGE_PREVIEW_DIM, KEYPRESS_DELAY,
|
import { DEFAULT_P2P_ACCESS_MODE, IMAGE_PREVIEW_DIM, KEYPRESS_DELAY,
|
||||||
MESSAGES_PAGE, MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE,
|
MESSAGES_PAGE, MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE,
|
||||||
READ_DELAY, QUOTED_REPLY_LENGTH } from '../config.js';
|
READ_DELAY, QUOTED_REPLY_LENGTH } from '../config.js';
|
||||||
import { blobToBase64, fileToBase64,
|
import { blobToBase64, fileToBase64, imageScaled, makeImageUrl } from '../lib/blob-helpers.js';
|
||||||
imageScaled, makeImageUrl } from '../lib/blob-helpers.js';
|
|
||||||
import HashNavigation from '../lib/navigation.js';
|
import HashNavigation from '../lib/navigation.js';
|
||||||
import { bytesToHumanSize, shortDateFormat } from '../lib/strformat.js';
|
import { bytesToHumanSize, shortDateFormat } from '../lib/strformat.js';
|
||||||
|
|
||||||
// Run timer with this frequency (ms) for checking notification queue.
|
// Run timer with this frequency (ms) for checking notification queue.
|
||||||
const NOTIFICATION_EXEC_INTERVAL = 300;
|
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({
|
const messages = defineMessages({
|
||||||
online_now: {
|
online_now: {
|
||||||
@@ -88,6 +91,7 @@ class MessagesView extends React.Component {
|
|||||||
this.retrySend = this.retrySend.bind(this);
|
this.retrySend = this.retrySend.bind(this);
|
||||||
this.sendImageAttachment = this.sendImageAttachment.bind(this);
|
this.sendImageAttachment = this.sendImageAttachment.bind(this);
|
||||||
this.sendFileAttachment = this.sendFileAttachment.bind(this);
|
this.sendFileAttachment = this.sendFileAttachment.bind(this);
|
||||||
|
this.sendAudioAttachment = this.sendAudioAttachment.bind(this);
|
||||||
this.sendKeyPress = this.sendKeyPress.bind(this);
|
this.sendKeyPress = this.sendKeyPress.bind(this);
|
||||||
this.subscribe = this.subscribe.bind(this);
|
this.subscribe = this.subscribe.bind(this);
|
||||||
this.handleScrollReference = this.handleScrollReference.bind(this);
|
this.handleScrollReference = this.handleScrollReference.bind(this);
|
||||||
@@ -109,6 +113,7 @@ class MessagesView extends React.Component {
|
|||||||
this.handleCancelUpload = this.handleCancelUpload.bind(this);
|
this.handleCancelUpload = this.handleCancelUpload.bind(this);
|
||||||
this.postReadNotification = this.postReadNotification.bind(this);
|
this.postReadNotification = this.postReadNotification.bind(this);
|
||||||
this.clearNotificationQueue = this.clearNotificationQueue.bind(this);
|
this.clearNotificationQueue = this.clearNotificationQueue.bind(this);
|
||||||
|
this.goToLatestMessage = this.goToLatestMessage.bind(this);
|
||||||
|
|
||||||
this.handlePickReply = this.handlePickReply.bind(this);
|
this.handlePickReply = this.handlePickReply.bind(this);
|
||||||
this.handleCancelReply = this.handleCancelReply.bind(this);
|
this.handleCancelReply = this.handleCancelReply.bind(this);
|
||||||
@@ -148,13 +153,13 @@ class MessagesView extends React.Component {
|
|||||||
// Scroll last message into view on component update e.g. on message received
|
// Scroll last message into view on component update e.g. on message received
|
||||||
// or vertical shrinking.
|
// or vertical shrinking.
|
||||||
componentDidUpdate(prevProps, prevState) {
|
componentDidUpdate(prevProps, prevState) {
|
||||||
if (this.messagesScroller) {
|
if (this.messagesScroller &&
|
||||||
if (prevState.topic != this.state.topic || prevState.messageCount != this.state.messageCount) {
|
(prevState.topic != this.state.topic || prevState.messageCount != this.state.messageCount)) {
|
||||||
// New message
|
// New message.
|
||||||
this.messagesScroller.scrollTop = this.messagesScroller.scrollHeight - this.state.scrollPosition;
|
if (this.state.scrollPosition < SHOW_GO_TO_LAST_DIST) {
|
||||||
} else if (prevProps.viewportHeight > this.props.viewportHeight) {
|
this.messagesScroller.scrollTop = this.messagesScroller.scrollHeight -
|
||||||
// Componet changed height.
|
this.state.scrollPosition -
|
||||||
this.messagesScroller.scrollTop += prevProps.viewportHeight - this.props.viewportHeight;
|
this.messagesScroller.offsetHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,8 +187,13 @@ class MessagesView extends React.Component {
|
|||||||
this.postReadNotification(0);
|
this.postReadNotification(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((this.state.topic != prevState.topic) || !prevProps.ready) {
|
if (topic && ((this.state.topic != prevState.topic) || !prevProps.ready)) {
|
||||||
this.subscribe(topic);
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +211,7 @@ class MessagesView extends React.Component {
|
|||||||
isVerified: false,
|
isVerified: false,
|
||||||
isStaff: false,
|
isStaff: false,
|
||||||
isDangerous: false,
|
isDangerous: false,
|
||||||
|
deleted: false,
|
||||||
docPreview: null,
|
docPreview: null,
|
||||||
imagePreview: null,
|
imagePreview: null,
|
||||||
imagePostview: null,
|
imagePostview: null,
|
||||||
@@ -209,7 +220,8 @@ class MessagesView extends React.Component {
|
|||||||
fetchingMessages: false,
|
fetchingMessages: false,
|
||||||
peerMessagingDisabled: false,
|
peerMessagingDisabled: false,
|
||||||
channel: false,
|
channel: false,
|
||||||
reply: null
|
reply: null,
|
||||||
|
showGoToLastButton: false
|
||||||
};
|
};
|
||||||
} else if (nextProps.topic != prevState.topic) {
|
} else if (nextProps.topic != prevState.topic) {
|
||||||
const topic = nextProps.tinode.getTopic(nextProps.topic);
|
const topic = nextProps.tinode.getTopic(nextProps.topic);
|
||||||
@@ -232,7 +244,9 @@ class MessagesView extends React.Component {
|
|||||||
typingIndicator: false,
|
typingIndicator: false,
|
||||||
scrollPosition: 0,
|
scrollPosition: 0,
|
||||||
fetchingMessages: false,
|
fetchingMessages: false,
|
||||||
reply: reply
|
reply: reply,
|
||||||
|
showGoToLastButton: false,
|
||||||
|
deleted: topic._deleted
|
||||||
};
|
};
|
||||||
|
|
||||||
if (topic) {
|
if (topic) {
|
||||||
@@ -333,7 +347,7 @@ class MessagesView extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
subscribe(topic) {
|
subscribe(topic) {
|
||||||
if (!topic || topic.isSubscribed() || !this.props.ready) {
|
if (topic.isSubscribed() || !this.props.ready) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,13 +381,16 @@ class MessagesView extends React.Component {
|
|||||||
this.props.onNewTopicCreated(this.props.topic, ctrl.topic);
|
this.props.onNewTopicCreated(this.props.topic, ctrl.topic);
|
||||||
// If there are unsent messages, try sending them now.
|
// If there are unsent messages, try sending them now.
|
||||||
topic.queuedMessages((pub) => {
|
topic.queuedMessages((pub) => {
|
||||||
if (!pub._sending && topic.isSubscribed()) {
|
if (pub._sending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (topic.isSubscribed()) {
|
||||||
this.retrySend(pub);
|
this.retrySend(pub);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error("Failed subscription to", this.state.topic);
|
console.error("Failed subscription to", this.state.topic, err);
|
||||||
this.props.onError(err.message, 'err');
|
this.props.onError(err.message, 'err');
|
||||||
const blankState = MessagesView.getDerivedStateFromProps({}, {});
|
const blankState = MessagesView.getDerivedStateFromProps({}, {});
|
||||||
blankState.title = this.props.intl.formatMessage(messages.not_found);
|
blankState.title = this.props.intl.formatMessage(messages.not_found);
|
||||||
@@ -403,6 +420,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) {
|
handleScrollReference(node) {
|
||||||
if (node) {
|
if (node) {
|
||||||
node.addEventListener('scroll', this.handleScrollEvent);
|
node.addEventListener('scroll', this.handleScrollEvent);
|
||||||
@@ -411,9 +429,14 @@ class MessagesView extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get older messages
|
// Get older messages and show/hide [go to latest message] button.
|
||||||
handleScrollEvent(event) {
|
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) {
|
if (this.state.fetchingMessages) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -430,6 +453,13 @@ class MessagesView extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
goToLatestMessage() {
|
||||||
|
this.setState({scrollPosition: 0});
|
||||||
|
if (this.messagesScroller) {
|
||||||
|
this.messagesScroller.scrollTop = this.messagesScroller.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleDescChange(desc) {
|
handleDescChange(desc) {
|
||||||
if (desc.public) {
|
if (desc.public) {
|
||||||
this.setState({
|
this.setState({
|
||||||
@@ -555,7 +585,11 @@ class MessagesView extends React.Component {
|
|||||||
// Scroll to the bottom if the message is added to the end of the message list.
|
// 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.
|
// TODO: This should be replaced by showing a "scroll to bottom" button.
|
||||||
if (topic.isNewMessage(msg.seq)) {
|
if (topic.isNewMessage(msg.seq)) {
|
||||||
this.setState({scrollPosition: 0});
|
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
|
// Aknowledge messages except own messages. They are
|
||||||
@@ -713,6 +747,10 @@ class MessagesView extends React.Component {
|
|||||||
if (file.size > maxInbandAttachmentSize) {
|
if (file.size > maxInbandAttachmentSize) {
|
||||||
// Too large to send inband - uploading out of band and sending as a link.
|
// Too large to send inband - uploading out of band and sending as a link.
|
||||||
const uploader = this.props.tinode.getLargeFileHelper();
|
const uploader = this.props.tinode.getLargeFileHelper();
|
||||||
|
if (!uploader) {
|
||||||
|
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const uploadCompletionPromise = uploader.upload(file);
|
const uploadCompletionPromise = uploader.upload(file);
|
||||||
const msg = Drafty.attachFile(null, {
|
const msg = Drafty.attachFile(null, {
|
||||||
mime: file.type,
|
mime: file.type,
|
||||||
@@ -730,7 +768,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) {
|
handleAttachFile(file) {
|
||||||
const maxExternAttachmentSize = this.props.tinode.getServerLimit('maxFileUploadSize', MAX_EXTERN_ATTACHMENT_SIZE);
|
const maxExternAttachmentSize = this.props.tinode.getServerLimit('maxFileUploadSize', MAX_EXTERN_ATTACHMENT_SIZE);
|
||||||
|
|
||||||
@@ -815,7 +853,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) {
|
handleAttachImage(file) {
|
||||||
const maxExternAttachmentSize = this.props.tinode.getServerLimit('maxFileUploadSize', MAX_EXTERN_ATTACHMENT_SIZE);
|
const maxExternAttachmentSize = this.props.tinode.getServerLimit('maxFileUploadSize', MAX_EXTERN_ATTACHMENT_SIZE);
|
||||||
|
|
||||||
@@ -836,6 +874,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) {
|
handleCancelUpload(seq, uploader) {
|
||||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||||
const found = topic.findMessage(seq);
|
const found = topic.findMessage(seq);
|
||||||
@@ -950,9 +1029,12 @@ class MessagesView extends React.Component {
|
|||||||
icon_badges.push({icon: 'dangerous', color: 'badge-inv'});
|
icon_badges.push({icon: 'dangerous', color: 'badge-inv'});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let messageNodes = [];
|
|
||||||
|
const messageNodes = [];
|
||||||
let previousFrom = null;
|
let previousFrom = null;
|
||||||
|
let prevDate = null;
|
||||||
let chatBoxClass = null;
|
let chatBoxClass = null;
|
||||||
|
const dateFmt = new Intl.DateTimeFormat(this.props.intl.locale);
|
||||||
topic.messages((msg, prev, next, i) => {
|
topic.messages((msg, prev, next, i) => {
|
||||||
let nextFrom = next ? (next.from || 'chan') : null;
|
let nextFrom = next ? (next.from || 'chan') : null;
|
||||||
|
|
||||||
@@ -987,36 +1069,55 @@ class MessagesView extends React.Component {
|
|||||||
replyToSeq = null;
|
replyToSeq = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
messageNodes.push(
|
if (msg.hi) {
|
||||||
<ChatMessage
|
// Deleted message.
|
||||||
tinode={this.props.tinode}
|
messageNodes.push(
|
||||||
content={msg.content}
|
<MetaMessage
|
||||||
deleted={msg.hi}
|
deleted={true}
|
||||||
mimeType={msg.head ? msg.head.mime : null}
|
key={msg.seq} />
|
||||||
timestamp={msg.ts}
|
);
|
||||||
response={isReply}
|
} else {
|
||||||
seq={msg.seq}
|
const thisDate = new Date(msg.ts);
|
||||||
isGroup={groupTopic}
|
// This message was sent on a different date than the previous.
|
||||||
isChan={this.state.channel}
|
if (!prevDate || prevDate.toDateString() != thisDate.toDateString()) {
|
||||||
userFrom={userFrom}
|
messageNodes.push(
|
||||||
userName={userName}
|
<MetaMessage
|
||||||
userAvatar={userAvatar}
|
date={dateFmt.format(msg.ts)}
|
||||||
sequence={sequence}
|
locale={this.props.intl.locale}
|
||||||
received={deliveryStatus}
|
key={'date-' + msg.seq} />
|
||||||
uploader={msg._uploader}
|
);
|
||||||
viewportWidth={this.props.viewportWidth} // Used by `formatter`.
|
prevDate = thisDate;
|
||||||
showContextMenu={this.handleShowMessageContextMenu}
|
}
|
||||||
onImagePreview={this.handleImagePostview}
|
messageNodes.push(
|
||||||
onFormResponse={this.handleFormResponse}
|
<ChatMessage
|
||||||
onError={this.props.onError}
|
tinode={this.props.tinode}
|
||||||
onCancelUpload={this.handleCancelUpload}
|
content={msg.content}
|
||||||
pickReply={this.handlePickReply}
|
mimeType={msg.head ? msg.head.mime : null}
|
||||||
replyToSeq={replyToSeq}
|
timestamp={msg.ts}
|
||||||
onQuoteClick={this.handleQuoteClick}
|
response={isReply}
|
||||||
ref={ref}
|
seq={msg.seq}
|
||||||
userIsWriter={this.state.isWriter}
|
isGroup={groupTopic}
|
||||||
key={msg.seq} />
|
isChan={this.state.channel}
|
||||||
);
|
userFrom={userFrom}
|
||||||
|
userName={userName}
|
||||||
|
userAvatar={userAvatar}
|
||||||
|
sequence={sequence}
|
||||||
|
received={deliveryStatus}
|
||||||
|
uploader={msg._uploader}
|
||||||
|
viewportWidth={this.props.viewportWidth} // Used by `formatter`.
|
||||||
|
showContextMenu={this.handleShowMessageContextMenu}
|
||||||
|
onImagePreview={this.handleImagePostview}
|
||||||
|
onFormResponse={this.handleFormResponse}
|
||||||
|
onError={this.props.onError}
|
||||||
|
onCancelUpload={this.handleCancelUpload}
|
||||||
|
pickReply={this.handlePickReply}
|
||||||
|
replyToSeq={replyToSeq}
|
||||||
|
onQuoteClick={this.handleQuoteClick}
|
||||||
|
ref={ref}
|
||||||
|
userIsWriter={this.state.isWriter}
|
||||||
|
key={msg.seq} />
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let lastSeen = null;
|
let lastSeen = null;
|
||||||
@@ -1035,7 +1136,10 @@ class MessagesView extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const avatar = this.state.avatar || true;
|
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 = (
|
component2 = (
|
||||||
<>
|
<>
|
||||||
@@ -1051,11 +1155,12 @@ class MessagesView extends React.Component {
|
|||||||
tinode={this.props.tinode}
|
tinode={this.props.tinode}
|
||||||
avatar={avatar}
|
avatar={avatar}
|
||||||
topic={this.state.topic}
|
topic={this.state.topic}
|
||||||
title={this.state.title} />
|
title={this.state.title}
|
||||||
|
deleted={this.state.deleted} />
|
||||||
{!isChannel ? <span className={online} /> : null}
|
{!isChannel ? <span className={online} /> : null}
|
||||||
</div>
|
</div>
|
||||||
<div id="topic-title-group">
|
<div id="topic-title-group">
|
||||||
<div id="topic-title" className="panel-title">{
|
<div id="topic-title" className={titleClass}>{
|
||||||
this.state.title ||
|
this.state.title ||
|
||||||
<i><FormattedMessage id="unnamed_topic" defaultMessage="Unnamed"
|
<i><FormattedMessage id="unnamed_topic" defaultMessage="Unnamed"
|
||||||
description="Title shown when the topic has no name" /></i>
|
description="Title shown when the topic has no name" /></i>
|
||||||
@@ -1082,6 +1187,10 @@ class MessagesView extends React.Component {
|
|||||||
: null}
|
: null}
|
||||||
<LoadSpinner show={this.state.fetchingMessages} />
|
<LoadSpinner show={this.state.fetchingMessages} />
|
||||||
<div id="messages-container">
|
<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}>
|
<div id="messages-panel" ref={this.handleScrollReference}>
|
||||||
<ul id="scroller" className={chatBoxClass}>
|
<ul id="scroller" className={chatBoxClass}>
|
||||||
{messageNodes}
|
{messageNodes}
|
||||||
@@ -1111,12 +1220,14 @@ class MessagesView extends React.Component {
|
|||||||
:
|
:
|
||||||
<SendMessage
|
<SendMessage
|
||||||
tinode={this.props.tinode}
|
tinode={this.props.tinode}
|
||||||
|
topicName={this.state.topic}
|
||||||
noInput={!!this.props.forwardMessage}
|
noInput={!!this.props.forwardMessage}
|
||||||
disabled={!this.state.isWriter}
|
disabled={!this.state.isWriter || this.state.deleted}
|
||||||
onKeyPress={this.sendKeyPress}
|
onKeyPress={this.sendKeyPress}
|
||||||
onSendMessage={this.sendMessage}
|
onSendMessage={this.sendMessage}
|
||||||
onAttachFile={this.props.forwardMessage ? null : this.handleAttachFile}
|
onAttachFile={this.props.forwardMessage ? null : this.handleAttachFile}
|
||||||
onAttachImage={this.props.forwardMessage ? null : this.handleAttachImage}
|
onAttachImage={this.props.forwardMessage ? null : this.handleAttachImage}
|
||||||
|
onAttachAudio={this.props.forwardMessage ? null : this.sendAudioAttachment}
|
||||||
onError={this.props.onError}
|
onError={this.props.onError}
|
||||||
reply={this.state.reply}
|
reply={this.state.reply}
|
||||||
onQuoteClick={this.handleQuoteClick}
|
onQuoteClick={this.handleQuoteClick}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
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 ContactList from '../widgets/contact-list.jsx';
|
||||||
import NewTopicById from '../widgets/new-topic-by-id.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.handleTabClick = this.handleTabClick.bind(this);
|
||||||
this.handleSearchContacts = this.handleSearchContacts.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.handleNewGroupSubmit = this.handleNewGroupSubmit.bind(this);
|
||||||
this.handleGroupByID = this.handleGroupByID.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});
|
this.setState({searchQuery: Tinode.isNullValue(query) ? null : query});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleContactSelected(uid) {
|
handleSearchResultSelected(name) {
|
||||||
if (this.state.tabSelected == 'find') {
|
if (this.state.tabSelected == 'find') {
|
||||||
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
|
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}
|
showOnline={false}
|
||||||
showUnread={false}
|
showUnread={false}
|
||||||
showContextMenu={false}
|
showContextMenu={false}
|
||||||
onTopicSelected={this.handleContactSelected} />
|
onTopicSelected={this.handleSearchResultSelected} />
|
||||||
</div>}
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+135
-122
@@ -2,10 +2,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||||
|
|
||||||
import * as firebase from 'firebase/app';
|
import { initializeApp as firebaseInitApp } from 'firebase/app';
|
||||||
import 'firebase/messaging';
|
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 Alert from '../widgets/alert.jsx';
|
||||||
import ContextMenu from '../widgets/context-menu.jsx';
|
import ContextMenu from '../widgets/context-menu.jsx';
|
||||||
@@ -114,8 +115,7 @@ class TinodeWeb extends React.Component {
|
|||||||
this.handleCredDelete = this.handleCredDelete.bind(this);
|
this.handleCredDelete = this.handleCredDelete.bind(this);
|
||||||
this.handleCredConfirm = this.handleCredConfirm.bind(this);
|
this.handleCredConfirm = this.handleCredConfirm.bind(this);
|
||||||
this.initFCMessaging = this.initFCMessaging.bind(this);
|
this.initFCMessaging = this.initFCMessaging.bind(this);
|
||||||
this.togglePushToken = this.togglePushToken.bind(this);
|
this.toggleFCMToken = this.toggleFCMToken.bind(this);
|
||||||
this.requestPushToken = this.requestPushToken.bind(this);
|
|
||||||
this.handlePushMessage = this.handlePushMessage.bind(this);
|
this.handlePushMessage = this.handlePushMessage.bind(this);
|
||||||
this.handleSidepanelCancel = this.handleSidepanelCancel.bind(this);
|
this.handleSidepanelCancel = this.handleSidepanelCancel.bind(this);
|
||||||
this.handleStartTopicRequest = this.handleStartTopicRequest.bind(this);
|
this.handleStartTopicRequest = this.handleStartTopicRequest.bind(this);
|
||||||
@@ -164,9 +164,11 @@ class TinodeWeb extends React.Component {
|
|||||||
// "On" is the default, so saving the "off" state.
|
// "On" is the default, so saving the "off" state.
|
||||||
messageSounds: !settings.messageSoundsOff,
|
messageSounds: !settings.messageSoundsOff,
|
||||||
incognitoMode: false,
|
incognitoMode: false,
|
||||||
|
// Persistent request to enable alerts.
|
||||||
desktopAlerts: persist && !!settings.desktopAlerts,
|
desktopAlerts: persist && !!settings.desktopAlerts,
|
||||||
|
// Enable / disable checkbox.
|
||||||
desktopAlertsEnabled: (isSecureConnection() || isLocalHost()) &&
|
desktopAlertsEnabled: (isSecureConnection() || isLocalHost()) &&
|
||||||
(typeof firebase != 'undefined') && (typeof navigator != 'undefined') &&
|
(typeof firebaseInitApp != 'undefined') && (typeof navigator != 'undefined') &&
|
||||||
(typeof FIREBASE_INIT != 'undefined'),
|
(typeof FIREBASE_INIT != 'undefined'),
|
||||||
firebaseToken: persist ? LocalStorageUtil.getObject('firebase-token') : null,
|
firebaseToken: persist ? LocalStorageUtil.getObject('firebase-token') : null,
|
||||||
|
|
||||||
@@ -257,11 +259,7 @@ class TinodeWeb extends React.Component {
|
|||||||
if (this.state.desktopAlertsEnabled) {
|
if (this.state.desktopAlertsEnabled) {
|
||||||
this.initFCMessaging().then(() => {
|
this.initFCMessaging().then(() => {
|
||||||
if (this.state.desktopAlerts) {
|
if (this.state.desktopAlerts) {
|
||||||
if (!this.state.firebaseToken) {
|
this.tinode.setDeviceToken(this.state.firebaseToken);
|
||||||
this.togglePushToken(true);
|
|
||||||
} else {
|
|
||||||
this.tinode.setDeviceToken(this.state.firebaseToken);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// do nothing: handled earlier.
|
// do nothing: handled earlier.
|
||||||
@@ -317,11 +315,22 @@ class TinodeWeb extends React.Component {
|
|||||||
return tinode;
|
return tinode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notifiy Tinode that a push message was received from the server.
|
// Tinode received a push notification from the server.
|
||||||
handlePushMessage(data) {
|
handlePushMessage(payload) {
|
||||||
if (data.what == 'msg' && Tinode.isChannelTopicName(data.topic)) {
|
const data = payload.data || {};
|
||||||
// The last argument is a fake user Id: otherwise the update is seen as one from the current user.
|
switch (data.what) {
|
||||||
this.tinode.oobNotification(data.topic, data.seq, 'fake-uid');
|
case 'msg':
|
||||||
|
if (Tinode.isChannelTopicName(data.topic)) {
|
||||||
|
// The last argument is a fake user Id: otherwise the update is seen as one from the current user.
|
||||||
|
this.tinode.oobNotification('msg', data.topic, data.seq, 'fake-uid');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'read':
|
||||||
|
// Message read on another device, clear it here.
|
||||||
|
this.tinode.oobNotification('read', data.topic, data.seq);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn("Unknown push type ignored", data.what, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,40 +339,77 @@ class TinodeWeb extends React.Component {
|
|||||||
const onError = (msg, err) => {
|
const onError = (msg, err) => {
|
||||||
console.error(msg, err);
|
console.error(msg, err);
|
||||||
this.handleError(formatMessage(messages.push_init_failed), '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 {
|
try {
|
||||||
this.fbPush = firebase.initializeApp(FIREBASE_INIT, APP_NAME).messaging();
|
this.fcm = firebaseGetMessaging(firebaseInitApp(FIREBASE_INIT, APP_NAME));
|
||||||
this.fbPush.usePublicVapidKey(FIREBASE_INIT.messagingVapidKey);
|
|
||||||
|
|
||||||
return navigator.serviceWorker.register('/service-worker.js').then((reg) => {
|
return navigator.serviceWorker.register('/service-worker.js').then((reg) => {
|
||||||
this.checkForAppUpdate(reg);
|
this.checkForAppUpdate(reg);
|
||||||
this.fbPush.useServiceWorker(reg);
|
|
||||||
reg.active.postMessage(JSON.stringify({locale: locale, version: PACKAGE_VERSION}));
|
reg.active.postMessage(JSON.stringify({locale: locale, version: PACKAGE_VERSION}));
|
||||||
|
return reg;
|
||||||
// Google could not be bothered to mention that onTokenRefresh is never called.
|
}).then((reg) => {
|
||||||
this.fbPush.onTokenRefresh(() => {
|
// Request token.
|
||||||
this.requestPushToken();
|
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
|
// Handhe FCM pushes
|
||||||
// (a) for channels always,
|
// (a) for channels always,
|
||||||
// (b) pushes when the app is in foreground but has not focus.
|
// (b) pushes when the app is in foreground but has no focus.
|
||||||
this.fbPush.onMessage(payload => { this.handlePushMessage(payload.data); });
|
firebaseOnMessage(this.fcm, (payload) => { this.handlePushMessage(payload); });
|
||||||
|
|
||||||
return reg;
|
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
// registration failed :(
|
// SW registration or FCM has failed :(
|
||||||
onError("Failed to register service worker:", err);
|
onError(err);
|
||||||
return Promise.reject(err);
|
throw err;
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
onError("Failed to initialize push notifications", err);
|
onError(err);
|
||||||
return Promise.reject(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() {
|
handleResize() {
|
||||||
const mobile = document.documentElement.clientWidth <= MEDIA_BREAKPOINT;
|
const mobile = document.documentElement.clientWidth <= MEDIA_BREAKPOINT;
|
||||||
this.setState({
|
this.setState({
|
||||||
@@ -414,14 +460,10 @@ class TinodeWeb extends React.Component {
|
|||||||
// Clear invalid topic name.
|
// Clear invalid topic name.
|
||||||
topicName = null;
|
topicName = null;
|
||||||
}
|
}
|
||||||
const newState = {
|
this.setState({
|
||||||
topicSelected: topicName
|
topicSelected: topicName,
|
||||||
};
|
topicSelectedAcs: this.tinode.getTopicAccessMode(topicName)
|
||||||
const acs = this.tinode.getTopicAccessMode(topicName);
|
});
|
||||||
if (acs) {
|
|
||||||
newState.topicSelectedAcs = acs;
|
|
||||||
}
|
|
||||||
this.setState(newState);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Empty hashpath
|
// Empty hashpath
|
||||||
@@ -544,7 +586,7 @@ class TinodeWeb extends React.Component {
|
|||||||
this.handleError(
|
this.handleError(
|
||||||
formatMessage(messages.reconnect_countdown, {seconds: timeLeft}),
|
formatMessage(messages.reconnect_countdown, {seconds: timeLeft}),
|
||||||
'warn',
|
'warn',
|
||||||
() => {
|
_ => {
|
||||||
clearInterval(this.reconnectCountdown);
|
clearInterval(this.reconnectCountdown);
|
||||||
this.tinode.reconnect();
|
this.tinode.reconnect();
|
||||||
},
|
},
|
||||||
@@ -865,7 +907,7 @@ class TinodeWeb extends React.Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// User clicked on a contact in the side panel or deleted a contact.
|
// User clicked on a topic in the side panel or deleted a topic.
|
||||||
handleTopicSelected(topicName) {
|
handleTopicSelected(topicName) {
|
||||||
// Clear newTopicParams after use.
|
// Clear newTopicParams after use.
|
||||||
if (this.state.newTopicParams && this.state.newTopicParams._topicName != topicName) {
|
if (this.state.newTopicParams && this.state.newTopicParams._topicName != topicName) {
|
||||||
@@ -881,7 +923,7 @@ class TinodeWeb extends React.Component {
|
|||||||
mobilePanel: 'topic-view',
|
mobilePanel: 'topic-view',
|
||||||
infoPanel: undefined
|
infoPanel: undefined
|
||||||
});
|
});
|
||||||
// Different contact selected.
|
// Different topic selected.
|
||||||
if (this.state.topicSelected != topicName) {
|
if (this.state.topicSelected != topicName) {
|
||||||
this.setState({
|
this.setState({
|
||||||
topicSelectedOnline: this.tinode.isTopicOnline(topicName),
|
topicSelectedOnline: this.tinode.isTopicOnline(topicName),
|
||||||
@@ -918,14 +960,14 @@ class TinodeWeb extends React.Component {
|
|||||||
// with attachments.
|
// with attachments.
|
||||||
// - msg - Drafty message with content
|
// - msg - Drafty message with content
|
||||||
// - promise - Promise to be resolved when the upload is completed
|
// - promise - Promise to be resolved when the upload is completed
|
||||||
// - uploader - for tracking progress
|
// - uploadCompletionPromise - for tracking progress
|
||||||
// - head - head dictionary to be attached to the message
|
// - head - head dictionary to be attached to the message
|
||||||
handleSendMessage(msg, promise, uploader, head) {
|
handleSendMessage(msg, uploadCompletionPromise, uploader, head) {
|
||||||
const topic = this.tinode.getTopic(this.state.topicSelected);
|
const topic = this.tinode.getTopic(this.state.topicSelected);
|
||||||
this.sendMessageToTopic(topic, msg, promise, uploader, head);
|
this.sendMessageToTopic(topic, msg, uploadCompletionPromise, uploader, head);
|
||||||
}
|
}
|
||||||
|
|
||||||
sendMessageToTopic(topic, msg, promise, uploader, head) {
|
sendMessageToTopic(topic, msg, uploadCompletionPromise, uploader, head) {
|
||||||
msg = topic.createMessage(msg, false);
|
msg = topic.createMessage(msg, false);
|
||||||
// The uploader is used to show progress.
|
// The uploader is used to show progress.
|
||||||
msg._uploader = uploader;
|
msg._uploader = uploader;
|
||||||
@@ -934,33 +976,33 @@ class TinodeWeb extends React.Component {
|
|||||||
msg.head = Object.assign(msg.head || {}, head);
|
msg.head = Object.assign(msg.head || {}, head);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const completion = [];
|
||||||
|
if (uploadCompletionPromise) {
|
||||||
|
completion.push(uploadCompletionPromise);
|
||||||
|
}
|
||||||
|
|
||||||
if (!topic.isSubscribed()) {
|
if (!topic.isSubscribed()) {
|
||||||
// Topic is not subscribed yet. Subscribe.
|
// Topic is not subscribed yet. Subscribe.
|
||||||
if (!promise) {
|
const subscribePromise =
|
||||||
promise = Promise.resolve();
|
topic.subscribe()
|
||||||
}
|
.then(() => {
|
||||||
promise = promise
|
// If there are unsent messages, try sending them now.
|
||||||
.then(() => topic.subscribe())
|
topic.queuedMessages(pub => {
|
||||||
.then(() => {
|
if (pub._sending || pub.seq == msg.seq) {
|
||||||
// If there are unsent messages, try sending them now.
|
return;
|
||||||
topic.queuedMessages((pub) => {
|
}
|
||||||
if (!pub._sending && topic.isSubscribed()) {
|
if (topic.isSubscribed()) {
|
||||||
this.sendMessage(pub);
|
topic.publishMessage(pub);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
completion.push(subscribePromise);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (promise) {
|
topic.publishDraft(msg, Promise.all(completion))
|
||||||
promise = promise.catch((err) => {
|
.then(_ => {
|
||||||
this.handleError(err.message, 'err');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
topic.publishDraft(msg, promise)
|
|
||||||
.then((ctrl) => {
|
|
||||||
if (topic.isArchived()) {
|
if (topic.isArchived()) {
|
||||||
return topic.archive(false);
|
topic.archive(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -1101,31 +1143,19 @@ class TinodeWeb extends React.Component {
|
|||||||
this.state.myUserId ? 'blocked' : ''));
|
this.state.myUserId ? 'blocked' : ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
togglePushToken(enabled) {
|
toggleFCMToken(enabled) {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
this.setState({desktopAlerts: null});
|
this.setState({desktopAlerts: null});
|
||||||
if (!this.state.firebaseToken) {
|
if (!this.state.firebaseToken) {
|
||||||
const fcm = this.fbPush ?
|
this.initFCMessaging();
|
||||||
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 {
|
} else {
|
||||||
this.setState({desktopAlerts: true});
|
this.setState({desktopAlerts: true});
|
||||||
if (LocalStorageUtil.getObject('keep-logged-in')) {
|
if (LocalStorageUtil.getObject('keep-logged-in')) {
|
||||||
LocalStorageUtil.updateObject('settings', {desktopAlerts: true});
|
LocalStorageUtil.updateObject('settings', {desktopAlerts: true});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (this.state.firebaseToken && this.fbPush) {
|
} else if (this.state.firebaseToken && this.fcm) {
|
||||||
this.fbPush.deleteToken(this.state.firebaseToken).catch((err) => {
|
firebaseDelToken(this.fcm).catch((err) => {
|
||||||
console.error("Unable to delete token.", err);
|
console.error("Unable to delete token.", err);
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
LocalStorageUtil.updateObject('settings', {desktopAlerts: false});
|
LocalStorageUtil.updateObject('settings', {desktopAlerts: false});
|
||||||
@@ -1140,25 +1170,6 @@ class TinodeWeb extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
requestPushToken() {
|
|
||||||
this.fbPush.getToken().then((refreshedToken) => {
|
|
||||||
const persist = LocalStorageUtil.getObject('keep-logged-in');
|
|
||||||
if (refreshedToken != this.state.firebaseToken) {
|
|
||||||
this.tinode.setDeviceToken(refreshedToken);
|
|
||||||
if (persist) {
|
|
||||||
LocalStorageUtil.setObject('firebase-token', refreshedToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.setState({firebaseToken: refreshedToken, desktopAlerts: true});
|
|
||||||
if (persist) {
|
|
||||||
LocalStorageUtil.updateObject('settings', {desktopAlerts: true});
|
|
||||||
}
|
|
||||||
}).catch((err) => {
|
|
||||||
this.handleError(err.message, 'err');
|
|
||||||
console.error("Failed to retrieve firebase token", err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleToggleMessageSounds(enabled) {
|
handleToggleMessageSounds(enabled) {
|
||||||
this.setState({messageSounds: enabled});
|
this.setState({messageSounds: enabled});
|
||||||
LocalStorageUtil.updateObject('settings', {
|
LocalStorageUtil.updateObject('settings', {
|
||||||
@@ -1338,24 +1349,30 @@ class TinodeWeb extends React.Component {
|
|||||||
localStorage.removeItem('firebase-token');
|
localStorage.removeItem('firebase-token');
|
||||||
localStorage.removeItem('settings');
|
localStorage.removeItem('settings');
|
||||||
if (this.state.firebaseToken) {
|
if (this.state.firebaseToken) {
|
||||||
this.fbPush.deleteToken(this.state.firebaseToken)
|
firebaseDelToken(this.fcm);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearInterval(this.reconnectCountdown);
|
clearInterval(this.reconnectCountdown);
|
||||||
|
|
||||||
|
let cleared;
|
||||||
if (this.tinode) {
|
if (this.tinode) {
|
||||||
this.tinode.clearStorage();
|
cleared = this.tinode.clearStorage();
|
||||||
this.tinode.onDisconnect = undefined;
|
this.tinode.onDisconnect = undefined;
|
||||||
this.tinode.disconnect();
|
this.tinode.disconnect();
|
||||||
|
} else {
|
||||||
|
cleared = Promose.resolve();
|
||||||
}
|
}
|
||||||
this.setState(this.getBlankState());
|
this.setState(this.getBlankState());
|
||||||
|
|
||||||
this.tinode = TinodeWeb.tnSetup(this.state.serverAddress,
|
cleared.then(() => {
|
||||||
this.state.transport, this.props.intl.locale, LocalStorageUtil.getObject('keep-logged-in'));
|
this.tinode = TinodeWeb.tnSetup(this.state.serverAddress,
|
||||||
this.tinode.onConnect = this.handleConnected;
|
this.state.transport, this.props.intl.locale, LocalStorageUtil.getObject('keep-logged-in'), () => {
|
||||||
this.tinode.onDisconnect = this.handleDisconnect;
|
this.tinode.onConnect = this.handleConnected;
|
||||||
this.tinode.onAutoreconnectIteration = this.handleAutoreconnectIteration;
|
this.tinode.onDisconnect = this.handleDisconnect;
|
||||||
HashNavigation.navigateTo('');
|
this.tinode.onAutoreconnectIteration = this.handleAutoreconnectIteration;
|
||||||
|
HashNavigation.navigateTo('');
|
||||||
|
})
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDeleteAccount() {
|
handleDeleteAccount() {
|
||||||
@@ -1454,15 +1471,11 @@ class TinodeWeb extends React.Component {
|
|||||||
this.handleSidepanelCancel();
|
this.handleSidepanelCancel();
|
||||||
}
|
}
|
||||||
const header = '➦ ' + params.userName;
|
const header = '➦ ' + params.userName;
|
||||||
const content = typeof params.content == 'string' ?
|
const content = typeof params.content == 'string' ? Drafty.init(params.content) : Drafty.forwardedContent(params.content);
|
||||||
Tinode.Drafty.init(params.content) : Tinode.Drafty.forwardedContent(params.content);
|
const preview = Drafty.preview(content, FORWARDED_PREVIEW_LENGTH, true);
|
||||||
const preview = Tinode.Drafty.preview(content, FORWARDED_PREVIEW_LENGTH, true);
|
const msg = Drafty.append(Drafty.appendLineBreak(Drafty.mention(header, params.userFrom)), content);
|
||||||
const msg = Tinode.Drafty.append(
|
const msgPreview = Drafty.quote(header, params.userFrom, preview);
|
||||||
Tinode.Drafty.appendLineBreak(
|
|
||||||
Tinode.Drafty.mention(header, params.userFrom)),
|
|
||||||
content);
|
|
||||||
|
|
||||||
const msgPreview = Tinode.Drafty.quote(header, params.userFrom, preview);
|
|
||||||
const head = {
|
const head = {
|
||||||
forwarded: params.topicName + ':' + params.seq
|
forwarded: params.topicName + ':' + params.seq
|
||||||
};
|
};
|
||||||
@@ -1714,7 +1727,7 @@ class TinodeWeb extends React.Component {
|
|||||||
onUpdateAccountDesc={this.handleTopicUpdateRequest}
|
onUpdateAccountDesc={this.handleTopicUpdateRequest}
|
||||||
onUpdatePassword={this.handleUpdatePasswordRequest}
|
onUpdatePassword={this.handleUpdatePasswordRequest}
|
||||||
onUpdateAccountTags={this.handleUpdateAccountTagsRequest}
|
onUpdateAccountTags={this.handleUpdateAccountTagsRequest}
|
||||||
onTogglePushNotifications={this.togglePushToken}
|
onTogglePushNotifications={this.toggleFCMToken}
|
||||||
onToggleMessageSounds={this.handleToggleMessageSounds}
|
onToggleMessageSounds={this.handleToggleMessageSounds}
|
||||||
onToggleIncognitoMode={this.handleToggleIncognitoMode}
|
onToggleIncognitoMode={this.handleToggleIncognitoMode}
|
||||||
onCredAdd={this.handleCredAdd}
|
onCredAdd={this.handleCredAdd}
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
// Audio recorder widget.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
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 = '#888A';
|
||||||
|
const BAR_COLOR_DARK = '#666C';
|
||||||
|
const THUMB_COLOR = '#444E';
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ratio 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 || !this.audioPlayer) {
|
||||||
|
// 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 playback 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 dstCount = ((this.canvasRef.current.width - SPACING) / (LINE_WIDTH + SPACING)) | 0;
|
||||||
|
// Remove extra padding on the right due to fractional bar which is not drawn.
|
||||||
|
this.effectiveWidth = dstCount * (LINE_WIDTH + SPACING) + SPACING;
|
||||||
|
|
||||||
|
if (!Array.isArray(original) || original.length == 0) {
|
||||||
|
return Array.apply(null, Array(dstCount)).map(_ => 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
const factor = original.length / dstCount;
|
||||||
|
let amps = [];
|
||||||
|
let maxAmp = -1;
|
||||||
|
for (let i=0; i<dstCount; i++) {
|
||||||
|
let lo = (i * factor) | 0; // low bound;
|
||||||
|
let hi = ((i + 1) * factor) | 0; // high bound;
|
||||||
|
if (hi == lo) {
|
||||||
|
amps[i] = original[lo];
|
||||||
|
} else {
|
||||||
|
let amp = 0.0;
|
||||||
|
for (let j=lo; j<hi; j++) {
|
||||||
|
amp += original[j]
|
||||||
|
}
|
||||||
|
amps[i] = Math.max(0, amp / (hi - lo));
|
||||||
|
}
|
||||||
|
maxAmp = Math.max(amps[i], maxAmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxAmp > 0) {
|
||||||
|
return amps.map(a => a / maxAmp);
|
||||||
|
}
|
||||||
|
return Array.apply(null, Array(dstCount)).map(_ => 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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 ?
|
||||||
|
<>
|
||||||
|
<canvas className="playback" ref={this.canvasRef} onClick={this.handleSeek} />
|
||||||
|
{play}
|
||||||
|
</>
|
||||||
|
:
|
||||||
|
<>
|
||||||
|
{play}
|
||||||
|
<div>
|
||||||
|
<canvas className="playback" ref={this.canvasRef} onClick={this.handleSeek} />
|
||||||
|
<div className="timer">{this.state.currentTime}/{this.state.duration}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
// 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';
|
||||||
|
import { MAX_DURATION, MIN_DURATION } from '../config.js';
|
||||||
|
|
||||||
|
// 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 = '#BBBD';
|
||||||
|
// Vertical scaling for visualization bars.
|
||||||
|
const BAR_SCALE = 64.0;
|
||||||
|
// 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 LetterTile from './letter-tile.jsx';
|
||||||
import LoadSpinner from './load-spinner.jsx';
|
import LoadSpinner from './load-spinner.jsx';
|
||||||
|
|
||||||
import { sanitizeImageUrl } from '../lib/utils.js';
|
import { sanitizeUrlForMime } from '../lib/utils.js';
|
||||||
|
|
||||||
import { AVATAR_SIZE } from '../config.js';
|
import { AVATAR_SIZE } from '../config.js';
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ export default class AvatarUpload extends React.Component {
|
|||||||
<i className="material-icons">clear</i>
|
<i className="material-icons">clear</i>
|
||||||
</a>}
|
</a>}
|
||||||
{this.state.source ?
|
{this.state.source ?
|
||||||
<img src={this.props.tinode.authorizeURL(sanitizeImageUrl(this.state.source))} className="preview" /> :
|
<img src={this.props.tinode.authorizeURL(sanitizeUrlForMime(this.state.source, 'image'))} className="preview" /> :
|
||||||
this.props.readOnly && this.props.uid ?
|
this.props.readOnly && this.props.uid ?
|
||||||
<div className="avatar-box">
|
<div className="avatar-box">
|
||||||
<LetterTile
|
<LetterTile
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// Single message, sent or received.
|
// Single chat message bubble, sent or received.
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage, injectIntl } from 'react-intl';
|
import { FormattedMessage, injectIntl } from 'react-intl';
|
||||||
import { Drafty } from 'tinode-sdk';
|
import { Drafty, Tinode } from 'tinode-sdk';
|
||||||
|
|
||||||
import Attachment from './attachment.jsx';
|
import Attachment from './attachment.jsx';
|
||||||
import LetterTile from './letter-tile.jsx';
|
import LetterTile from './letter-tile.jsx';
|
||||||
@@ -110,11 +111,10 @@ class BaseChatMessage extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const sideClass = this.props.deleted ? 'center' :
|
const sideClass = this.props.sequence + ' ' + (this.props.response ? 'left' : 'right');
|
||||||
(this.props.sequence + ' ' + (this.props.response ? 'left' : 'right'));
|
|
||||||
const bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ? 'bubble tip' : 'bubble';
|
const bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ? 'bubble tip' : 'bubble';
|
||||||
const avatar = this.props.deleted ? null : (this.props.userAvatar || true);
|
const avatar = this.props.userAvatar || true;
|
||||||
const fullDisplay = (this.props.isGroup && this.props.response && !this.props.deleted &&
|
const fullDisplay = (this.props.isGroup && this.props.response &&
|
||||||
(this.props.sequence == 'single' || this.props.sequence == 'last'));
|
(this.props.sequence == 'single' || this.props.sequence == 'last'));
|
||||||
|
|
||||||
let content = this.props.content;
|
let content = this.props.content;
|
||||||
@@ -140,12 +140,6 @@ class BaseChatMessage extends React.PureComponent {
|
|||||||
}, this);
|
}, this);
|
||||||
const tree = Drafty.format(content, fullFormatter, this.formatterContext);
|
const tree = Drafty.format(content, fullFormatter, this.formatterContext);
|
||||||
content = React.createElement(React.Fragment, null, tree);
|
content = React.createElement(React.Fragment, null, tree);
|
||||||
} else 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></>
|
|
||||||
} else if (typeof content != 'string') {
|
} else if (typeof content != 'string') {
|
||||||
content = <><i className="material-icons gray">warning_amber</i> <i className="gray">
|
content = <><i className="material-icons gray">warning_amber</i> <i className="gray">
|
||||||
<FormattedMessage id="invalid_content"
|
<FormattedMessage id="invalid_content"
|
||||||
@@ -178,13 +172,12 @@ class BaseChatMessage extends React.PureComponent {
|
|||||||
received={this.props.received} />
|
received={this.props.received} />
|
||||||
: null}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
{this.props.deleted || !this.props.showContextMenu ?
|
{this.props.showContextMenu ?
|
||||||
null :
|
|
||||||
<span className="menuTrigger">
|
<span className="menuTrigger">
|
||||||
<a href="#" onClick={this.handleContextClick}>
|
<a href="#" onClick={this.handleContextClick}>
|
||||||
<i className="material-icons">expand_more</i>
|
<i className="material-icons">expand_more</i>
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span> : null
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
{fullDisplay ?
|
{fullDisplay ?
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
|
|
||||||
import { Drafty } from 'tinode-sdk';
|
import { Drafty, Tinode } from 'tinode-sdk';
|
||||||
import Tinode from 'tinode-sdk';
|
|
||||||
|
|
||||||
import Contact from './contact.jsx';
|
import Contact from './contact.jsx';
|
||||||
import ContactAction from './contact-action.jsx';
|
import ContactAction from './contact-action.jsx';
|
||||||
@@ -115,6 +114,7 @@ class ContactList extends React.Component {
|
|||||||
isVerified={c.trusted && c.trusted.verified}
|
isVerified={c.trusted && c.trusted.verified}
|
||||||
isStaff={c.trusted && c.trusted.staff}
|
isStaff={c.trusted && c.trusted.staff}
|
||||||
isDangerous={c.trusted && c.trusted.danger}
|
isDangerous={c.trusted && c.trusted.danger}
|
||||||
|
deleted={c._deleted}
|
||||||
item={key}
|
item={key}
|
||||||
index={contactNodes.length}
|
index={contactNodes.length}
|
||||||
key={key} />
|
key={key} />
|
||||||
|
|||||||
+10
-7
@@ -4,7 +4,6 @@ import { FormattedMessage, injectIntl } from 'react-intl';
|
|||||||
|
|
||||||
import ContactBadges from './contact-badges.jsx';
|
import ContactBadges from './contact-badges.jsx';
|
||||||
import LetterTile from './letter-tile.jsx';
|
import LetterTile from './letter-tile.jsx';
|
||||||
import ReceivedMarker from './received-marker.jsx'
|
|
||||||
import UnreadBadge from './unread-badge.jsx';
|
import UnreadBadge from './unread-badge.jsx';
|
||||||
|
|
||||||
import { Drafty } from 'tinode-sdk';
|
import { Drafty } from 'tinode-sdk';
|
||||||
@@ -87,6 +86,7 @@ class Contact extends React.Component {
|
|||||||
const icon = deliveryMarker(this.props.received);
|
const icon = deliveryMarker(this.props.received);
|
||||||
const marker = icon ? <i className={'material-icons small space-right' +
|
const marker = icon ? <i className={'material-icons small space-right' +
|
||||||
(icon.color ? ' ' + icon.color : '')}>{icon.name}</i> : null;
|
(icon.color ? ' ' + icon.color : '')}>{icon.name}</i> : null;
|
||||||
|
const titleClass = 'contact-title' + (this.props.deleted ? ' deleted' : '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={!this.props.showCheckmark && this.props.selected ? 'selected' : null} onClick={this.handleClick}>
|
<li className={!this.props.showCheckmark && this.props.selected ? 'selected' : null} onClick={this.handleClick}>
|
||||||
@@ -95,16 +95,19 @@ class Contact extends React.Component {
|
|||||||
tinode={this.props.tinode}
|
tinode={this.props.tinode}
|
||||||
avatar={avatar}
|
avatar={avatar}
|
||||||
title={this.props.title}
|
title={this.props.title}
|
||||||
topic={this.props.item} />
|
topic={this.props.item}
|
||||||
{this.props.showOnline ? <span className={online} /> :
|
deleted={this.props.deleted} />
|
||||||
(this.props.showCheckmark && this.props.selected ?
|
{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>
|
<i className="checkmark material-icons">check_circle</i>
|
||||||
: null)}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-box">
|
<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}
|
{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>
|
</div>
|
||||||
{this.props.showMode ?
|
{this.props.showMode ?
|
||||||
<span><ContactBadges badges={badges} /></span> :
|
<span><ContactBadges badges={badges} /></span> :
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ class ContextMenu extends React.Component {
|
|||||||
(() => {
|
(() => {
|
||||||
const topic = this.props.tinode.getTopic(params.topicName);
|
const topic = this.props.tinode.getTopic(params.topicName);
|
||||||
if (!topic) {
|
if (!topic) {
|
||||||
console.log("Topic not found: ", params.topicName);
|
console.warn("Topic not found: ", params.topicName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return topic.delTopic(true).catch((err) => {
|
return topic.delTopic(true).catch((err) => {
|
||||||
@@ -258,7 +258,7 @@ class ContextMenu extends React.Component {
|
|||||||
handler: (params, errorHandler) => {
|
handler: (params, errorHandler) => {
|
||||||
const topic = this.props.tinode.getTopic(params.topicName);
|
const topic = this.props.tinode.getTopic(params.topicName);
|
||||||
if (!topic) {
|
if (!topic) {
|
||||||
console.log("Topic not found: ", params.topicName);
|
console.warn("Topic not found: ", params.topicName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return topic.archive(true).catch((err) => {
|
return topic.archive(true).catch((err) => {
|
||||||
@@ -274,7 +274,7 @@ class ContextMenu extends React.Component {
|
|||||||
handler: (params, errorHandler) => {
|
handler: (params, errorHandler) => {
|
||||||
const topic = this.props.tinode.getTopic(params.topicName);
|
const topic = this.props.tinode.getTopic(params.topicName);
|
||||||
if (!topic) {
|
if (!topic) {
|
||||||
console.log("Topic not found: ", params.topicName);
|
console.warn("Topic not found: ", params.topicName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return topic.archive(false).catch((err) => {
|
return topic.archive(false).catch((err) => {
|
||||||
@@ -296,7 +296,7 @@ class ContextMenu extends React.Component {
|
|||||||
handler: (params, errorHandler) => {
|
handler: (params, errorHandler) => {
|
||||||
const topic = this.props.tinode.getTopic(params.topicName);
|
const topic = this.props.tinode.getTopic(params.topicName);
|
||||||
if (!topic || !params.user) {
|
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;
|
||||||
}
|
}
|
||||||
return topic.delSubscription(params.user).catch((err) => {
|
return topic.delSubscription(params.user).catch((err) => {
|
||||||
@@ -378,7 +378,7 @@ class ContextMenu extends React.Component {
|
|||||||
deleteMessages(all, hard, params, errorHandler) {
|
deleteMessages(all, hard, params, errorHandler) {
|
||||||
const topic = this.props.tinode.getTopic(params.topicName);
|
const topic = this.props.tinode.getTopic(params.topicName);
|
||||||
if (!topic) {
|
if (!topic) {
|
||||||
console.log("Topic not found: ", params.topicName);
|
console.warn("Topic not found: ", params.topicName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,7 +420,7 @@ class ContextMenu extends React.Component {
|
|||||||
topicPermissionSetter(mode, params, errorHandler) {
|
topicPermissionSetter(mode, params, errorHandler) {
|
||||||
const topic = this.props.tinode.getTopic(params.topicName);
|
const topic = this.props.tinode.getTopic(params.topicName);
|
||||||
if (!topic) {
|
if (!topic) {
|
||||||
console.log("Topic not found", params.topicName);
|
console.warn("Topic not found", params.topicName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import Tinode from 'tinode-sdk';
|
import { Tinode } from 'tinode-sdk';
|
||||||
|
|
||||||
import ContactList from './contact-list.jsx';
|
import ContactList from './contact-list.jsx';
|
||||||
import SearchContacts from './search-contacts.jsx';
|
import SearchContacts from './search-contacts.jsx';
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import Tinode from 'tinode-sdk';
|
import { Tinode } from 'tinode-sdk';
|
||||||
|
|
||||||
import { idToColorClass } from '../lib/strformat.js';
|
import { idToColorClass } from '../lib/strformat.js';
|
||||||
import { sanitizeImageUrl } from '../lib/utils.js';
|
import { sanitizeUrlForMime } from '../lib/utils.js';
|
||||||
|
|
||||||
export default class LetterTile extends React.PureComponent {
|
export default class LetterTile extends React.PureComponent {
|
||||||
render() {
|
render() {
|
||||||
let avatar;
|
let avatar;
|
||||||
if (this.props.avatar === true) {
|
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);
|
const iconColor = idToColorClass(this.props.topic, isGroup);
|
||||||
if (this.props.topic && this.props.title && this.props.title.trim()) {
|
if (this.props.topic && this.props.title && this.props.title.trim()) {
|
||||||
const letter = this.props.title.trim().charAt(0);
|
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>)
|
avatar = (<div className={className}><div>{letter}</div></div>)
|
||||||
} else {
|
} else {
|
||||||
const className = 'material-icons ' + iconColor;
|
const className = 'material-icons ' + iconColor + (this.props.deleted ? ' disabled' : '');
|
||||||
avatar = isGroup ?
|
avatar = isGroup ? <i className={className}>group</i> : <i className={className}>person</i>;
|
||||||
<i className={className}>group</i> : <i className={className}>person</i>;
|
|
||||||
}
|
}
|
||||||
} else if (this.props.avatar) {
|
} else if (this.props.avatar) {
|
||||||
const url = this.props.tinode.authorizeURL(sanitizeImageUrl(this.props.avatar));
|
const url = this.props.tinode.authorizeURL(sanitizeUrlForMime(this.props.avatar, 'image'));
|
||||||
// If avatar image is invalid, show a placeholder.
|
// If avatar image is invalid, show a placeholder.
|
||||||
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"}} />;
|
onError={(e)=>{e.target.onerror = null; e.target.src="../img/broken_image.png"}} />;
|
||||||
} else {
|
} else {
|
||||||
avatar = null;
|
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 React from 'react';
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
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';
|
import { deliveryMarker } from '../lib/utils.js';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
@@ -29,7 +28,7 @@ class ReceivedMarker extends React.PureComponent {
|
|||||||
} else if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
|
} else if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
|
||||||
timestamp = formatMessage(messages.message_sending_failed);
|
timestamp = formatMessage(messages.message_sending_failed);
|
||||||
} else {
|
} 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);
|
const icon = deliveryMarker(this.props.received);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import Tinode from 'tinode-sdk';
|
import { Tinode } from 'tinode-sdk';
|
||||||
|
|
||||||
export default class SearchContacts extends React.PureComponent {
|
export default class SearchContacts extends React.PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import React from 'react';
|
|||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
import { Drafty } from 'tinode-sdk';
|
import { Drafty } from 'tinode-sdk';
|
||||||
|
|
||||||
|
import AudioRecorder from './audio-recorder.jsx';
|
||||||
|
|
||||||
import { KEYPRESS_DELAY } from '../config.js';
|
import { KEYPRESS_DELAY } from '../config.js';
|
||||||
import { filePasted } from '../lib/blob-helpers.js';
|
import { filePasted } from '../lib/blob-helpers.js';
|
||||||
import { replyFormatter } from '../lib/formatters.js';
|
import { replyFormatter } from '../lib/formatters.js';
|
||||||
@@ -42,6 +44,8 @@ class SendMessage extends React.PureComponent {
|
|||||||
this.state = {
|
this.state = {
|
||||||
quote: null,
|
quote: null,
|
||||||
message: '',
|
message: '',
|
||||||
|
audioRec: false,
|
||||||
|
audioAvailable: !!navigator.mediaDevices.getUserMedia,
|
||||||
// Make initial keypress time as if it happened 5001 milliseconds in the past.
|
// Make initial keypress time as if it happened 5001 milliseconds in the past.
|
||||||
keypressTimestamp: new Date().getTime() - KEYPRESS_DELAY - 1
|
keypressTimestamp: new Date().getTime() - KEYPRESS_DELAY - 1
|
||||||
};
|
};
|
||||||
@@ -49,6 +53,7 @@ class SendMessage extends React.PureComponent {
|
|||||||
this.handlePasteEvent = this.handlePasteEvent.bind(this);
|
this.handlePasteEvent = this.handlePasteEvent.bind(this);
|
||||||
this.handleAttachImage = this.handleAttachImage.bind(this);
|
this.handleAttachImage = this.handleAttachImage.bind(this);
|
||||||
this.handleAttachFile = this.handleAttachFile.bind(this);
|
this.handleAttachFile = this.handleAttachFile.bind(this);
|
||||||
|
this.handleAttachAudio = this.handleAttachAudio.bind(this);
|
||||||
this.handleSend = this.handleSend.bind(this);
|
this.handleSend = this.handleSend.bind(this);
|
||||||
this.handleKeyPress = this.handleKeyPress.bind(this);
|
this.handleKeyPress = this.handleKeyPress.bind(this);
|
||||||
this.handleMessageTyping = this.handleMessageTyping.bind(this);
|
this.handleMessageTyping = this.handleMessageTyping.bind(this);
|
||||||
@@ -77,7 +82,9 @@ class SendMessage extends React.PureComponent {
|
|||||||
this.messageEditArea.focus();
|
this.messageEditArea.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prevProps.reply != this.props.reply) {
|
if (prevProps.topicName != this.props.topicName) {
|
||||||
|
this.setState({message: '', audioRec: false, quote: null});
|
||||||
|
} else if (prevProps.reply != this.props.reply) {
|
||||||
this.setState({quote: this.formatReply()});
|
this.setState({quote: this.formatReply()});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,7 +125,6 @@ class SendMessage extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleAttachFile(e) {
|
handleAttachFile(e) {
|
||||||
const {formatMessage} = this.props.intl;
|
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
this.props.onAttachFile(e.target.files[0]);
|
this.props.onAttachFile(e.target.files[0]);
|
||||||
}
|
}
|
||||||
@@ -126,6 +132,11 @@ class SendMessage extends React.PureComponent {
|
|||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleAttachAudio(url, preview, duration) {
|
||||||
|
this.setState({audioRec: false});
|
||||||
|
this.props.onAttachAudio(url, preview, duration);
|
||||||
|
}
|
||||||
|
|
||||||
handleSend(e) {
|
handleSend(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const message = this.state.message.trim();
|
const message = this.state.message.trim();
|
||||||
@@ -137,6 +148,13 @@ class SendMessage extends React.PureComponent {
|
|||||||
|
|
||||||
/* Send on Enter key */
|
/* Send on Enter key */
|
||||||
handleKeyPress(e) {
|
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
|
// Remove this if you don't want Enter to trigger send
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
// Have Shift-Enter insert a line break instead
|
// Have Shift-Enter insert a line break instead
|
||||||
@@ -185,14 +203,14 @@ class SendMessage extends React.PureComponent {
|
|||||||
</div>
|
</div>
|
||||||
{this.state.quote}
|
{this.state.quote}
|
||||||
</div>);
|
</div>);
|
||||||
|
const audioEnabled = this.state.audioAvailable && this.props.onAttachAudio;
|
||||||
return (
|
return (
|
||||||
<div id="send-message-wrapper">
|
<div id="send-message-wrapper">
|
||||||
{this.state.quote && !this.props.noInput ? quote : null}
|
{this.state.quote && !this.props.noInput ? quote : null}
|
||||||
<div id="send-message-panel">
|
<div id="send-message-panel">
|
||||||
{!this.props.disabled ?
|
{!this.props.disabled ?
|
||||||
<>
|
<>
|
||||||
{this.props.onAttachFile ?
|
{this.props.onAttachFile && !this.state.audioRec ?
|
||||||
<>
|
<>
|
||||||
<a href="#" onClick={(e) => {e.preventDefault(); this.attachImage.click();}} title="Add image">
|
<a href="#" onClick={(e) => {e.preventDefault(); this.attachImage.click();}} title="Add image">
|
||||||
<i className="material-icons secondary">photo</i>
|
<i className="material-icons secondary">photo</i>
|
||||||
@@ -205,14 +223,25 @@ class SendMessage extends React.PureComponent {
|
|||||||
null}
|
null}
|
||||||
{this.props.noInput ?
|
{this.props.noInput ?
|
||||||
(this.state.quote ? quote : <div className="hr thin" />) :
|
(this.state.quote ? quote : <div className="hr thin" />) :
|
||||||
<textarea id="sendMessage" placeholder={prompt}
|
(this.state.audioRec ?
|
||||||
value={this.state.message} onChange={this.handleMessageTyping}
|
<AudioRecorder
|
||||||
onKeyPress={this.handleKeyPress}
|
onDeleted={_ => this.setState({audioRec: false})}
|
||||||
ref={(ref) => {this.messageEditArea = ref;}}
|
onFinished={this.handleAttachAudio}/> :
|
||||||
autoFocus />}
|
<textarea id="sendMessage" placeholder={prompt}
|
||||||
<a href="#" onClick={this.handleSend} title="Send">
|
value={this.state.message} onChange={this.handleMessageTyping}
|
||||||
<i className="material-icons">send</i>
|
onKeyPress={this.handleKeyPress}
|
||||||
</a>
|
ref={(ref) => {this.messageEditArea = ref;}}
|
||||||
|
autoFocus />)}
|
||||||
|
{this.state.message || !audioEnabled ?
|
||||||
|
<a href="#" onClick={this.handleSend} title="Send">
|
||||||
|
<i className="material-icons">send</i>
|
||||||
|
</a> :
|
||||||
|
!this.state.audioRec ?
|
||||||
|
<a href="#" onClick={e => {e.preventDefault(); this.setState({audioRec: true})}} title="Voice">
|
||||||
|
<i className="material-icons">mic</i>
|
||||||
|
</a> :
|
||||||
|
null
|
||||||
|
}
|
||||||
<input type="file" ref={(ref) => {this.attachFile = ref;}}
|
<input type="file" ref={(ref) => {this.attachFile = ref;}}
|
||||||
onChange={this.handleAttachFile} style={{display: 'none'}} />
|
onChange={this.handleAttachFile} style={{display: 'none'}} />
|
||||||
<input type="file" ref={(ref) => {this.attachImage = ref;}} accept="image/*"
|
<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 MenuContacts from './menu-contacts.jsx';
|
||||||
import MenuStart from './menu-start.jsx';
|
import MenuStart from './menu-start.jsx';
|
||||||
|
|
||||||
import { sanitizeImageUrl } from '../lib/utils.js';
|
import { sanitizeUrlForMime } from '../lib/utils.js';
|
||||||
|
|
||||||
export default class SideNavbar extends React.PureComponent {
|
export default class SideNavbar extends React.PureComponent {
|
||||||
render() {
|
render() {
|
||||||
@@ -18,7 +18,7 @@ export default class SideNavbar extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
let avatar = null;
|
let avatar = null;
|
||||||
if (this.props.tinode) {
|
if (this.props.tinode) {
|
||||||
avatar = this.props.tinode.authorizeURL(sanitizeImageUrl(this.props.avatar));
|
avatar = this.props.tinode.authorizeURL(sanitizeUrlForMime(this.props.avatar, 'image'));
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div id="side-caption-panel" className="caption-panel">
|
<div id="side-caption-panel" className="caption-panel">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import Tinode from 'tinode-sdk';
|
import { Tinode } from 'tinode-sdk';
|
||||||
|
|
||||||
import AvatarCrop from './avatar-crop.jsx';
|
import AvatarCrop from './avatar-crop.jsx';
|
||||||
import AvatarUpload from './avatar-upload.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.
|
// for uploading it to the server out of band.
|
||||||
uploadAvatar(mime, blob, width, height) {
|
uploadAvatar(mime, blob, width, height) {
|
||||||
const readyToUpload = (image) => {
|
const readyToUpload = (image) => {
|
||||||
let {mime, blob, width, height} = image;
|
let {mime, blob} = image;
|
||||||
if (blob.size > MAX_AVATAR_BYTES) {
|
if (blob.size > MAX_AVATAR_BYTES) {
|
||||||
// Too large to send inband - uploading out of band and sending as a link.
|
// Too large to send inband - uploading out of band and sending as a link.
|
||||||
const uploader = this.props.tinode.getLargeFileHelper();
|
const uploader = this.props.tinode.getLargeFileHelper();
|
||||||
|
|||||||
+8303
-421
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
+4214
-6306
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.
|
// This is a generated file. Don't edit.
|
||||||
|
|
||||||
const PACKAGE_VERSION = "0.18.6";
|
const PACKAGE_VERSION = "0.19.0-beta1";
|
||||||
|
|||||||
+2
-4
@@ -52,8 +52,8 @@ module.exports = (env, argv) => {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
performance: {
|
performance: {
|
||||||
maxEntrypointSize: 294912,
|
maxEntrypointSize: 360000,
|
||||||
maxAssetSize: 294912
|
maxAssetSize: 360000
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
new CopyPlugin({
|
new CopyPlugin({
|
||||||
@@ -66,8 +66,6 @@ module.exports = (env, argv) => {
|
|||||||
'react': 'React',
|
'react': 'React',
|
||||||
'react-dom': 'ReactDOM',
|
'react-dom': 'ReactDOM',
|
||||||
'react-intl': 'ReactIntl',
|
'react-intl': 'ReactIntl',
|
||||||
'firebase/app': 'firebase',
|
|
||||||
'firebase/messaging': ['firebase', 'messaging'],
|
|
||||||
'tinode-sdk': 'Tinode',
|
'tinode-sdk': 'Tinode',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user