Compare commits

...
50 Commits
Author SHA1 Message Date
or-else e7840b05a6 removed debug printing 2020-02-18 14:34:40 +03:00
or-else 3484c6549e rebuilt with SDK fix 2020-02-18 14:01:35 +03:00
or-else 30054aec04 formatting 2020-02-14 09:59:35 +03:00
or-else fae274062a correct link to web-app icon 2020-02-14 09:58:35 +03:00
or-else 3b77af2a10 adding missing web-app.svg icon 2020-02-14 09:58:03 +03:00
or-else a4b757fa95 bump version and rebuild 2020-02-08 12:23:51 +03:00
or-else 9d7e1d4f63 rebuilt with fixed note-read & translations 2020-02-05 10:18:59 +03:00
or-else 858e62f732 google translate missing chinese strings 2020-02-04 18:05:58 +03:00
or-else 50a775fc72 fix missing read notifications 2020-01-22 21:01:05 +03:00
or-else feaefdb952 update and restructure for clarity 2020-01-22 11:05:02 +03:00
or-else 755ad00619 merge next 2020-01-21 22:09:45 +03:00
or-else c6e440ca72 merge 2020-01-21 16:18:19 +03:00
or-else f34f786bc6 bump version and rebuild 2020-01-21 14:24:21 +03:00
aforge b2e7a4d63c Merge pull request #43 from tinode/retry-context-menu
Add Retry option in context menu for failed messages.
2020-01-20 22:35:58 -08:00
aforge f100385c79 Minor syntax change. 2020-01-20 22:34:39 -08:00
aforge 0081ed3716 Add Retry option in context menu for failed messages. 2020-01-20 21:25:22 -08:00
or-else 065115d2bf done 2020-01-18 13:42:19 +03:00
or-else ed6518df9c almost works 2020-01-18 11:36:57 +03:00
or-else f7a52d70d6 first pass at file attachment previews 2020-01-18 11:15:37 +03:00
or-else 72d978ed55 previewing image before sending works now 2020-01-14 18:07:56 +03:00
or-else 90889454a0 fix placeholder text positioning 2020-01-13 19:25:07 +03:00
or-else d1c90f8434 more refactoring of image preview 2020-01-13 18:20:09 +03:00
or-else 8622926a4c first pass at implementing image preview before sending 2020-01-13 10:20:12 +03:00
or-else 2de35424b5 updated FCM instructions 2020-01-13 10:19:36 +03:00
or-else d603953a77 bump version and rebuild 2020-01-11 14:21:16 +03:00
or-else d0e3e238b8 missing "content deleted" when all content is deleted 2020-01-10 12:25:46 +03:00
or-else 83dfb5e9b2 fix for incorrect "Not found" user name in messages 2020-01-09 20:54:25 +03:00
or-else 727d926bf6 dependencies updated 2020-01-09 20:53:45 +03:00
or-else fe9f4bb17e bump version and rebuild 2020-01-05 16:34:26 -05:00
or-else 1afd876d99 updated instructions for setting up firebase in web app 2019-12-30 15:42:41 -05:00
or-else 6fee1ce5ea firebase made a breaking change in 7.0.0 - need different config 2019-12-30 15:31:39 -05:00
or-else 7828c372e3 better exclusion of node modules cache 2019-12-29 20:48:17 -05:00
or-else e02047345c check in .gitignore 2019-12-29 20:46:56 -05:00
or-else db76ec3266 dependencies updated 2019-12-23 10:27:34 +03:00
or-else 06de1c1fb6 bump version and rebuild 2019-12-23 09:54:35 +03:00
or-else a662787b9c fix for different representation of deleted messages 2019-12-12 15:58:21 +03:00
or-else bc4c0811d1 indicators of deleted content work now 2019-12-06 10:41:21 +03:00
or-else 893cbdaa0c does not crash on deleted messages 2019-12-03 13:01:23 +03:00
or-else 44c5e30f4d all dependencies updated 2019-11-30 12:16:29 +03:00
or-else b81ec3e634 update dependencies and bump version 2019-11-30 11:21:10 +03:00
or-else b48e4e63d9 rebuilt 2019-11-20 11:49:06 +03:00
or-else dce1875de4 simplify handling of new message notifications 2019-11-20 11:44:15 +03:00
or-else 3392bf5ed7 make sure unread is > 0 before playing a sound 2019-11-20 11:40:36 +03:00
or-else 99de97b2e1 pres.act is not set when the message is sent by the current user 2019-11-20 11:18:52 +03:00
or-else d30b616b26 bump version and rebuild 2019-11-18 09:25:30 +03:00
or-else aa7cb55c8e better presentation of unnamed contacts 2019-11-13 20:18:21 +03:00
or-else 9e756c6772 bump version and rebuild 2019-11-12 15:54:12 +03:00
or-else 8ee50adf4d fix base64 preprocessing 2019-11-12 15:53:52 +03:00
or-else 4c28753e6a show different message when search returned no contacts 2019-11-08 17:43:41 +03:00
or-else 1cdb8b724c rebuilt 2019-11-06 11:28:30 +03:00
33 changed files with 3144 additions and 2185 deletions
+3
View File
@@ -0,0 +1,3 @@
.DS_Store
node_modules
src/i18n/src/
+25 -10
View File
@@ -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
View File
@@ -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;
+1 -1
View File
File diff suppressed because one or more lines are too long
+6 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+1041 -1196
View File
File diff suppressed because it is too large Load Diff
+15 -15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": "重试"
}
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+41 -16
View File
@@ -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
View File
@@ -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}
+30 -17
View File
@@ -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';
+27
View File
@@ -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);
+51
View File
@@ -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>
);
}
};
+43 -23
View File
@@ -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} &times; {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} &times; {height} px; {bytesToHumanSize(this.props.content.size)}</div>
</div>
</div>}
</div>
);
}
+6 -6
View File
@@ -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.
+62 -95
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+339 -83
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB