Compare commits
50
Commits
6a2efac566
...
e7840b05a6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7840b05a6 | ||
|
|
3484c6549e | ||
|
|
30054aec04 | ||
|
|
fae274062a | ||
|
|
3b77af2a10 | ||
|
|
a4b757fa95 | ||
|
|
9d7e1d4f63 | ||
|
|
858e62f732 | ||
|
|
50a775fc72 | ||
|
|
feaefdb952 | ||
|
|
755ad00619 | ||
|
|
c6e440ca72 | ||
|
|
f34f786bc6 | ||
|
|
b2e7a4d63c | ||
|
|
f100385c79 | ||
|
|
0081ed3716 | ||
|
|
065115d2bf | ||
|
|
ed6518df9c | ||
|
|
f7a52d70d6 | ||
|
|
72d978ed55 | ||
|
|
90889454a0 | ||
|
|
d1c90f8434 | ||
|
|
8622926a4c | ||
|
|
2de35424b5 | ||
|
|
d603953a77 | ||
|
|
d0e3e238b8 | ||
|
|
83dfb5e9b2 | ||
|
|
727d926bf6 | ||
|
|
fe9f4bb17e | ||
|
|
1afd876d99 | ||
|
|
6fee1ce5ea | ||
|
|
7828c372e3 | ||
|
|
e02047345c | ||
|
|
db76ec3266 | ||
|
|
06de1c1fb6 | ||
|
|
a662787b9c | ||
|
|
bc4c0811d1 | ||
|
|
893cbdaa0c | ||
|
|
44c5e30f4d | ||
|
|
b81ec3e634 | ||
|
|
b48e4e63d9 | ||
|
|
dce1875de4 | ||
|
|
3392bf5ed7 | ||
|
|
99de97b2e1 | ||
|
|
d30b616b26 | ||
|
|
aa7cb55c8e | ||
|
|
9e756c6772 | ||
|
|
8ee50adf4d | ||
|
|
4c28753e6a | ||
|
|
1cdb8b724c |
@@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
src/i18n/src/
|
||||
@@ -1,15 +1,20 @@
|
||||
# Single-page chat with Tinode
|
||||
|
||||
Single-page web chat application built with [Tinode](https://github.com/tinode/chat/). The app uses
|
||||
[React](https://facebook.github.io/react/), Google's material design [fonts](https://www.google.com/fonts/)
|
||||
and [icons](https://google.github.io/material-design-icons/#icon-font-for-the-web). The [Tinode javascript SDK](https://github.com/tinode/tinode-js/) has no external dependencies.
|
||||
Single-page web chat application for [Tinode](https://github.com/tinode/chat/). The app is built on
|
||||
[React](https://reactjs.org/). The [Tinode javascript SDK](https://github.com/tinode/tinode-js/) has no external dependencies.
|
||||
Overall it's a lot like open source WhatsApp or Telegram web apps.
|
||||
|
||||
The app is about 200KB minified and gzipped. That includes all dependencies (React, ReactDOM), fonts, graphics, css etc.
|
||||
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. The app was tested in the latest Chrome & Firefox only. NPM package is available at 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://web.tinode.co/. For passwords for demo accounts and other instructions [see here](https://github.com/tinode/chat/#demo).
|
||||
<a href="https://web.tinode.co/"><img src="web-app.svg" height=36></a>
|
||||
|
||||
For demo access and other instructions [see here](https://github.com/tinode/chat/#demosandbox).
|
||||
|
||||
## Installing and running
|
||||
|
||||
This is NOT a standalone app, this is just a frontend, a client. It requires a [backend](https://github.com/tinode/chat/). See [installation instructions](https://github.com/tinode/chat/blob/master/INSTALL.md).
|
||||
|
||||
## Getting support
|
||||
|
||||
@@ -19,9 +24,14 @@ Try possibly newer or older version live at https://web.tinode.co/. For password
|
||||
|
||||
## Internationalization
|
||||
|
||||
The app is fully internationalized using [React-Intl](https://github.com/formatjs/react-intl). The UI language is selected automatically from the language specified by the browser. A specific language can be forced by adding `#?hl=XX` parameter to the URL, i.e. https://web.tinode.co/#?hl=ru.
|
||||
The app is fully internationalized using [React-Intl](https://github.com/formatjs/react-intl). The UI language is selected automatically from the language specified by the browser. A specific language can be forced by adding `#?hl=XX` parameter to the URL when starting the app, e.g. https://web.tinode.co/#?hl=ru.
|
||||
|
||||
As of the time of this writing the following translations exist: English, Russian, Simplified Chinese. More translations are welcome. Send a pull request with a json file with translated strings. Take a look at [English](/src/i18n/en.json), [Russian](/src/i18n/ru.json), or [Simplified Chinese](/src/i18n/zh-CN.json) translations for guidance.
|
||||
As of the time of this writing the following translations exist:
|
||||
* English
|
||||
* Russian
|
||||
* Simplified Chinese
|
||||
|
||||
More translations are welcome. Send a pull request with a json file with translated strings. Take a look at [English](/src/i18n/en.json), [Russian](/src/i18n/ru.json), or [Simplified Chinese](/src/i18n/zh.json) translations for guidance.
|
||||
|
||||
|
||||
## Not done yet
|
||||
@@ -36,14 +46,19 @@ As of the time of this writing the following translations exist: English, Russia
|
||||
|
||||
* Demo avatars and some other graphics are from https://www.pexels.com/ under [CC0 license](https://www.pexels.com/photo-license/).
|
||||
* Background patterns from http://subtlepatterns.com/, commercial and non-commercial use allowed with attribution.
|
||||
* [Fonts](https://fonts.google.com/) and [icons](https://google.github.io/material-design-icons/#icon-font-for-the-web) from Google under [license](https://developers.google.com/terms) which permits commercial and non-commercial use.
|
||||
* Push notifications built with https://firebase.google.com/.
|
||||
|
||||
## Push notifications
|
||||
|
||||
If you want to use the app with your own server and want web push notification to work you have to set them up:
|
||||
|
||||
* Register at https://firebase.google.com/, set up the project if you have not done so already.
|
||||
* Open https://console.firebase.google.com/, navigate to your project the to `Cloud Messaging`.
|
||||
* Locate `firebase-init.js` in the root folder of your copy of this web app. Get Sender ID and Web Push certificate from https://console.firebase.google.com/: copy `Sender ID` (Project Settings -> Cloud Messaging, "Sender ID") to `messagingSenderId` field, copy `Web Push certificate` (Project Settings -> Cloud Messaging -> Web configuration -> Web Push certificates) to `messagingVapidKey` field.
|
||||
* Follow instructions to create a web application https://support.google.com/firebase/answer/9326094 in your project.
|
||||
* Follow instructions at https://support.google.com/firebase/answer/7015592 to get a Firebase configuration object ("Firebase SDK snippet").
|
||||
* Locate `firebase-init.js` in the root folder of your copy of TinodeWeb app. Copy-paste the following keys from the configuration object to `firebase-init.js`: `apiKey`, `messagingSenderId`, `projectId`, `appId` (you may copy all keys).
|
||||
* Copy `Web Push certificate` (Project Settings -> Cloud Messaging -> Web configuration -> Web Push certificates) to `messagingVapidKey` field in `firebase-init.js`.
|
||||
* Double check that `firebase-init.js` contains the following keys: `apiKey`, `messagingSenderId`, `projectId`, `appId`, `messagingVapidKey`. The file may contain other optional keys.
|
||||
* Copy Google-provided server key to `tinode.conf`, see details [here](https://github.com/tinode/chat/blob/master/docs/faq.md#q-how-to-setup-fcm-push-notifications).
|
||||
|
||||
## Responsive design
|
||||
|
||||
+67
-39
@@ -181,9 +181,7 @@ textarea, input {
|
||||
outline: none;
|
||||
color: #666;
|
||||
}
|
||||
textarea {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
@@ -234,6 +232,11 @@ input[type="password"].with-visibility {
|
||||
margin-right:-2rem;
|
||||
padding-right:2rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: none;
|
||||
padding-top: 0.45rem;
|
||||
}
|
||||
/* END of input and buttons */
|
||||
|
||||
/* BEGIN scrollbar styling. */
|
||||
@@ -392,6 +395,10 @@ div.alert .content {
|
||||
inset 0 -0.25rem 0.25rem -0.25rem rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.hr.thin {
|
||||
height: 0.015rem;
|
||||
}
|
||||
|
||||
/* Element which can take focus but shows no outline */
|
||||
.group-focus {
|
||||
outline: none;
|
||||
@@ -777,6 +784,10 @@ i.search {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.flex-column.narrow {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.scrollable-panel {
|
||||
overflow-y: auto;
|
||||
margin-top: 0.25rem;
|
||||
@@ -1279,10 +1290,12 @@ i.search {
|
||||
#image-preview-caption-panel {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background-color: transparent;
|
||||
background-color: #eceff1;
|
||||
box-shadow: 0 1px 2px silver; /* Faint shadow under the panel */
|
||||
line-height: 2.5rem;
|
||||
height: 3.5rem;
|
||||
height: 4.6rem;
|
||||
padding: 1rem 0.75rem 0 0.75rem;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
#image-preview-caption-panel a {
|
||||
@@ -1308,7 +1321,12 @@ i.search {
|
||||
justify-content: space-around;
|
||||
height: 3.2rem;
|
||||
line-height: 1.5rem;
|
||||
background-color: transparent;
|
||||
background-color: #eceff1;
|
||||
box-shadow: 0px -2px 3px -1px rgba(0,0,0,0.1); /* Faint shadow above panel */
|
||||
}
|
||||
|
||||
#image-preview-container .material-icons {
|
||||
font-size: 6rem;
|
||||
}
|
||||
|
||||
/* Talk bubbles */
|
||||
@@ -1334,6 +1352,12 @@ i.search {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chat-box .center {
|
||||
justify-content: center;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.chat-box .bubble {
|
||||
display: flex;
|
||||
position: relative;
|
||||
@@ -1375,6 +1399,11 @@ i.search {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.chat-box .center .bubble {
|
||||
background-color: #e3f2fd;
|
||||
padding: 0.75rem 0.75rem 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
/* Left triangle placed bottom left flush. */
|
||||
.chat-box .left .tip::before {
|
||||
content: '';
|
||||
@@ -1684,102 +1713,102 @@ ul.tabbar li.active > a:after {
|
||||
}
|
||||
|
||||
/* Colors of letter tiles */
|
||||
.lettertile.color0 {
|
||||
.light-color0 {
|
||||
background-color: #ef9a9a;
|
||||
}
|
||||
.lettertile.color1 {
|
||||
.light-color1 {
|
||||
background-color: #90caf9;
|
||||
}
|
||||
.lettertile.color2 {
|
||||
.light-color2 {
|
||||
background-color: #b0bec5;
|
||||
}
|
||||
.lettertile.color3 {
|
||||
.light-color3 {
|
||||
background-color: #b39ddb;
|
||||
}
|
||||
.lettertile.color4 {
|
||||
.light-color4 {
|
||||
background-color: #ffab91;
|
||||
}
|
||||
.lettertile.color5 {
|
||||
.light-color5 {
|
||||
background-color: #a5d6a7;
|
||||
}
|
||||
.lettertile.color6 {
|
||||
.light-color6 {
|
||||
background-color: #eeeeee;
|
||||
}
|
||||
.lettertile.color7 {
|
||||
.light-color7 {
|
||||
background-color: #e6ee9c;
|
||||
}
|
||||
.lettertile.color8 {
|
||||
.light-color8 {
|
||||
background-color: #c5e1a5;
|
||||
}
|
||||
.lettertile.color9 {
|
||||
.light-color9 {
|
||||
background-color: #f0e57d;
|
||||
}
|
||||
.lettertile.color10 {
|
||||
.light-color10 {
|
||||
background-color: #f48fb1;
|
||||
}
|
||||
.lettertile.color11 {
|
||||
.light-color11 {
|
||||
background-color: #9fa8da;
|
||||
}
|
||||
.lettertile.color12 {
|
||||
.light-color12 {
|
||||
background-color: #ffe082;
|
||||
}
|
||||
.lettertile.color13 {
|
||||
.light-color13 {
|
||||
background-color: #bcaaa4;
|
||||
}
|
||||
.lettertile.color14 {
|
||||
.light-color14 {
|
||||
background-color: #80deea;
|
||||
}
|
||||
.lettertile.color15 {
|
||||
.light-color15 {
|
||||
background-color: #ce93d8;
|
||||
}
|
||||
|
||||
/* Darker version of letter tile colors */
|
||||
.lettertile.dark-color0 {
|
||||
.dark-color0 {
|
||||
background-color: #C62828;
|
||||
}
|
||||
.lettertile.dark-color1 {
|
||||
.dark-color1 {
|
||||
background-color: #AD1457;
|
||||
}
|
||||
.lettertile.dark-color2 {
|
||||
.dark-color2 {
|
||||
background-color: #6A1B9A;
|
||||
}
|
||||
.lettertile.dark-color3 {
|
||||
.dark-color3 {
|
||||
background-color: #4527A0;
|
||||
}
|
||||
.lettertile.dark-color4 {
|
||||
.dark-color4 {
|
||||
background-color: #283593;
|
||||
}
|
||||
.lettertile.dark-color5 {
|
||||
.dark-color5 {
|
||||
background-color: #1565C0;
|
||||
}
|
||||
.lettertile.dark-color6 {
|
||||
.dark-color6 {
|
||||
background-color: #0277BD;
|
||||
}
|
||||
.lettertile.dark-color7 {
|
||||
.dark-color7 {
|
||||
background-color: #00838F;
|
||||
}
|
||||
.lettertile.dark-color8 {
|
||||
.dark-color8 {
|
||||
background-color: #00695C;
|
||||
}
|
||||
.lettertile.dark-color9 {
|
||||
.dark-color9 {
|
||||
background-color: #2E7D32;
|
||||
}
|
||||
.lettertile.dark-color10 {
|
||||
.dark-color10 {
|
||||
background-color: #558B2F;
|
||||
}
|
||||
.lettertile.dark-color11 {
|
||||
.dark-color11 {
|
||||
background-color: #9E9D24;
|
||||
}
|
||||
.lettertile.dark-color12 {
|
||||
.dark-color12 {
|
||||
background-color: #F9A825;
|
||||
}
|
||||
.lettertile.dark-color13 {
|
||||
.dark-color13 {
|
||||
background-color: #FF8F00;
|
||||
}
|
||||
.lettertile.dark-color14 {
|
||||
.dark-color14 {
|
||||
background-color: #EF6C00;
|
||||
}
|
||||
.lettertile.dark-color15 {
|
||||
.dark-color15 {
|
||||
background-color: #D84315;
|
||||
}
|
||||
|
||||
@@ -1803,7 +1832,6 @@ ul.tabbar li.active > a:after {
|
||||
}
|
||||
.avatar-box .material-icons {
|
||||
color: rgba(250, 250, 250, 0.8); /* #fafafa, gray 50 */
|
||||
background-color: #c0c0c0;
|
||||
border-radius: 50%;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+6
-2
@@ -1,9 +1,13 @@
|
||||
// Get these values for your project from the https://console.firebase.google.com/
|
||||
const FIREBASE_INIT = {
|
||||
// Project Settings -> Cloud Messaging, "Sender ID".
|
||||
// Set up a web application in console then get the config object.
|
||||
// The following keys are copied from the object:
|
||||
apiKey: "AIzaSyD6X4ULR-RUsobvs1zZ2bHdJuPz39q2tbQ",
|
||||
messagingSenderId: "114126160546",
|
||||
projectId: "tinode-1000",
|
||||
appId: "1:114126160546:web:aca6ea2981feb81fb44dfb",
|
||||
// Project Settings -> Cloud Messaging -> Web configuration -> Web Push certificates.
|
||||
// This value IS NOT included in the generated js config you can download from
|
||||
// This value IS NOT included in the generated js config you get from
|
||||
// the firebase console. It needs to be added separately.
|
||||
messagingVapidKey: "BOgQVPOMzIMXUpsYGpbVkZoEBc0ifKY_f2kSU5DNDGYI6i6CoKqqxDd7w7PJ3FaGRBgVGJffldETumOx831jl58"
|
||||
};
|
||||
|
||||
+2
-2
@@ -34,8 +34,8 @@
|
||||
<!-- React-Intl for translations -->
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@3/dist/react-intl.js"></script>
|
||||
<!-- Firebase for push notifications -->
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@6.6.2/firebase-app.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@6.6.2/firebase-messaging.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@7.6.1/firebase-app.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@7.6.1/firebase-messaging.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
+5
-5
@@ -22,11 +22,11 @@
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400i,500,700,700i" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
|
||||
<link rel="stylesheet" href="css/base.min.css" />
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react@16.11.0/umd/react.production.min.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@16.11.0/umd/react-dom.production.min.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@3.4.0/dist/react-intl.min.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@6.6.2/firebase-app.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@6.6.2/firebase-messaging.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react@16.12.0/umd/react.production.min.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@16.12.0/umd/react-dom.production.min.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@3.11.0/dist/react-intl.min.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@7.6.1/firebase-app.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@7.6.1/firebase-messaging.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
|
||||
|
||||
Generated
+1041
-1196
File diff suppressed because it is too large
Load Diff
+15
-15
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tinode-webapp",
|
||||
"description": "Tinode messenger for the web",
|
||||
"version": "0.16.2-rc4",
|
||||
"version": "0.16.4-rc2",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tinode/example-react-js.git"
|
||||
@@ -68,24 +68,24 @@
|
||||
"author": "Tinode Authors <info@tinode.co>",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"firebase": "^6.4.2",
|
||||
"react": "^16.9.0",
|
||||
"react-dom": "^16.9.0",
|
||||
"react-intl": "^3.2.1",
|
||||
"tinode-sdk": "^0.16.2-rc3"
|
||||
"firebase": "^7.6.1",
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-intl": "^3.11.0",
|
||||
"tinode-sdk": "^0.16.4-rc2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.6.0",
|
||||
"@babel/preset-env": "^7.6.0",
|
||||
"@babel/preset-react": "^7.0.0",
|
||||
"@babel/core": "^7.7.7",
|
||||
"@babel/preset-env": "^7.7.7",
|
||||
"@babel/preset-react": "^7.7.4",
|
||||
"babel-loader": "^8.0.6",
|
||||
"babel-plugin-react-intl": "^4.1.19",
|
||||
"browserslist": "^4.7.0",
|
||||
"copy-webpack-plugin": "^5.0.4",
|
||||
"babel-plugin-react-intl": "^5.1.16",
|
||||
"browserslist": "^4.8.3",
|
||||
"copy-webpack-plugin": "^5.1.1",
|
||||
"cssnano": "^4.1.10",
|
||||
"postcss": "^7.0.18",
|
||||
"postcss": "^7.0.26",
|
||||
"uglifyjs-webpack-plugin": "^2.2.0",
|
||||
"webpack": "^4.40.2",
|
||||
"webpack-cli": "^3.3.9"
|
||||
"webpack": "^4.41.5",
|
||||
"webpack-cli": "^3.3.10"
|
||||
}
|
||||
}
|
||||
|
||||
+8
-4
@@ -1,14 +1,18 @@
|
||||
// Must be located at the root.
|
||||
importScripts('https://cdn.jsdelivr.net/npm/firebase@6.6.2/firebase-app.js');
|
||||
importScripts('https://cdn.jsdelivr.net/npm/firebase@6.6.2/firebase-messaging.js');
|
||||
importScripts('https://cdn.jsdelivr.net/npm/firebase@7.6.1/firebase-app.js');
|
||||
importScripts('https://cdn.jsdelivr.net/npm/firebase@7.6.1/firebase-messaging.js');
|
||||
importScripts('firebase-init.js');
|
||||
|
||||
firebase.initializeApp(FIREBASE_INIT);
|
||||
|
||||
// This method shows the notifications.
|
||||
firebase.messaging().setBackgroundMessageHandler(function(payload) {
|
||||
let title = payload.data.title || "New message";
|
||||
let options = {
|
||||
if (payload.data.silent === 'true') {
|
||||
return;
|
||||
}
|
||||
const pushType = payload.data.what || 'msg';
|
||||
const title = payload.data.title || (pushType == 'msg' ? "New message" : "New chat");
|
||||
const options = {
|
||||
body: payload.data.content || "",
|
||||
icon: '/img/logo96.png',
|
||||
badge: '/img/badge96.png',
|
||||
|
||||
+2
-2
@@ -69,8 +69,8 @@ export const MAX_IMAGE_DIM = 768;
|
||||
// hidden under "+X more"
|
||||
export const MAX_ONLINE_IN_TOPIC = 4;
|
||||
|
||||
// Maximum length of topic title (and Private comment) or user name.
|
||||
export const MAX_TITLE_LENGTH = 40;
|
||||
// Maximum length of user name, topic title, and private comment.
|
||||
export const MAX_TITLE_LENGTH = 60;
|
||||
|
||||
// Link for "Contact Us".
|
||||
export const LINK_CONTACT_US = 'email:info@tinode.co';
|
||||
|
||||
+5
-1
@@ -61,10 +61,11 @@
|
||||
"messages_not_readable": "no access to messages",
|
||||
"peers_messaging_disabled": "Peer's messaging is disabled.",
|
||||
"enable_peers_messaging": "Enable",
|
||||
"search_for_contacts": "Use search to find contacts",
|
||||
"search_no_results": "Search returned no results",
|
||||
"tabtitle_find_user": "find",
|
||||
"tabtitle_new_group": "new group",
|
||||
"tabtitle_group_by_id": "by id",
|
||||
"search_for_contacts": "Use search to find contacts",
|
||||
"new_password_placeholder": "Enter new password",
|
||||
"label_reset_password": "Send a password reset email:",
|
||||
"credential_email_prompt": "Your registration email",
|
||||
@@ -90,6 +91,7 @@
|
||||
"numeric_confirmation_code_prompt": "Numbers only",
|
||||
"button_confirm": "Confirm",
|
||||
"save_attachment": "save",
|
||||
"deleted_content": "content deleted",
|
||||
"invalid_content": "invalid content",
|
||||
"user_not_found": "Not found",
|
||||
"badge_you": "you",
|
||||
@@ -99,6 +101,7 @@
|
||||
"menu_item_clear_messages_for_all": "Clear for All",
|
||||
"menu_item_delete": "Delete",
|
||||
"menu_item_delete_for_all": "Delete for All",
|
||||
"menu_item_send_retry": "Retry",
|
||||
"menu_item_mute": "Mute",
|
||||
"menu_item_unmute": "Unmute",
|
||||
"menu_item_delete_topic": "Delete",
|
||||
@@ -144,6 +147,7 @@
|
||||
"search_placeholder": "List like email:alice@example.com, tel:17025550003...",
|
||||
"messaging_disabled_prompt": "Messaging disabled",
|
||||
"new_message_prompt": "New message",
|
||||
"image_caption_prompt": "Image caption",
|
||||
"file_attachment_too_large": "The file size {size} exceeds the {limit} limit.",
|
||||
"cannot_initiate_file_upload": "Cannot initiate file upload.",
|
||||
"tags_not_found": "No tags defined. Add some.",
|
||||
|
||||
+6
-2
@@ -93,6 +93,7 @@
|
||||
"menu_item_clear_messages_for_all": "Удалить для всех",
|
||||
"menu_item_delete": "Удалить",
|
||||
"menu_item_delete_for_all": "Удалить для всех",
|
||||
"menu_item_send_retry": "Отправить заново",
|
||||
"menu_item_mute": "Не уведомлять",
|
||||
"menu_item_unmute": "Уведомлять",
|
||||
"menu_item_delete_topic": "Удалить чат",
|
||||
@@ -147,6 +148,9 @@
|
||||
"report_chat_warning": "Вы действительно хотите сообщить о нарушении и заблокировать этот чат?",
|
||||
"action_clear_messages": "Удалить сообщения",
|
||||
"clear_messages_warning": "Вы действительно хотите удалить все сообщения в чате? Их будет невозможно восстановить.",
|
||||
"topic_delete_warning": ":!![MISSING]!!:Are you sure you want to delete this conversation?",
|
||||
"topic_block_warning": ":!![MISSING]!!:Are you sure you want to block this conversation?"
|
||||
"topic_delete_warning": "Вы действительно хотите удалить этот чат?",
|
||||
"topic_block_warning": "Вы действительно хотите заблокировать этот чат?",
|
||||
"search_no_results": "Ничего не найдено",
|
||||
"deleted_content": "удалено",
|
||||
"image_caption_prompt": "Подпись к фото"
|
||||
}
|
||||
|
||||
+12
-8
@@ -140,13 +140,17 @@
|
||||
"link_privacy_policy": "隐私政策",
|
||||
"action_delete_messages": "删除所有帖子",
|
||||
"action_block_contact": "屏蔽联系人",
|
||||
"delete_messages_warning": ":!![MISSING]!!:Are you sure you want to delete all mesages? It cannot be undone.",
|
||||
"leave_chat_warning": ":!![MISSING]!!:Are you sure you want to leave this conversation.",
|
||||
"block_contact_warning": ":!![MISSING]!!:Are you sure you want to block this contact?",
|
||||
"delete_messages_warning": "您确定要删除所有消息吗?无法撤消。",
|
||||
"leave_chat_warning": "您确定要退出此对话吗?",
|
||||
"block_contact_warning": "您确定要阻止此联系人吗?",
|
||||
"action_report_chat": "检举垃圾邮件",
|
||||
"report_chat_warning": ":!![MISSING]!!:Are you sure you want to block and report this conversation?",
|
||||
"action_clear_messages": ":!![MISSING]!!:Clear Messages",
|
||||
"clear_messages_warning": ":!![MISSING]!!:Are you sure you want to clear all messages? It cannot be undone.",
|
||||
"topic_delete_warning": ":!![MISSING]!!:Are you sure you want to delete this conversation?",
|
||||
"topic_block_warning": ":!![MISSING]!!:Are you sure you want to block this conversation?"
|
||||
"report_chat_warning": "您确定要停止并报告此对话吗?",
|
||||
"action_clear_messages": "删除讯息",
|
||||
"clear_messages_warning": "您确定要清除所有消息吗?无法撤消。",
|
||||
"topic_delete_warning": "您确定要删除此对话吗?",
|
||||
"topic_block_warning": "您确定要阻止此对话吗?",
|
||||
"search_no_results": "搜索返回任何结果",
|
||||
"deleted_content": "内容已删除",
|
||||
"image_caption_prompt": "图片标题",
|
||||
"menu_item_send_retry": "重试"
|
||||
}
|
||||
|
||||
@@ -207,11 +207,11 @@ export function base64DecodedLen(n) {
|
||||
// The string may be base64-URL encoded without the padding.
|
||||
export function base64ReEncode(str) {
|
||||
if (str) {
|
||||
str = str.replace('-', '+').replace('_', '/');
|
||||
str = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||
try {
|
||||
str = btoa(atob(str));
|
||||
} catch(err) {
|
||||
console.log("Failed to base64 re-encode string");
|
||||
console.log("Failed to base64 re-encode string.", err);
|
||||
str = null;
|
||||
}
|
||||
}
|
||||
|
||||
+27
-5
@@ -62,10 +62,11 @@
|
||||
"messages_not_readable": "no access to messages",
|
||||
"peers_messaging_disabled": "Peer's messaging is disabled.",
|
||||
"enable_peers_messaging": "Enable",
|
||||
"search_for_contacts": "Use search to find contacts",
|
||||
"search_no_results": "Search returned no results",
|
||||
"tabtitle_find_user": "find",
|
||||
"tabtitle_new_group": "new group",
|
||||
"tabtitle_group_by_id": "by id",
|
||||
"search_for_contacts": "Use search to find contacts",
|
||||
"new_password_placeholder": "Enter new password",
|
||||
"label_reset_password": "Send a password reset email:",
|
||||
"credential_email_prompt": "Your registration email",
|
||||
@@ -91,6 +92,7 @@
|
||||
"numeric_confirmation_code_prompt": "Numbers only",
|
||||
"button_confirm": "Confirm",
|
||||
"save_attachment": "save",
|
||||
"deleted_content": "content deleted",
|
||||
"invalid_content": "invalid content",
|
||||
"user_not_found": "Not found",
|
||||
"badge_you": "you",
|
||||
@@ -100,6 +102,7 @@
|
||||
"menu_item_clear_messages_for_all": "Clear for All",
|
||||
"menu_item_delete": "Delete",
|
||||
"menu_item_delete_for_all": "Delete for All",
|
||||
"menu_item_send_retry": "Retry",
|
||||
"menu_item_mute": "Mute",
|
||||
"menu_item_unmute": "Unmute",
|
||||
"menu_item_delete_topic": "Delete",
|
||||
@@ -145,6 +148,7 @@
|
||||
"search_placeholder": "List like email:alice@example.com, tel:17025550003...",
|
||||
"messaging_disabled_prompt": "Messaging disabled",
|
||||
"new_message_prompt": "New message",
|
||||
"image_caption_prompt": "Image caption",
|
||||
"file_attachment_too_large": "The file size {size} exceeds the {limit} limit.",
|
||||
"cannot_initiate_file_upload": "Cannot initiate file upload.",
|
||||
"tags_not_found": "No tags defined. Add some.",
|
||||
@@ -155,7 +159,7 @@
|
||||
"contacts_not_found": "Чатов нет<br />¯∖_(ツ)_/¯",
|
||||
"full_name_prompt": "Полное имя, напр. Иван Петров",
|
||||
"email_prompt": "Email, напр. ivan@example.com",
|
||||
"button_sign_up": "Создать счет",
|
||||
"button_sign_up": "Создать аккаунт",
|
||||
"label_your_name": "Ваше имя",
|
||||
"label_password": "Пароль",
|
||||
"password_unchanged_prompt": "Не изменен",
|
||||
@@ -187,7 +191,7 @@
|
||||
"sidepanel_title_login": "Авторизация",
|
||||
"sidepanel_title_register": "Зарегистрироваться",
|
||||
"sidepanel_title_settings": "Настройки",
|
||||
"sidepanel_title_edit_account": "Редактировать счет",
|
||||
"sidepanel_title_edit_account": "Редактировать аккаунт",
|
||||
"sidepanel_title_newtpk": "Новый чат",
|
||||
"sidepanel_title_cred": "Подтвердить",
|
||||
"sidepanel_title_reset": "Сменить пароль",
|
||||
@@ -246,6 +250,7 @@
|
||||
"menu_item_clear_messages_for_all": "Удалить для всех",
|
||||
"menu_item_delete": "Удалить",
|
||||
"menu_item_delete_for_all": "Удалить для всех",
|
||||
"menu_item_send_retry": "Отправить заново",
|
||||
"menu_item_mute": "Не уведомлять",
|
||||
"menu_item_unmute": "Уведомлять",
|
||||
"menu_item_delete_topic": "Удалить чат",
|
||||
@@ -299,7 +304,12 @@
|
||||
"block_contact_warning": "Вы действительно заблокировать этот контакт?",
|
||||
"report_chat_warning": "Вы действительно хотите сообщить о нарушении и заблокировать этот чат?",
|
||||
"action_clear_messages": "Удалить сообщения",
|
||||
"clear_messages_warning": "Вы действительно хотите удалить все сообщения в чате? Их будет невозможно восстановить."
|
||||
"clear_messages_warning": "Вы действительно хотите удалить все сообщения в чате? Их будет невозможно восстановить.",
|
||||
"topic_delete_warning": "Вы действительно хотите удалить этот чат?",
|
||||
"topic_block_warning": "Вы действительно хотите заблокировать этот чат?",
|
||||
"search_no_results": "Ничего не найдено",
|
||||
"deleted_content": "удалено",
|
||||
"image_caption_prompt": "Подпись к фото"
|
||||
},
|
||||
"zh": {
|
||||
"archived_contacts": "已归档联系人 ({count})",
|
||||
@@ -443,6 +453,18 @@
|
||||
"link_privacy_policy": "隐私政策",
|
||||
"action_delete_messages": "删除所有帖子",
|
||||
"action_block_contact": "屏蔽联系人",
|
||||
"action_report_chat": "检举垃圾邮件"
|
||||
"delete_messages_warning": "您确定要删除所有消息吗?无法撤消。",
|
||||
"leave_chat_warning": "您确定要退出此对话吗?",
|
||||
"block_contact_warning": "您确定要阻止此联系人吗?",
|
||||
"action_report_chat": "检举垃圾邮件",
|
||||
"report_chat_warning": "您确定要停止并报告此对话吗?",
|
||||
"action_clear_messages": "删除讯息",
|
||||
"clear_messages_warning": "您确定要清除所有消息吗?无法撤消。",
|
||||
"topic_delete_warning": "您确定要删除此对话吗?",
|
||||
"topic_block_warning": "您确定要阻止此对话吗?",
|
||||
"search_no_results": "搜索返回任何结果",
|
||||
"deleted_content": "内容已删除",
|
||||
"image_caption_prompt": "图片标题",
|
||||
"menu_item_send_retry": "重试"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
// This is a generated file. Don't edit.
|
||||
|
||||
export const PACKAGE_VERSION = "0.16.2-rc4";
|
||||
export const PACKAGE_VERSION = "0.16.4-rc2";
|
||||
|
||||
+391
-171
@@ -5,6 +5,7 @@ import Tinode from 'tinode-sdk';
|
||||
const Drafty = Tinode.Drafty;
|
||||
|
||||
import ChatMessage from '../widgets/chat-message.jsx';
|
||||
import DocPreview from '../widgets/doc-preview.jsx';
|
||||
import ErrorPanel from '../widgets/error-panel.jsx';
|
||||
import GroupSubs from '../widgets/group-subs.jsx';
|
||||
import ImagePreview from '../widgets/image-preview.jsx';
|
||||
@@ -14,9 +15,14 @@ import LoadSpinner from '../widgets/load-spinner.jsx';
|
||||
import LogoView from './logo-view.jsx';
|
||||
import SendMessage from '../widgets/send-message.jsx';
|
||||
|
||||
import { DEFAULT_P2P_ACCESS_MODE, KEYPRESS_DELAY, MESSAGES_PAGE } from '../config.js';
|
||||
import { makeImageUrl } from '../lib/blob-helpers.js';
|
||||
import { shortDateFormat } from '../lib/strformat.js';
|
||||
import { DEFAULT_P2P_ACCESS_MODE, KEYPRESS_DELAY, MESSAGES_PAGE, MAX_EXTERN_ATTACHMENT_SIZE,
|
||||
MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE, READ_DELAY } from '../config.js';
|
||||
import { SUPPORTED_IMAGE_FORMATS, filePasted, fileToBase64, imageFileToBase64,
|
||||
imageFileScaledToBase64, makeImageUrl } from '../lib/blob-helpers.js';
|
||||
import { bytesToHumanSize, shortDateFormat } from '../lib/strformat.js';
|
||||
|
||||
// Run timer with this frequency (ms) for checking notification queue.
|
||||
const NOTIFICATION_EXEC_INTERVAL = 300;
|
||||
|
||||
const messages = defineMessages({
|
||||
online_now: {
|
||||
@@ -59,8 +65,10 @@ class MessagesView extends React.Component {
|
||||
|
||||
this.state = MessagesView.getDerivedStateFromProps(props, {});
|
||||
|
||||
// this.propsChange = this.propsChange.bind(this);
|
||||
this.leave = this.leave.bind(this);
|
||||
this.sendImageAttachment = this.sendImageAttachment.bind(this);
|
||||
this.sendFileAttachment = this.sendFileAttachment.bind(this);
|
||||
this.sendKeyPress = this.sendKeyPress.bind(this);
|
||||
this.handleScrollReference = this.handleScrollReference.bind(this);
|
||||
this.handleScrollEvent = this.handleScrollEvent.bind(this);
|
||||
this.handleDescChange = this.handleDescChange.bind(this);
|
||||
@@ -68,13 +76,20 @@ class MessagesView extends React.Component {
|
||||
this.handleNewMessage = this.handleNewMessage.bind(this);
|
||||
this.handleAllMessagesReceived = this.handleAllMessagesReceived.bind(this);
|
||||
this.handleInfoReceipt = this.handleInfoReceipt.bind(this);
|
||||
this.handleImagePreview = this.handleImagePreview.bind(this);
|
||||
this.handleCloseImagePreview = this.handleCloseImagePreview.bind(this);
|
||||
this.handleImagePostview = this.handleImagePostview.bind(this);
|
||||
this.handleClosePreview = this.handleClosePreview.bind(this);
|
||||
this.handleFormResponse = this.handleFormResponse.bind(this);
|
||||
this.handleContextClick = this.handleContextClick.bind(this);
|
||||
this.handleShowContextMenuMessage = this.handleShowContextMenuMessage.bind(this);
|
||||
this.handleNewChatAcceptance = this.handleNewChatAcceptance.bind(this);
|
||||
this.handleEnablePeer = this.handleEnablePeer.bind(this);
|
||||
this.handleAttachFile = this.handleAttachFile.bind(this);
|
||||
this.handleAttachImage = this.handleAttachImage.bind(this);
|
||||
this.postReadNotification = this.postReadNotification.bind(this);
|
||||
this.clearNotificationQueue = this.clearNotificationQueue.bind(this);
|
||||
|
||||
this.readNotificationQueue = [];
|
||||
this.readNotificationTimer = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -88,6 +103,9 @@ class MessagesView extends React.Component {
|
||||
if (this.messagesScroller) {
|
||||
this.messagesScroller.removeEventListener('scroll', this.handleScrollEvent);
|
||||
}
|
||||
|
||||
// Flush all notifications.
|
||||
this.clearNotificationQueue();
|
||||
}
|
||||
|
||||
// Scroll last message into view on component update e.g. on message received
|
||||
@@ -105,7 +123,6 @@ class MessagesView extends React.Component {
|
||||
if (this.state.topic != prevState.topic) {
|
||||
if (prevState.topic && !Tinode.isNewGroupTopicName(prevState.topic)) {
|
||||
this.leave(prevState.topic);
|
||||
this.props.readTimerHandler(null);
|
||||
}
|
||||
|
||||
if (topic) {
|
||||
@@ -118,6 +135,14 @@ class MessagesView extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.props.applicationVisible) {
|
||||
// If application is not visible, flush all unsent 'read' notifications.
|
||||
this.clearNotificationQueue();
|
||||
} else {
|
||||
// Otherwise assume there are unread messages.
|
||||
this.postReadNotification(0);
|
||||
}
|
||||
|
||||
if (topic && !topic.isSubscribed() && this.props.ready &&
|
||||
((this.state.topic != prevState.topic) || !prevProps.ready)) {
|
||||
// Is this a new topic?
|
||||
@@ -169,7 +194,9 @@ class MessagesView extends React.Component {
|
||||
topic: null,
|
||||
title: '',
|
||||
avatar: null,
|
||||
docPreview: null,
|
||||
imagePreview: null,
|
||||
imagePostview: null,
|
||||
typingIndicator: false,
|
||||
scrollPosition: 0,
|
||||
fetchingMessages: false,
|
||||
@@ -179,7 +206,9 @@ class MessagesView extends React.Component {
|
||||
const topic = nextProps.tinode.getTopic(nextProps.topic);
|
||||
nextState = {
|
||||
topic: nextProps.topic,
|
||||
docPreview: null,
|
||||
imagePreview: null,
|
||||
imagePostview: null,
|
||||
typingIndicator: false,
|
||||
scrollPosition: 0,
|
||||
fetchingMessages: false
|
||||
@@ -350,6 +379,68 @@ class MessagesView extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
postReadNotification(seq) {
|
||||
// Ignore notifications if the app is invisible.
|
||||
if (!this.props.applicationVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up the timer if it's not running already.
|
||||
if (!this.readNotificationTimer) {
|
||||
this.readNotificationTimer = setInterval(() => {
|
||||
if (this.readNotificationQueue.length == 0) {
|
||||
// Shut down the timer if the queue is empty.
|
||||
clearInterval(this.readNotificationTimer);
|
||||
this.readNotificationTimer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let seq = -1;
|
||||
while (this.readNotificationQueue.length > 0) {
|
||||
const n = this.readNotificationQueue[0];
|
||||
if (n.topicName != this.state.topic) {
|
||||
// Topic has changed. Drop the notification.
|
||||
this.readNotificationQueue.shift();
|
||||
continue;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
if (n.sendAt <= now) {
|
||||
// Remove expired notification from queue.
|
||||
this.readNotificationQueue.shift();
|
||||
seq = Math.max(seq, n.seq);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Send only one notification for the whole batch of messages.
|
||||
if (seq >= 0) {
|
||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||
if (topic) {
|
||||
topic.noteRead(seq);
|
||||
}
|
||||
}
|
||||
}, NOTIFICATION_EXEC_INTERVAL);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
this.readNotificationQueue.push({
|
||||
topicName: this.state.topic,
|
||||
seq: seq,
|
||||
sendAt: now.setMilliseconds(now.getMilliseconds() + READ_DELAY)
|
||||
});
|
||||
}
|
||||
|
||||
// Clear notification queue and timer.
|
||||
clearNotificationQueue() {
|
||||
this.readNotificationQueue = [];
|
||||
if (this.readNotificationTimer) {
|
||||
clearInterval(this.readNotificationTimer);
|
||||
this.readNotificationTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleSubsUpdated() {
|
||||
if (this.state.topic) {
|
||||
const subs = [];
|
||||
@@ -396,9 +487,7 @@ class MessagesView extends React.Component {
|
||||
// automatically assumed to be read and recived.
|
||||
const status = topic.msgStatus(msg);
|
||||
if (status >= Tinode.MESSAGE_STATUS_SENT && msg.from != this.props.myUserId) {
|
||||
this.props.readTimerHandler(() => {
|
||||
topic.noteRead(msg.seq);
|
||||
});
|
||||
this.postReadNotification(msg.seq);
|
||||
}
|
||||
this.props.onData(msg);
|
||||
}
|
||||
@@ -407,6 +496,10 @@ class MessagesView extends React.Component {
|
||||
|
||||
handleAllMessagesReceived(count) {
|
||||
this.setState({fetchingMessages: false});
|
||||
if (count > 0) {
|
||||
// 0 means "latest".
|
||||
this.postReadNotification(0);
|
||||
}
|
||||
}
|
||||
|
||||
handleInfoReceipt(info) {
|
||||
@@ -432,12 +525,12 @@ class MessagesView extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
handleImagePreview(content) {
|
||||
this.setState({ imagePreview: content });
|
||||
handleImagePostview(content) {
|
||||
this.setState({ imagePostview: content });
|
||||
}
|
||||
|
||||
handleCloseImagePreview() {
|
||||
this.setState({ imagePreview: null });
|
||||
handleClosePreview() {
|
||||
this.setState({ imagePostview: null, imagePreview: null, docPreview: null });
|
||||
}
|
||||
|
||||
handleFormResponse(action, text, data) {
|
||||
@@ -470,9 +563,10 @@ class MessagesView extends React.Component {
|
||||
this.props.showContextMenu({ topicName: this.state.topic, y: e.pageY, x: e.pageX });
|
||||
}
|
||||
|
||||
handleShowContextMenuMessage(params) {
|
||||
handleShowContextMenuMessage(params, messageSpecificMenuItems) {
|
||||
params.topicName = this.state.topic;
|
||||
const menuItems = ['message_delete'];
|
||||
const menuItems = messageSpecificMenuItems || [];
|
||||
menuItems.push('message_delete');
|
||||
const topic = this.props.tinode.getTopic(params.topicName);
|
||||
if (topic) {
|
||||
const acs = topic.getAccessMode();
|
||||
@@ -492,170 +586,296 @@ class MessagesView extends React.Component {
|
||||
this.props.onChangePermissions(this.state.topic, DEFAULT_P2P_ACCESS_MODE, this.state.topic);
|
||||
}
|
||||
|
||||
sendKeyPress() {
|
||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||
if (topic.isSubscribed()) {
|
||||
topic.noteKeyPress();
|
||||
}
|
||||
}
|
||||
|
||||
sendFileAttachment(file) {
|
||||
if (file.size > MAX_INBAND_ATTACHMENT_SIZE) {
|
||||
// Too large to send inband - uploading out of band and sending as a link.
|
||||
const uploader = this.props.tinode.getLargeFileHelper();
|
||||
if (!uploader) {
|
||||
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
|
||||
return;
|
||||
}
|
||||
const uploadCompletionPromise = uploader.upload(file);
|
||||
const msg = Drafty.attachFile(null, file.type, null, file.name, file.size, uploadCompletionPromise);
|
||||
// Pass data and the uploader to the TinodeWeb.
|
||||
this.props.sendMessage(msg, uploadCompletionPromise, uploader);
|
||||
} else {
|
||||
// Small enough to send inband.
|
||||
fileToBase64(file,
|
||||
(mime, bits, fname) => {
|
||||
this.props.sendMessage(Drafty.attachFile(null, mime, bits, fname));
|
||||
},
|
||||
this.props.onError
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handleAttachFile(file) {
|
||||
if (file.size > MAX_EXTERN_ATTACHMENT_SIZE) {
|
||||
// Too large.
|
||||
this.props.onError(this.props.intl.formatMessage(messages.file_attachment_too_large,
|
||||
{size: bytesToHumanSize(file.size), limit: bytesToHumanSize(MAX_EXTERN_ATTACHMENT_SIZE)}), 'err');
|
||||
} else {
|
||||
this.setState({ docPreview: {
|
||||
file: file,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
}});
|
||||
}
|
||||
}
|
||||
|
||||
sendImageAttachment(caption, mime, bits, width, height, fname) {
|
||||
let msg = Drafty.insertImage(null, 0, mime, bits, width, height, fname);
|
||||
if (caption) {
|
||||
msg = Drafty.appendLineBreak(msg);
|
||||
msg = Drafty.append(msg, Drafty.init(caption));
|
||||
}
|
||||
this.props.sendMessage(msg);
|
||||
}
|
||||
|
||||
handleAttachImage(file) {
|
||||
// Check if the uploaded file is indeed an image and if it isn't too large.
|
||||
if (file.size > MAX_INBAND_ATTACHMENT_SIZE || SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) {
|
||||
// Convert image for size or format.
|
||||
imageFileScaledToBase64(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, false,
|
||||
// Success
|
||||
(bits, mime, width, height, fname) => {
|
||||
this.setState({imagePreview: {
|
||||
url: URL.createObjectURL(file),
|
||||
bits: bits,
|
||||
filename: fname,
|
||||
width: width,
|
||||
height: height,
|
||||
size: bits.length,
|
||||
type: mime
|
||||
}});
|
||||
},
|
||||
// Failure
|
||||
(err) => {
|
||||
this.props.onError(err, 'err');
|
||||
});
|
||||
} else {
|
||||
// Image can be uploaded as is. No conversion is needed.
|
||||
imageFileToBase64(file,
|
||||
// Success
|
||||
(bits, mime, width, height, fname) => {
|
||||
this.setState({imagePreview: {
|
||||
url: URL.createObjectURL(file),
|
||||
bits: bits,
|
||||
filename: fname,
|
||||
width: width,
|
||||
height: height,
|
||||
size: bits.length,
|
||||
type: mime
|
||||
}});
|
||||
},
|
||||
// Failure
|
||||
(err) => {
|
||||
this.props.onError(err, 'err');
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {formatMessage} = this.props.intl;
|
||||
|
||||
let component;
|
||||
if (this.state.topic) {
|
||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||
const groupTopic = topic.getType() == 'grp';
|
||||
let messageNodes = [];
|
||||
let previousFrom = null;
|
||||
let chatBoxClass = null;
|
||||
for (let i=0; i<this.state.messages.length; i++) {
|
||||
let msg = this.state.messages[i];
|
||||
let nextFrom = null;
|
||||
|
||||
if (i + 1 < this.state.messages.length) {
|
||||
nextFrom = this.state.messages[i+1].from
|
||||
}
|
||||
|
||||
let sequence = 'single';
|
||||
if (msg.from == previousFrom) {
|
||||
if (msg.from == nextFrom) {
|
||||
sequence = 'middle';
|
||||
} else {
|
||||
sequence = 'last';
|
||||
}
|
||||
} else if (msg.from == nextFrom) {
|
||||
sequence = 'first';
|
||||
}
|
||||
previousFrom = msg.from;
|
||||
|
||||
const isReply = !(msg.from == this.props.myUserId);
|
||||
const deliveryStatus = topic.msgStatus(msg);
|
||||
|
||||
let userName, userAvatar, userFrom;
|
||||
if (groupTopic) {
|
||||
const user = topic.userDesc(msg.from);
|
||||
if (user && user.public) {
|
||||
userName = user.public.fn;
|
||||
userAvatar = makeImageUrl(user.public.photo);
|
||||
}
|
||||
userFrom = msg.from;
|
||||
chatBoxClass='chat-box group';
|
||||
} else {
|
||||
chatBoxClass='chat-box';
|
||||
}
|
||||
|
||||
messageNodes.push(
|
||||
<ChatMessage
|
||||
tinode={this.props.tinode}
|
||||
content={msg.content}
|
||||
mimeType={msg.head ? msg.head.mime : null}
|
||||
timestamp={msg.ts} response={isReply} seq={msg.seq}
|
||||
userFrom={userFrom} userName={userName} userAvatar={userAvatar}
|
||||
sequence={sequence} received={deliveryStatus} uploader={msg._uploader}
|
||||
viewportWidth={this.props.viewportWidth}
|
||||
showContextMenu={this.handleShowContextMenuMessage}
|
||||
onImagePreview={this.handleImagePreview}
|
||||
onFormResponse={this.handleFormResponse}
|
||||
onError={this.props.onError}
|
||||
key={msg.seq} />
|
||||
);
|
||||
}
|
||||
|
||||
let lastSeen = null;
|
||||
const cont = this.props.tinode.getMeTopic().getContact(this.state.topic);
|
||||
if (cont && Tinode.topicType(cont.topic) == 'p2p') {
|
||||
if (cont.online) {
|
||||
lastSeen = formatMessage(messages.online_now);
|
||||
} else if (cont.seen) {
|
||||
lastSeen = formatMessage(messages.last_seen) + ": " +
|
||||
shortDateFormat(cont.seen.when, this.props.intl.locale);
|
||||
// TODO: also handle user agent in c.seen.ua
|
||||
}
|
||||
}
|
||||
const avatar = this.state.avatar || true;
|
||||
const online = this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline';
|
||||
|
||||
component = (
|
||||
<div id="topic-view" className={this.props.hideSelf ? 'nodisplay' : null}>
|
||||
<div id="topic-caption-panel" className="caption-panel">
|
||||
{this.props.displayMobile ?
|
||||
<a href="#" id="hide-message-view" onClick={(e) => {e.preventDefault(); this.props.onHideMessagesView();}}>
|
||||
<i className="material-icons">arrow_back</i>
|
||||
</a>
|
||||
:
|
||||
null}
|
||||
<div className="avatar-box">
|
||||
<LetterTile
|
||||
avatar={avatar}
|
||||
topic={this.state.topic}
|
||||
title={this.state.title} />
|
||||
<span className={online} />
|
||||
</div>
|
||||
<div id="topic-title-group">
|
||||
<div id="topic-title" className="panel-title">{
|
||||
this.state.title ||
|
||||
<i><FormattedMessage id="unnamed_topic" defaultMessage="Unnamed"
|
||||
description="Title shown when the topic has no name" /></i>
|
||||
}</div>
|
||||
<div id="topic-last-seen">{lastSeen}</div>
|
||||
</div>
|
||||
{groupTopic ?
|
||||
<GroupSubs
|
||||
subscribers={this.state.onlineSubs} /> :
|
||||
<div id="topic-users" />
|
||||
}
|
||||
<div>
|
||||
<a href="#" onClick={this.handleContextClick}>
|
||||
<i className="material-icons">more_vert</i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{this.props.displayMobile ?
|
||||
<ErrorPanel
|
||||
level={this.props.errorLevel}
|
||||
text={this.props.errorText}
|
||||
onClearError={this.props.onError} />
|
||||
: null}
|
||||
<LoadSpinner show={this.state.fetchingMessages} />
|
||||
<div id="messages-container">
|
||||
<div id="messages-panel" ref={this.handleScrollReference}>
|
||||
<ul id="scroller" className={chatBoxClass}>
|
||||
{messageNodes}
|
||||
</ul>
|
||||
</div>
|
||||
{!this.state.isReader ?
|
||||
<div id="write-only-background">
|
||||
{this.state.readingBlocked ?
|
||||
<div id="write-only-note">
|
||||
<FormattedMessage id="messages_not_readable" defaultMessage="no access to messages"
|
||||
description="Message shown in topic without the read access" />
|
||||
</div>
|
||||
: null }
|
||||
</div>
|
||||
: null }
|
||||
</div>
|
||||
{this.state.peerMessagingDisabled && !this.state.unconfirmed ?
|
||||
<div id="peer-messaging-disabled-note">
|
||||
<i className="material-icons secondary">block</i> <FormattedMessage
|
||||
id="peers_messaging_disabled" defaultMessage="Peer's messaging is disabled."
|
||||
description="Shown when the p2p peer's messaging is disabled" /> <a href="#"
|
||||
onClick={this.handleEnablePeer}><FormattedMessage id="enable_peers_messaging"
|
||||
defaultMessage="Enable" description="Call to action to enable peer's messaging" /></a>.
|
||||
</div> : null}
|
||||
{this.state.unconfirmed ?
|
||||
<Invitation onAction={this.handleNewChatAcceptance} />
|
||||
:
|
||||
<SendMessage
|
||||
tinode={this.props.tinode}
|
||||
topic={this.props.topic}
|
||||
disabled={!this.state.isWriter}
|
||||
sendMessage={this.props.sendMessage}
|
||||
onError={this.props.onError} />}
|
||||
{this.state.imagePreview ?
|
||||
<ImagePreview content={this.state.imagePreview}
|
||||
onClose={this.handleCloseImagePreview} /> : null}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
if (this.props.hideSelf) {
|
||||
component = null;
|
||||
} else if (!this.state.topic) {
|
||||
component = (
|
||||
<LogoView hideSelf={this.props.hideSelf}
|
||||
serverVersion={this.props.serverVersion}
|
||||
serverAddress={this.props.serverAddress} />
|
||||
);
|
||||
} else {
|
||||
let component2;
|
||||
if (this.state.imagePreview) {
|
||||
// Preview image before sending.
|
||||
component2 = (
|
||||
<ImagePreview
|
||||
content={this.state.imagePreview}
|
||||
onClose={this.handleClosePreview}
|
||||
onSendMessage={this.sendImageAttachment} />
|
||||
);
|
||||
} else if (this.state.imagePostview) {
|
||||
// Expand received image.
|
||||
component2 = (
|
||||
<ImagePreview
|
||||
content={this.state.imagePostview}
|
||||
onClose={this.handleClosePreview} />
|
||||
);
|
||||
} else if (this.state.docPreview) {
|
||||
// Preview attachment before sending.
|
||||
component2 = (
|
||||
<DocPreview
|
||||
content={this.state.docPreview}
|
||||
onClose={this.handleClosePreview}
|
||||
onSendMessage={this.sendFileAttachment} />
|
||||
);
|
||||
} else {
|
||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||
const groupTopic = topic.getType() == 'grp';
|
||||
let messageNodes = [];
|
||||
let previousFrom = null;
|
||||
let chatBoxClass = null;
|
||||
for (let i=0; i<this.state.messages.length; i++) {
|
||||
let msg = this.state.messages[i];
|
||||
let nextFrom = null;
|
||||
|
||||
if (i + 1 < this.state.messages.length) {
|
||||
nextFrom = this.state.messages[i+1].from
|
||||
}
|
||||
|
||||
let sequence = 'single';
|
||||
if (msg.from == previousFrom) {
|
||||
if (msg.from == nextFrom) {
|
||||
sequence = 'middle';
|
||||
} else {
|
||||
sequence = 'last';
|
||||
}
|
||||
} else if (msg.from == nextFrom) {
|
||||
sequence = 'first';
|
||||
}
|
||||
previousFrom = msg.from;
|
||||
|
||||
const isReply = !(msg.from == this.props.myUserId);
|
||||
const deliveryStatus = topic.msgStatus(msg);
|
||||
|
||||
let userName, userAvatar, userFrom;
|
||||
if (groupTopic) {
|
||||
const user = topic.userDesc(msg.from);
|
||||
if (user && user.public) {
|
||||
userName = user.public.fn;
|
||||
userAvatar = makeImageUrl(user.public.photo);
|
||||
}
|
||||
userFrom = msg.from;
|
||||
chatBoxClass='chat-box group';
|
||||
} else {
|
||||
chatBoxClass='chat-box';
|
||||
}
|
||||
|
||||
messageNodes.push(
|
||||
<ChatMessage
|
||||
tinode={this.props.tinode}
|
||||
content={msg.content} deleted={msg.hi}
|
||||
mimeType={msg.head ? msg.head.mime : null}
|
||||
timestamp={msg.ts} response={isReply} seq={msg.seq}
|
||||
userFrom={userFrom} userName={userName} userAvatar={userAvatar}
|
||||
sequence={sequence} received={deliveryStatus} uploader={msg._uploader}
|
||||
viewportWidth={this.props.viewportWidth}
|
||||
showContextMenu={this.handleShowContextMenuMessage}
|
||||
onImagePreview={this.handleImagePostview}
|
||||
onFormResponse={this.handleFormResponse}
|
||||
onError={this.props.onError}
|
||||
key={msg.seq} />
|
||||
);
|
||||
}
|
||||
|
||||
let lastSeen = null;
|
||||
const cont = this.props.tinode.getMeTopic().getContact(this.state.topic);
|
||||
if (cont && Tinode.topicType(cont.topic) == 'p2p') {
|
||||
if (cont.online) {
|
||||
lastSeen = formatMessage(messages.online_now);
|
||||
} else if (cont.seen) {
|
||||
lastSeen = formatMessage(messages.last_seen) + ": " +
|
||||
shortDateFormat(cont.seen.when, this.props.intl.locale);
|
||||
// TODO: also handle user agent in c.seen.ua
|
||||
}
|
||||
}
|
||||
const avatar = this.state.avatar || true;
|
||||
const online = this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline';
|
||||
|
||||
component2 = (
|
||||
<>
|
||||
<div id="topic-caption-panel" className="caption-panel">
|
||||
{this.props.displayMobile ?
|
||||
<a href="#" id="hide-message-view" onClick={(e) => {e.preventDefault(); this.props.onHideMessagesView();}}>
|
||||
<i className="material-icons">arrow_back</i>
|
||||
</a>
|
||||
:
|
||||
null}
|
||||
<div className="avatar-box">
|
||||
<LetterTile
|
||||
avatar={avatar}
|
||||
topic={this.state.topic}
|
||||
title={this.state.title} />
|
||||
<span className={online} />
|
||||
</div>
|
||||
<div id="topic-title-group">
|
||||
<div id="topic-title" className="panel-title">{
|
||||
this.state.title ||
|
||||
<i><FormattedMessage id="unnamed_topic" defaultMessage="Unnamed"
|
||||
description="Title shown when the topic has no name" /></i>
|
||||
}</div>
|
||||
<div id="topic-last-seen">{lastSeen}</div>
|
||||
</div>
|
||||
{groupTopic ?
|
||||
<GroupSubs
|
||||
subscribers={this.state.onlineSubs} /> :
|
||||
<div id="topic-users" />
|
||||
}
|
||||
<div>
|
||||
<a href="#" onClick={this.handleContextClick}>
|
||||
<i className="material-icons">more_vert</i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{this.props.displayMobile ?
|
||||
<ErrorPanel
|
||||
level={this.props.errorLevel}
|
||||
text={this.props.errorText}
|
||||
onClearError={this.props.onError} />
|
||||
: null}
|
||||
<LoadSpinner show={this.state.fetchingMessages} />
|
||||
<div id="messages-container">
|
||||
<div id="messages-panel" ref={this.handleScrollReference}>
|
||||
<ul id="scroller" className={chatBoxClass}>
|
||||
{messageNodes}
|
||||
</ul>
|
||||
</div>
|
||||
{!this.state.isReader ?
|
||||
<div id="write-only-background">
|
||||
{this.state.readingBlocked ?
|
||||
<div id="write-only-note">
|
||||
<FormattedMessage id="messages_not_readable" defaultMessage="no access to messages"
|
||||
description="Message shown in topic without the read access" />
|
||||
</div>
|
||||
: null }
|
||||
</div>
|
||||
: null }
|
||||
</div>
|
||||
{this.state.peerMessagingDisabled && !this.state.unconfirmed ?
|
||||
<div id="peer-messaging-disabled-note">
|
||||
<i className="material-icons secondary">block</i> <FormattedMessage
|
||||
id="peers_messaging_disabled" defaultMessage="Peer's messaging is disabled."
|
||||
description="Shown when the p2p peer's messaging is disabled" /> <a href="#"
|
||||
onClick={this.handleEnablePeer}><FormattedMessage id="enable_peers_messaging"
|
||||
defaultMessage="Enable" description="Call to action to enable peer's messaging" /></a>.
|
||||
</div> : null}
|
||||
{this.state.unconfirmed ?
|
||||
<Invitation onAction={this.handleNewChatAcceptance} />
|
||||
:
|
||||
<SendMessage
|
||||
disabled={!this.state.isWriter}
|
||||
onSendMessage={this.props.sendMessage}
|
||||
onKeyPress={this.sendKeyPress}
|
||||
onAttachFile={this.handleAttachFile}
|
||||
onAttachImage={this.handleAttachImage}
|
||||
onError={this.props.onError} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
component = <div id="topic-view">{component2}</div>
|
||||
}
|
||||
return component;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Create new topic and invite users or send an invite.
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import Tinode from 'tinode-sdk';
|
||||
|
||||
import ContactList from '../widgets/contact-list.jsx';
|
||||
import NewTopicById from '../widgets/new-topic-by-id.jsx';
|
||||
@@ -10,15 +12,30 @@ import SearchContacts from '../widgets/search-contacts.jsx';
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import { vcard } from '../lib/utils.js';
|
||||
|
||||
export default class NewTopicView extends React.Component {
|
||||
const messages = defineMessages({
|
||||
search_for_contacts: {
|
||||
id: "search_for_contacts",
|
||||
defaultMessage: "Use search to find contacts",
|
||||
description: "Text shown in contacts view when user entered no search query."
|
||||
},
|
||||
search_no_results: {
|
||||
id: "search_no_results",
|
||||
defaultMessage: "Search returned no results",
|
||||
description: "Text shown in contacts view when query returned no results."
|
||||
}
|
||||
});
|
||||
|
||||
class NewTopicView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
tabSelected: 'p2p'
|
||||
tabSelected: 'p2p',
|
||||
searchQuery: null
|
||||
};
|
||||
|
||||
this.handleTabClick = this.handleTabClick.bind(this);
|
||||
this.handleSearchContacts = this.handleSearchContacts.bind(this);
|
||||
this.handleContactSelected = this.handleContactSelected.bind(this);
|
||||
this.handleNewGroupSubmit = this.handleNewGroupSubmit.bind(this);
|
||||
this.handleGroupByID = this.handleGroupByID.bind(this);
|
||||
@@ -34,6 +51,11 @@ export default class NewTopicView extends React.Component {
|
||||
this.setState({tabSelected: e.currentTarget.dataset.id});
|
||||
}
|
||||
|
||||
handleSearchContacts(query) {
|
||||
this.props.onSearchContacts(query);
|
||||
this.setState({searchQuery: Tinode.isNullValue(query) ? null : query});
|
||||
}
|
||||
|
||||
handleContactSelected(sel) {
|
||||
if (this.state.tabSelected === 'p2p') {
|
||||
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
|
||||
@@ -52,6 +74,9 @@ export default class NewTopicView extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const {formatMessage} = this.props.intl;
|
||||
const no_contacts_placeholder = formatMessage(this.state.searchQuery ?
|
||||
messages.search_no_results : messages.search_for_contacts);
|
||||
return (
|
||||
<div className="flex-column">
|
||||
<ul className="tabbar">
|
||||
@@ -81,21 +106,21 @@ export default class NewTopicView extends React.Component {
|
||||
onSubmit={this.handleGroupByID}
|
||||
onError={this.props.onError} /> :
|
||||
<div className="flex-column">
|
||||
<SearchContacts type="p2p"
|
||||
onSearchContacts={this.props.onSearchContacts} />
|
||||
<FormattedMessage id="search_for_contacts" defaultMessage="Use search to find contacts"
|
||||
description="Prompt in search field.">{
|
||||
(search_for_contacts) => <ContactList
|
||||
contacts={this.props.searchResults}
|
||||
myUserId={this.props.myUserId}
|
||||
emptyListMessage={search_for_contacts}
|
||||
showOnline={false}
|
||||
showUnread={false}
|
||||
showContextMenu={false}
|
||||
onTopicSelected={this.handleContactSelected} />
|
||||
}</FormattedMessage>
|
||||
<SearchContacts
|
||||
type="p2p"
|
||||
onSearchContacts={this.handleSearchContacts} />
|
||||
<ContactList
|
||||
contacts={this.props.searchResults}
|
||||
myUserId={this.props.myUserId}
|
||||
emptyListMessage={no_contacts_placeholder}
|
||||
showOnline={false}
|
||||
showUnread={false}
|
||||
showContextMenu={false}
|
||||
onTopicSelected={this.handleContactSelected} />
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default injectIntl(NewTopicView);
|
||||
|
||||
+12
-33
@@ -15,8 +15,8 @@ import InfoView from './info-view.jsx';
|
||||
import MessagesView from './messages-view.jsx';
|
||||
import SidepanelView from './sidepanel-view.jsx';
|
||||
|
||||
import { API_KEY, APP_NAME, DEFAULT_P2P_ACCESS_MODE, LOGGING_ENABLED, MEDIA_BREAKPOINT,
|
||||
READ_DELAY, RECEIVED_DELAY } from '../config.js';
|
||||
import { API_KEY, APP_NAME, DEFAULT_P2P_ACCESS_MODE, LOGGING_ENABLED,
|
||||
MEDIA_BREAKPOINT, RECEIVED_DELAY } from '../config.js';
|
||||
import { base64ReEncode, makeImageUrl } from '../lib/blob-helpers.js';
|
||||
import { detectServerAddress, isLocalHost, isSecureConnection } from '../lib/host-name.js';
|
||||
import LocalStorageUtil from '../lib/local-storage.js';
|
||||
@@ -55,8 +55,6 @@ class TinodeWeb extends React.Component {
|
||||
this.handleHashRoute = this.handleHashRoute.bind(this);
|
||||
this.handleOnline = this.handleOnline.bind(this);
|
||||
this.checkForAppUpdate = this.checkForAppUpdate.bind(this);
|
||||
this.handleAppVisibility = this.handleAppVisibility.bind(this);
|
||||
this.handleReadTimer = this.handleReadTimer.bind(this);
|
||||
this.handleVisibilityEvent = this.handleVisibilityEvent.bind(this);
|
||||
this.handleError = this.handleError.bind(this);
|
||||
this.handleLoginRequest = this.handleLoginRequest.bind(this);
|
||||
@@ -135,6 +133,8 @@ class TinodeWeb extends React.Component {
|
||||
(typeof FIREBASE_INIT != 'undefined'),
|
||||
firebaseToken: LocalStorageUtil.getObject('firebase-token'),
|
||||
|
||||
applicationVisible: !document.hidden,
|
||||
|
||||
errorText: '',
|
||||
errorLevel: null,
|
||||
errorAction: undefined,
|
||||
@@ -334,24 +334,8 @@ class TinodeWeb extends React.Component {
|
||||
this.setState({liveConnection: online});
|
||||
}
|
||||
|
||||
// Handling read notifications here to be able to pause
|
||||
// them then the window/tab is not visible.
|
||||
handleAppVisibility(visible, callback) {
|
||||
clearTimeout(this.readTimer);
|
||||
this.readTimerCallback = callback;
|
||||
if (visible && callback) {
|
||||
this.readTimer = setTimeout(callback, READ_DELAY);
|
||||
} else {
|
||||
this.readTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleReadTimer(callback) {
|
||||
this.handleAppVisibility(!document.hidden, callback);
|
||||
}
|
||||
|
||||
handleVisibilityEvent() {
|
||||
this.handleAppVisibility(!document.hidden, this.readTimerCallback);
|
||||
this.setState({applicationVisible: !document.hidden});
|
||||
}
|
||||
|
||||
handleError(err, level, action, actionText) {
|
||||
@@ -557,20 +541,15 @@ class TinodeWeb extends React.Component {
|
||||
const topic = this.tinode.getTopic(cont.topic);
|
||||
const archived = topic && topic.isArchived();
|
||||
|
||||
// New message received
|
||||
if (document.hidden) {
|
||||
if (this.state.messageSounds && !archived) {
|
||||
// New message received. Maybe the message is from the current user, then unread is 0.
|
||||
if (cont.unread > 0 && this.state.messageSounds && !archived) {
|
||||
// Skip update if the topic is currently open, otherwise the badge will annoyingly flash.
|
||||
if (document.hidden || this.state.topicSelected != cont.topic) {
|
||||
POP_SOUND.play();
|
||||
}
|
||||
this.resetContactList();
|
||||
|
||||
// Skip update if the topic is currently open, otherwise the badge will annoyingly flash.
|
||||
} else if (this.state.topicSelected != cont.topic) {
|
||||
if (this.state.messageSounds && !archived) {
|
||||
POP_SOUND.play();
|
||||
}
|
||||
this.resetContactList();
|
||||
}
|
||||
// Reorder contact list to use possibly updated 'touched'.
|
||||
this.resetContactList();
|
||||
} else if (what == 'recv') {
|
||||
// Explicitly ignoring "recv" -- it causes no visible updates to contact list.
|
||||
} else if (what == 'gone' || what == 'unsub') {
|
||||
@@ -1422,6 +1401,7 @@ class TinodeWeb extends React.Component {
|
||||
myUserId={this.state.myUserId}
|
||||
serverVersion={this.state.serverVersion}
|
||||
serverAddress={this.state.serverAddress}
|
||||
applicationVisible={this.state.applicationVisible}
|
||||
|
||||
errorText={this.state.errorText}
|
||||
errorLevel={this.state.errorLevel}
|
||||
@@ -1434,7 +1414,6 @@ class TinodeWeb extends React.Component {
|
||||
onData={this.tnData}
|
||||
onError={this.handleError}
|
||||
onNewTopicCreated={this.handleNewTopicCreated}
|
||||
readTimerHandler={this.handleReadTimer}
|
||||
showContextMenu={this.handleShowContextMenu}
|
||||
onChangePermissions={this.handleChangePermissions}
|
||||
onNewChat={this.handleNewChatInvitation}
|
||||
|
||||
@@ -20,13 +20,13 @@ export default class ChatMessage extends React.Component {
|
||||
props.uploader.onProgress = this.handleProgress.bind(this);
|
||||
}
|
||||
|
||||
this.handlePreviewImage = this.handlePreviewImage.bind(this);
|
||||
this.handleImagePreview = this.handleImagePreview.bind(this);
|
||||
this.handleFormButtonClick = this.handleFormButtonClick.bind(this);
|
||||
this.handleContextClick = this.handleContextClick.bind(this);
|
||||
this.handleCancelUpload = this.handleCancelUpload.bind(this);
|
||||
}
|
||||
|
||||
handlePreviewImage(e) {
|
||||
handleImagePreview(e) {
|
||||
e.preventDefault();
|
||||
this.props.onImagePreview({
|
||||
url: e.target.src,
|
||||
@@ -58,7 +58,9 @@ export default class ChatMessage extends React.Component {
|
||||
handleContextClick(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.props.showContextMenu({ seq: this.props.seq, y: e.pageY, x: e.pageX });
|
||||
const menuItems = this.props.received == Tinode.MESSAGE_STATUS_FAILED ? ['menu_item_send_retry'] : [];
|
||||
this.props.showContextMenu({ seq: this.props.seq, content: this.props.content,
|
||||
y: e.pageY, x: e.pageX }, menuItems);
|
||||
}
|
||||
|
||||
handleProgress(ratio) {
|
||||
@@ -70,10 +72,10 @@ export default class ChatMessage extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const sideClass = this.props.sequence + ' ' + (this.props.response ? 'left' : 'right');
|
||||
const bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ?
|
||||
'bubble tip' : 'bubble';
|
||||
const avatar = this.props.userAvatar || true;
|
||||
const sideClass = this.props.deleted ? 'center' :
|
||||
(this.props.sequence + ' ' + (this.props.response ? 'left' : 'right'));
|
||||
const bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ? 'bubble tip' : 'bubble';
|
||||
const avatar = this.props.deleted ? null : (this.props.userAvatar || true);
|
||||
const fullDisplay = (this.props.userFrom && this.props.response &&
|
||||
(this.props.sequence == 'single' || this.props.sequence == 'last'));
|
||||
|
||||
@@ -97,8 +99,14 @@ export default class ChatMessage extends React.Component {
|
||||
key={i} />);
|
||||
}, this);
|
||||
content = React.createElement('span', null, Drafty.format(content, draftyFormatter, this));
|
||||
} 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') {
|
||||
content = <><i className="material-icons">error_outline</i> <i>
|
||||
content = <><i className="material-icons gray">error_outline</i> <i className="gray">
|
||||
<FormattedMessage id="invalid_content"
|
||||
defaultMessage="invalid content" description="Shown when message is unreadable" />
|
||||
</i></>
|
||||
@@ -121,15 +129,20 @@ export default class ChatMessage extends React.Component {
|
||||
<div className="message-content">
|
||||
{content}
|
||||
{attachments}
|
||||
<ReceivedMarker
|
||||
timestamp={this.props.timestamp}
|
||||
received={this.props.received} />
|
||||
{this.props.timestamp ?
|
||||
<ReceivedMarker
|
||||
timestamp={this.props.timestamp}
|
||||
received={this.props.received} />
|
||||
: null}
|
||||
</div>
|
||||
<span className="menuTrigger">
|
||||
<a href="#" onClick={this.handleContextClick}>
|
||||
<i className="material-icons">expand_more</i>
|
||||
</a>
|
||||
</span>
|
||||
{this.props.deleted ?
|
||||
null :
|
||||
<span className="menuTrigger">
|
||||
<a href="#" onClick={this.handleContextClick}>
|
||||
<i className="material-icons">expand_more</i>
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
{fullDisplay ?
|
||||
<div className="author">
|
||||
@@ -166,7 +179,7 @@ function draftyFormatter(style, data, values, key) {
|
||||
attr.style = { width: dim.dstWidth + 'px', height: dim.dstHeight + 'px' };
|
||||
attr.src = sanitizeImageUrl(attr.src);
|
||||
if (attr.src) {
|
||||
attr.onClick = this.handlePreviewImage;
|
||||
attr.onClick = this.handleImagePreview;
|
||||
attr.className += ' image-clickable';
|
||||
} else {
|
||||
attr.src = 'img/broken_image.png';
|
||||
|
||||
@@ -32,6 +32,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Delete for All',
|
||||
description: 'Delete selected message(s) for all members'
|
||||
},
|
||||
send_retry: {
|
||||
id: 'menu_item_send_retry',
|
||||
defaultMessage: 'Retry',
|
||||
description: 'Retry sending message'
|
||||
},
|
||||
mute: {
|
||||
id: 'menu_item_mute',
|
||||
defaultMessage: 'Mute',
|
||||
@@ -139,6 +144,13 @@ class ContextMenu extends React.Component {
|
||||
return this.deleteMessages(false, true, params, errorHandler);
|
||||
}
|
||||
},
|
||||
'menu_item_send_retry': {
|
||||
id: 'menu_item_send_retry',
|
||||
title: formatMessage(messages.send_retry),
|
||||
handler: (params, errorHandler) => {
|
||||
return this.retryMessage(params, errorHandler);
|
||||
}
|
||||
},
|
||||
'topic_unmute': {
|
||||
id: 'topic_unmute',
|
||||
title: formatMessage(messages.unmute),
|
||||
@@ -331,6 +343,21 @@ class ContextMenu extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
// Retries sending failed message.
|
||||
retryMessage(params, errorHandler) {
|
||||
const topic = this.props.tinode.getTopic(params.topicName);
|
||||
// Remove the existing message entry.
|
||||
if (!topic || !topic.flushMessage(params.seq)) {
|
||||
return;
|
||||
}
|
||||
const msg = topic.createMessage(params.content, false);
|
||||
return topic.publishDraft(msg).catch((err) => {
|
||||
if (errorHandler) {
|
||||
errorHandler(err.message, 'err');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Function is used by context menu to set permissions.
|
||||
topicPermissionSetter(mode, params, errorHandler) {
|
||||
const topic = this.props.tinode.getTopic(params.topicName);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import SendMessage from '../widgets/send-message.jsx';
|
||||
|
||||
import { bytesToHumanSize } from '../lib/strformat.js';
|
||||
|
||||
// Get material icon name from mime type.
|
||||
function iconFromMime(mime) {
|
||||
// If more icons become available in material icons, add them to this mime-to-icon mapping.
|
||||
const mimeToIcon = {default: 'insert_drive_file', image: 'image', text: 'description', video: 'theaters'};
|
||||
|
||||
return mimeToIcon[mime] || mimeToIcon[(mime || '').split('/')[0]] || mimeToIcon['default'];
|
||||
}
|
||||
|
||||
export default class DocPreview extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.handleSendDoc = this.handleSendDoc.bind(this);
|
||||
}
|
||||
|
||||
handleSendDoc(caption) {
|
||||
this.props.onClose();
|
||||
this.props.onSendMessage(this.props.content.file);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.props.content) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div id="image-preview">
|
||||
<div id="image-preview-caption-panel">
|
||||
<span>{this.props.content.filename}</span>
|
||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a>
|
||||
</div>
|
||||
<div id="image-preview-container">
|
||||
<div className="flex-column narrow">
|
||||
<i className="material-icons gray">{iconFromMime(this.props.content.type)}</i>
|
||||
<div><b><FormattedMessage id="label_content_type" /></b> {this.props.content.type || 'application/octet-stream'}</div>
|
||||
<div><b><FormattedMessage id="label_size" /></b> {bytesToHumanSize(this.props.content.size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<SendMessage
|
||||
noInput={true}
|
||||
onSendMessage={this.handleSendDoc}
|
||||
onError={this.props.onError} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import SendMessage from '../widgets/send-message.jsx';
|
||||
|
||||
import { REM_SIZE } from '../config.js';
|
||||
import { fitImageSize } from '../lib/blob-helpers.js';
|
||||
@@ -13,6 +14,8 @@ export default class ImagePreview extends React.PureComponent {
|
||||
width: 0,
|
||||
height: 0
|
||||
};
|
||||
|
||||
this.handleSendImage = this.handleSendImage.bind(this);
|
||||
}
|
||||
|
||||
assignWidth(node) {
|
||||
@@ -25,6 +28,12 @@ export default class ImagePreview extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleSendImage(caption) {
|
||||
this.props.onClose();
|
||||
this.props.onSendMessage(caption, this.props.content.type, this.props.content.bits,
|
||||
this.props.content.width, this.props.content.height, this.props.content.filename);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.props.content) {
|
||||
return null;
|
||||
@@ -41,39 +50,50 @@ export default class ImagePreview extends React.PureComponent {
|
||||
// Averate font aspect ratio is ~0.5; File name takes 1/3 of the viewport width.
|
||||
const maxlength = Math.max(((this.state.width / REM_SIZE / 1.5) | 0) - 2, 12);
|
||||
if (filename.length > maxlength) {
|
||||
filename = filename.slice(0, maxlength/2 - 1) + '...' + filename.slice(1 - maxlength/2);
|
||||
filename = filename.slice(0, maxlength/2 - 1) + '…' + filename.slice(1 - maxlength/2);
|
||||
}
|
||||
const width = this.props.content.width || '-';
|
||||
const height = this.props.content.height || '-';
|
||||
return (
|
||||
<div id="image-preview" onClick={this.props.onClose}>
|
||||
<div id="image-preview">
|
||||
<div id="image-preview-caption-panel">
|
||||
<a href={this.props.content.url} download={this.props.content.filename}>
|
||||
<i className="material-icons">file_download</i> <FormattedMessage
|
||||
id="download_action" defaultMessage="download" description="Call to action [download]" />
|
||||
</a>
|
||||
{!this.props.onSendMessage ?
|
||||
<a href={this.props.content.url} download={this.props.content.filename}>
|
||||
<i className="material-icons">file_download</i> <FormattedMessage
|
||||
id="download_action" defaultMessage="download" description="Call to action [download]" />
|
||||
</a>
|
||||
:
|
||||
<span>{this.props.content.filename}</span>
|
||||
}
|
||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a>
|
||||
</div>
|
||||
<div id="image-preview-container" ref={(node) => this.assignWidth(node)}>
|
||||
<img src={this.props.content.url} style={size} />
|
||||
</div>
|
||||
<div id="image-preview-footer">
|
||||
<div>
|
||||
<div><b><FormattedMessage id="label_file_name" defaultMessage="File name:"
|
||||
description="Label for a file name" /></b></div>
|
||||
<div><span title={this.props.content.filename}>{filename ? filename : '-'}</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div><b><FormattedMessage id="label_content_type" defaultMessage="Content type:"
|
||||
description="Label for file content type (mime)" /></b></div>
|
||||
<div>{this.props.content.type}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div><b><FormattedMessage id="label_size" defaultMessage="Size:"
|
||||
description="Label for file size" /></b></div>
|
||||
<div>{width} × {height} px; {bytesToHumanSize(this.props.content.size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{this.props.onSendMessage ?
|
||||
<SendMessage
|
||||
messagePrompt="add_image_caption"
|
||||
acceptBlank={true}
|
||||
onSendMessage={this.handleSendImage}
|
||||
onError={this.props.onError} />
|
||||
:
|
||||
<div id="image-preview-footer">
|
||||
<div>
|
||||
<div><b><FormattedMessage id="label_file_name" defaultMessage="File name:"
|
||||
description="Label for a file name" /></b></div>
|
||||
<div><span title={this.props.content.filename}>{filename ? filename : '-'}</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div><b><FormattedMessage id="label_content_type" defaultMessage="Content type:"
|
||||
description="Label for file content type (mime)" /></b></div>
|
||||
<div>{this.props.content.type}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div><b><FormattedMessage id="label_size" defaultMessage="Size:"
|
||||
description="Label for file size" /></b></div>
|
||||
<div>{width} × {height} px; {bytesToHumanSize(this.props.content.size)}</div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,16 +9,16 @@ export default class LetterTile extends React.PureComponent {
|
||||
let avatar;
|
||||
if (this.props.avatar === true) {
|
||||
const isGroup = (Tinode.topicType(this.props.topic) == 'grp');
|
||||
const iconColor = (isGroup ? 'light-color' : 'dark-color')
|
||||
+ (Math.abs(stringHash(this.props.topic)) % 16);
|
||||
if (this.props.topic && this.props.title && this.props.title.trim()) {
|
||||
const letter = this.props.title.trim().charAt(0);
|
||||
const color = 'lettertile '
|
||||
+ (isGroup ? 'color' : 'dark-color')
|
||||
+ (Math.abs(stringHash(this.props.topic)) % 16);
|
||||
avatar = (<div className={color}><div>{letter}</div></div>)
|
||||
const className = 'lettertile ' + iconColor;
|
||||
avatar = (<div className={className}><div>{letter}</div></div>)
|
||||
} else {
|
||||
const className = 'material-icons ' + iconColor;
|
||||
avatar = isGroup ?
|
||||
<i className="material-icons">group</i> :
|
||||
<i className="material-icons">person</i>;
|
||||
<i className={className}>group</i> : <i className={className}>person</i>;
|
||||
}
|
||||
} else if (this.props.avatar) {
|
||||
// If avatar image is invalid, show a placeholder.
|
||||
|
||||
@@ -3,9 +3,8 @@ import React from 'react';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { Drafty } from 'tinode-sdk';
|
||||
|
||||
import { KEYPRESS_DELAY, MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE } from '../config.js';
|
||||
import { SUPPORTED_IMAGE_FORMATS, filePasted, fileToBase64, imageFileToBase64, imageFileScaledToBase64 } from '../lib/blob-helpers.js';
|
||||
import { bytesToHumanSize } from '../lib/strformat.js';
|
||||
import { KEYPRESS_DELAY } from '../config.js';
|
||||
import { filePasted } from '../lib/blob-helpers.js';
|
||||
|
||||
const messages = defineMessages({
|
||||
'messaging_disabled': {
|
||||
@@ -16,7 +15,12 @@ const messages = defineMessages({
|
||||
'type_new_message': {
|
||||
id: 'new_message_prompt',
|
||||
defaultMessage: 'New message',
|
||||
description: 'Prompt in SendMessage in read-only topic'
|
||||
description: 'Prompt in send message field'
|
||||
},
|
||||
'add_image_caption': {
|
||||
id: 'image_caption_prompt',
|
||||
defaultMessage: 'Image caption',
|
||||
description: 'Prompt in SendMessage for attached image'
|
||||
},
|
||||
'file_attachment_too_large': {
|
||||
id: 'file_attachment_too_large',
|
||||
@@ -49,15 +53,21 @@ class SendMessage extends React.PureComponent {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.messageEditArea.addEventListener('paste', this.handlePasteEvent, false);
|
||||
if (this.messageEditArea) {
|
||||
this.messageEditArea.addEventListener('paste', this.handlePasteEvent, false);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.messageEditArea.removeEventListener('paste', this.handlePasteEvent, false);
|
||||
if (this.messageEditArea) {
|
||||
this.messageEditArea.removeEventListener('paste', this.handlePasteEvent, false);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.messageEditArea.focus();
|
||||
if (this.messageEditArea) {
|
||||
this.messageEditArea.focus();
|
||||
}
|
||||
}
|
||||
|
||||
handlePasteEvent(e) {
|
||||
@@ -67,11 +77,10 @@ class SendMessage extends React.PureComponent {
|
||||
// FIXME: handle large files too.
|
||||
if (filePasted(e,
|
||||
(bits, mime, width, height, fname) => {
|
||||
this.props.sendMessage(Drafty.insertImage(null,
|
||||
0, mime, bits, width, height, fname));
|
||||
this.props.onAttachImage(mime, bits, width, height, fname);
|
||||
},
|
||||
(mime, bits, fname) => {
|
||||
this.props.sendMessage(Drafty.attachFile(null, mime, bits, fname));
|
||||
this.props.onAttachFile(mime, bits, fname);
|
||||
},
|
||||
this.props.onError)) {
|
||||
|
||||
@@ -82,34 +91,7 @@ class SendMessage extends React.PureComponent {
|
||||
|
||||
handleAttachImage(e) {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const file = e.target.files[0];
|
||||
// Check if the uploaded file is indeed an image and if it isn't too large.
|
||||
if (file.size > MAX_INBAND_ATTACHMENT_SIZE || SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) {
|
||||
// Convert image for size or format.
|
||||
imageFileScaledToBase64(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, false,
|
||||
// Success
|
||||
(bits, mime, width, height, fname) => {
|
||||
this.props.sendMessage(Drafty.insertImage(null,
|
||||
0, mime, bits, width, height, fname));
|
||||
},
|
||||
// Failure
|
||||
(err) => {
|
||||
this.props.onError(err, 'err');
|
||||
});
|
||||
} else {
|
||||
// Image can be uploaded as is. No conversion is needed.
|
||||
imageFileToBase64(file,
|
||||
// Success
|
||||
(bits, mime, width, height, fname) => {
|
||||
this.props.sendMessage(Drafty.insertImage(null,
|
||||
0, mime, bits, width, height, fname));
|
||||
},
|
||||
// Failure
|
||||
(err) => {
|
||||
this.props.onError(err, 'err');
|
||||
}
|
||||
);
|
||||
}
|
||||
this.props.onAttachImage(e.target.files[0]);
|
||||
}
|
||||
// Clear the value so the same file can be uploaded again.
|
||||
e.target.value = '';
|
||||
@@ -118,32 +100,7 @@ class SendMessage extends React.PureComponent {
|
||||
handleAttachFile(e) {
|
||||
const {formatMessage} = this.props.intl;
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const file = e.target.files[0];
|
||||
if (file.size > MAX_EXTERN_ATTACHMENT_SIZE) {
|
||||
// Too large.
|
||||
this.props.onError(formatMessage(messages.file_attachment_too_large,
|
||||
{size: bytesToHumanSize(file.size), limit: bytesToHumanSize(MAX_EXTERN_ATTACHMENT_SIZE)}), 'err');
|
||||
} else if (file.size > MAX_INBAND_ATTACHMENT_SIZE) {
|
||||
// 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(formatMessage(messages.cannot_initiate_upload));
|
||||
return;
|
||||
}
|
||||
// Format data and initiate upload.
|
||||
const uploadCompletionPromise = uploader.upload(file);
|
||||
const msg = Drafty.attachFile(null, file.type, null, file.name, file.size, uploadCompletionPromise);
|
||||
// Pass data and the uploader to the TinodeWeb.
|
||||
this.props.sendMessage(msg, uploadCompletionPromise, uploader);
|
||||
} else {
|
||||
// Small enough to send inband.
|
||||
fileToBase64(file,
|
||||
(mime, bits, fname) => {
|
||||
this.props.sendMessage(Drafty.attachFile(null, mime, bits, fname));
|
||||
},
|
||||
this.props.onError
|
||||
);
|
||||
}
|
||||
this.props.onAttachFile(e.target.files[0]);
|
||||
}
|
||||
// Clear the value so the same file can be uploaded again.
|
||||
e.target.value = '';
|
||||
@@ -152,8 +109,8 @@ class SendMessage extends React.PureComponent {
|
||||
handleSend(e) {
|
||||
e.preventDefault();
|
||||
const message = this.state.message.trim();
|
||||
if (message) {
|
||||
this.props.sendMessage(this.state.message.trim());
|
||||
if (message || this.props.acceptBlank || this.props.noInput) {
|
||||
this.props.onSendMessage(message);
|
||||
this.setState({message: ''});
|
||||
}
|
||||
}
|
||||
@@ -174,13 +131,12 @@ class SendMessage extends React.PureComponent {
|
||||
|
||||
handleMessageTyping(e) {
|
||||
const newState = {message: e.target.value};
|
||||
const now = new Date().getTime();
|
||||
if (now - this.state.keypressTimestamp > KEYPRESS_DELAY) {
|
||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||
if (topic.isSubscribed()) {
|
||||
topic.noteKeyPress();
|
||||
if (this.props.onKeyPress) {
|
||||
const now = new Date().getTime();
|
||||
if (now - this.state.keypressTimestamp > KEYPRESS_DELAY) {
|
||||
this.props.onKeyPress();
|
||||
newState.keypressTimestamp = now;
|
||||
}
|
||||
newState.keypressTimestamp = now;
|
||||
}
|
||||
this.setState(newState);
|
||||
}
|
||||
@@ -189,33 +145,44 @@ class SendMessage extends React.PureComponent {
|
||||
const {formatMessage} = this.props.intl;
|
||||
const prompt = this.props.disabled ?
|
||||
formatMessage(messages.messaging_disabled) :
|
||||
formatMessage(messages.type_new_message);
|
||||
(this.props.messagePrompt ?
|
||||
formatMessage(messages[this.props.messagePrompt]) :
|
||||
formatMessage(messages.type_new_message));
|
||||
return (
|
||||
<div id="send-message-panel">
|
||||
{this.props.onAttachFile ?
|
||||
(this.props.disabled ?
|
||||
<>
|
||||
<i className="material-icons disabled">photo</i>
|
||||
<i className="material-icons disabled">attach_file</i>
|
||||
</>
|
||||
:
|
||||
<>
|
||||
<a href="#" onClick={(e) => {e.preventDefault(); this.attachImage.click();}} title="Add image">
|
||||
<i className="material-icons secondary">photo</i>
|
||||
</a>
|
||||
<a href="#" onClick={(e) => {e.preventDefault(); this.attachFile.click();}} title="Attach file">
|
||||
<i className="material-icons secondary">attach_file</i>
|
||||
</a>
|
||||
</>)
|
||||
:
|
||||
null}
|
||||
{this.props.noInput ?
|
||||
<div className="hr thin" /> :
|
||||
<textarea id="sendMessage" placeholder={prompt}
|
||||
disabled={this.props.disabled} value={this.state.message}
|
||||
onChange={this.handleMessageTyping} onKeyPress={this.handleKeyPress}
|
||||
ref={(ref) => {this.messageEditArea = ref;}}
|
||||
autoFocus />}
|
||||
{this.props.disabled ?
|
||||
<i className="material-icons disabled">photo</i> :
|
||||
<a href="#" onClick={(e) => {e.preventDefault(); this.attachImage.click();}} title="Add image">
|
||||
<i className="material-icons secondary">photo</i>
|
||||
<i className="material-icons disabled">send</i> :
|
||||
<a href="#" onClick={this.handleSend} title="Send">
|
||||
<i className="material-icons">send</i>
|
||||
</a>}
|
||||
{this.props.disabled ?
|
||||
<i className="material-icons disabled">attach_file</i> :
|
||||
<a href="#" onClick={(e) => {e.preventDefault(); this.attachFile.click();}} title="Attach file">
|
||||
<i className="material-icons secondary">attach_file</i>
|
||||
</a>}
|
||||
<textarea id="sendMessage" placeholder={prompt}
|
||||
disabled={this.props.disabled} value={this.state.message}
|
||||
onChange={this.handleMessageTyping} onKeyPress={this.handleKeyPress}
|
||||
ref={(ref) => {this.messageEditArea = ref;}}
|
||||
autoFocus />
|
||||
{this.props.disabled ?
|
||||
<i className="material-icons disabled">send</i> :
|
||||
<a href="#" onClick={this.handleSend} title="Send">
|
||||
<i className="material-icons">send</i>
|
||||
</a>}
|
||||
<input type="file" ref={(ref) => {this.attachFile = ref;}}
|
||||
onChange={this.handleAttachFile} style={{display: 'none'}} />
|
||||
<input type="file" ref={(ref) => {this.attachImage = ref;}} accept="image/*"
|
||||
onChange={this.handleAttachImage} style={{display: 'none'}} />
|
||||
<input type="file" ref={(ref) => {this.attachFile = ref;}}
|
||||
onChange={this.handleAttachFile} style={{display: 'none'}} />
|
||||
<input type="file" ref={(ref) => {this.attachImage = ref;}} accept="image/*"
|
||||
onChange={this.handleAttachImage} style={{display: 'none'}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+909
-442
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
+339
-83
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
|
After Width: | Height: | Size: 12 KiB |
Reference in New Issue
Block a user