Compare commits

...
55 Commits
Author SHA1 Message Date
or-else e05e75ed28 use server-provided limits instead of hardcoded ones 2020-12-17 20:14:03 -08:00
or-else 4d450e6b85 show indicators for channels 2020-12-16 16:27:23 -08:00
or-else b014592381 rebuilt with the bug fix in tinode-js 2020-12-13 19:03:16 -08:00
or-else 1d1c9400f8 dependencies updated 2020-12-11 17:19:01 -08:00
or-else 97d4bcfba9 update version, add romanian translation to service worker 2020-12-10 11:07:04 -08:00
or-else ed19ed750c change style of divider in chat list 2020-12-06 20:18:28 -08:00
or-else 93e7b69f88 Merge branch 'devel' of https://github.com/tinode/webapp into devel 2020-12-06 19:47:09 -08:00
or-else af58db914b fixed id of a translated message 2020-12-06 19:47:01 -08:00
or-else 0c9e6379d6 mention Romanian translation, fix reference to error message 2020-12-04 12:00:09 -08:00
Gene 311b36b977 Merge pull request #60 from georgelemon/ro-language-support
Adding i18n support for romanian language
2020-11-24 13:37:14 -08:00
George Lemon d12fd0e16f Adding i18n support for romanian language 2020-11-24 22:45:26 +02:00
or-else a7998a1d29 better visual style of broken images 2020-11-21 17:21:56 -08:00
or-else 231635f86d increase max inline image size 2020-11-10 14:47:58 -08:00
or-else 5574085a01 rebuilt 2020-11-02 15:08:31 -08:00
or-else 5d53899f89 fix for https://github.com/tinode/chat/issues/563 2020-11-02 11:38:41 -08:00
or-else 97c8114fb7 uploading and downloading works 2020-10-25 16:19:56 -07:00
or-else 28d825c681 out of band image uploads work, need testing and polish 2020-10-24 20:49:43 -07:00
or-else cede800726 wip: handling previews/cancellations while uploading 2020-10-22 17:40:40 -07:00
or-else 53c1f9fd0b everyting works except upload progress 2020-10-17 19:05:31 -07:00
or-else 796f16a003 works except for upload progress 2020-10-16 17:43:12 -07:00
or-else 738d84045d wip: fixing bugs in image uploads and display 2020-10-15 17:36:35 -07:00
or-else de1028e141 wip: out of band inline images are starting to work 2020-10-14 16:55:54 -07:00
or-else f9d622a8d8 wip: image scaling by size 2020-10-13 16:30:34 -07:00
or-else fbdf1df9d0 wip: adding out of band image sending 2020-10-13 14:46:45 -07:00
or-else 09baee8d73 Merge branch 'devel' 2020-10-11 10:23:33 -07:00
or-else 84d0ec5e9c bump version 2020-10-11 10:23:06 -07:00
or-else 4d9bc435fc Merge branch 'master' of https://github.com/tinode/webapp 2020-09-30 11:40:16 -07:00
Gene 7d08e65e64 Merge pull request #59 from HitmanCodes/master
Update README.md
2020-09-30 08:12:52 -07:00
HitmanCodes 6e19bf6add Update README.md 2020-09-30 19:16:46 +05:30
or-else 96ad18a902 dependencies updated 2020-09-15 13:57:35 -07:00
or-else 58983588a4 show "channel" instead of "online" in message view caption 2020-09-14 15:54:14 -07:00
or-else 9401a8d483 topic redirects work now, bug with starting topic from search results fixed 2020-09-04 11:01:44 -07:00
or-else 6019303f2c fixes for bugs in starting new topics 2020-09-03 15:03:51 -07:00
or-else 28fd6a7236 fix channel permissions & disable deleting messages 2020-08-25 17:44:14 -07:00
or-else e8d48eb5f0 adding missing dependency 2020-08-25 13:50:48 -07:00
or-else d91534236f dependencies updated 2020-08-24 11:38:01 -07:00
or-else 4d3682c0ef rebuilt 2020-08-01 15:12:20 -07:00
or-else 45c6cf22ff support for channels 2020-08-01 15:10:37 -07:00
or-else c56b30337f rebuilt 2020-07-31 15:40:07 -07:00
or-else ab128ba6bb dependencies updated, security fix for elliptic pkg 2020-07-30 13:51:40 -07:00
or-else b2bbe75f6d bump version. channel UI works 2020-07-20 14:12:20 -07:00
or-else 0f095ceeca adding channel support 2020-07-20 12:20:38 -07:00
or-else 331dea37cf bump version and rebuild 2020-07-17 10:02:40 -07:00
or-else af72df8a4f dependencies updated 2020-07-17 09:22:26 -07:00
or-else 4f56dfc276 hide load spinner on failed login 2020-07-13 18:20:38 -07:00
or-else 2817d83c56 bump version and rebuild 2020-07-12 09:50:45 -07:00
or-else 571b876626 bump version and rebuild 2020-07-06 09:17:32 -07:00
or-else 60867671aa rebuilt with german translation 2020-07-03 15:43:19 -04:00
or-else 1d46599a1f add german strings to service worker 2020-07-01 06:10:08 -04:00
or-else 04734a7dd9 Merge branch 'devel' of https://github.com/tinode/webapp into devel 2020-07-01 06:06:42 -04:00
or-else 37351f9fdf mention German translation 2020-07-01 06:06:25 -04:00
Gene 79fba3a402 Merge pull request #56 from natowi/patch-1
German translation
2020-07-01 12:10:34 +03:00
natowi 7c9cc230ac German translation 2020-06-30 21:19:59 +02:00
Gene ff2f6b035a Update issue templates 2020-06-28 15:43:43 +03:00
or-else b16d538942 another screenshot added 2020-06-23 17:40:12 +03:00
47 changed files with 4147 additions and 3170 deletions
+38
View File
@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve TinodeWeb
title: ''
labels: bug
assignees: ''
---
If you ant to ask a question, please post to https://groups.google.com/d/forum/tinode instead.
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Environment (please complete the following information):**
- OS: [e.g. Mac OS, Windows 10 Home]
- Browser [e.g. Chrome, Safari]
- TinodeWeb Version [e.g. 0.16.6]
**Console log**
Please attach or insert console log which illustrates the problem.
**Additional context**
Add any other context about the problem here.
+23
View File
@@ -0,0 +1,23 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: feature request
assignees: ''
---
If you ant to ask a question, please post to https://groups.google.com/d/forum/tinode instead.
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+6 -2
View File
@@ -1,8 +1,10 @@
# Single-page chat with Tinode # Tinode Web
## Briefing
Single-page web chat application for [Tinode](https://github.com/tinode/chat/). The app is built on 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. [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. Overall, it's a lot like open source WhatsApp or Telegram web apps.
Although the app is generally usable, keep in mind that this is work in progress. Some bugs probably exist, some features are missing. The app was tested in the latest Chrome & Firefox only. NPM package is [available](https://www.npmjs.com/package/tinode-webapp). Although the app is generally usable, keep in mind that this is work in progress. Some bugs probably exist, some features are missing. The app was tested in the latest Chrome & Firefox only. NPM package is [available](https://www.npmjs.com/package/tinode-webapp).
@@ -29,7 +31,9 @@ The app is fully internationalized using [React-Intl](https://github.com/formatj
As of the time of this writing the following translations exist: As of the time of this writing the following translations exist:
* English * English
* Chinese (simplified) * Chinese (simplified)
* German
* Korean * Korean
* Romanian
* Russian * Russian
* Spanish * Spanish
+63 -7
View File
@@ -541,6 +541,15 @@ div.alert .content {
margin-left: 1rem; margin-left: 1rem;
} }
/* Container with whitish background and rounded corners */
.rounded-container {
position: relative;
background-color: #fafafa;
border-radius: 0.1rem;
padding: 0.25rem;
margin: 0.25rem;
}
/* BEGIN InPlaceEdit styles */ /* BEGIN InPlaceEdit styles */
.in-place-edit { .in-place-edit {
display: inline-block; display: inline-block;
@@ -819,12 +828,13 @@ i.search {
min-height: 3.5rem; min-height: 3.5rem;
flex-grow: 0; flex-grow: 0;
flex-shrink: 0; flex-shrink: 0;
border-bottom: 1px solid #eee; /* partial bottom border */
border-collapse: collapse; background: linear-gradient(to right, transparent 3.5rem, #eee 1px, #eee 100%) 0 100% / 100% 1px no-repeat, #fff;
overflow-x: hidden; overflow-x: hidden;
} }
.contact-box > li:last-child { .contact-box > li:last-child {
border-bottom: none; /* remove border from the last contact */ /* remove border from the last contact */
background: #fff;
} }
.contact-box > li:hover { .contact-box > li:hover {
background-color: #eceff1; /* blue-gray 50 highlight */ background-color: #eceff1; /* blue-gray 50 highlight */
@@ -892,6 +902,13 @@ i.search {
cursor: pointer; cursor: pointer;
} }
.contact-box img.channel {
width: 1rem;
height: 1rem;
vertical-align: middle;
margin-left: 0.3rem;
}
/* Online-offline indicator positioned inside avatar-box */ /* Online-offline indicator positioned inside avatar-box */
.avatar-box .online, .offline { .avatar-box .online, .offline {
display: inline-block; display: inline-block;
@@ -1520,15 +1537,54 @@ i.search {
color:#777; color:#777;
} }
/* Inline image styles */ /* Inline and preview image styles */
.inline-image { .inline-image, .image-preview {
max-width: 36rem; position: relative;
max-height: 24rem;
overflow: hidden; overflow: hidden;
border-radius: 0.1rem; border-radius: 0.1rem;
display: block; display: block;
} }
.inline-image {
max-width: 36rem;
max-height: 24rem;
}
/* Container with upload progress */
.inline-image > .rounded-container {
position: relative;
margin: 0.25rem auto;
bottom: 2rem;
/* Imitating minimum margins on the left and right */
border-left: 0.25rem solid transparent;
border-right: 0.25rem solid transparent;
}
/* Broken image graphics which replaces the stock graphics. */
img.inline-image::before, img.image-preview::before {
font-size: 0.9rem;
font-weight: lighter;
color: #666;
display:flex;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
justify-content: center;
background: center no-repeat #ccc url("../img/broken_image.png");
}
img.inline-image::before {
content: attr(alt);
background-size: 2rem 2rem;
}
img.image-preview::before {
content: " ";
background-size: 8rem 8rem;
}
/* Attachment styles */ /* Attachment styles */
.attachment { .attachment {
display: flex; display: flex;
+1 -1
View File
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+3 -3
View File
@@ -32,10 +32,10 @@
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react@16/umd/react.development.js"></script> <script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react@16/umd/react.development.js"></script>
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@16/umd/react-dom.development.js"></script> <script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@16/umd/react-dom.development.js"></script>
<!-- React-Intl for translations --> <!-- React-Intl for translations -->
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@3/dist/react-intl.js"></script> <script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@4/dist/react-intl.js"></script>
<!-- Firebase for push notifications --> <!-- Firebase for push notifications -->
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@7.14.6/firebase-app.js"></script> <script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@7.20.0/firebase-app.js"></script>
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@7.14.6/firebase-messaging.js"></script> <script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@7.20.0/firebase-messaging.js"></script>
</head> </head>
<body> <body>
+3 -3
View File
@@ -24,9 +24,9 @@
<link rel="stylesheet" href="css/base.min.css" /> <link rel="stylesheet" href="css/base.min.css" />
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react@16.13.1/umd/react.production.min.js"></script> <script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react@16.13.1/umd/react.production.min.js"></script>
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@16.13.1/umd/react-dom.production.min.js"></script> <script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@16.13.1/umd/react-dom.production.min.js"></script>
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@3.12.1/dist/react-intl.min.js"></script> <script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@4.7.6/dist/react-intl.min.js"></script>
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@7.14.6/firebase-app.js"></script> <script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@7.20.0/firebase-app.js"></script>
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@7.14.6/firebase-messaging.js"></script> <script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@7.20.0/firebase-messaging.js"></script>
</head> </head>
<body> <body>
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div> <noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
+1920 -2461
View File
File diff suppressed because it is too large Load Diff
+18 -18
View File
@@ -1,7 +1,7 @@
{ {
"name": "tinode-webapp", "name": "tinode-webapp",
"description": "Tinode messenger for the web", "description": "Tinode messenger for the web",
"version": "0.16.6", "version": "0.16.9-rc1",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/tinode/example-react-js.git" "url": "https://github.com/tinode/example-react-js.git"
@@ -68,25 +68,25 @@
"author": "Tinode Authors <info@tinode.co>", "author": "Tinode Authors <info@tinode.co>",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"firebase": "^7.14.6", "firebase": "^7.24.0",
"react": "^16.13.1", "react": "^16.14.0",
"react-dom": "^16.13.1", "react-dom": "^16.14.0",
"react-intl": "^4.6.9", "react-intl": "^4.7.6",
"tinode-sdk": "^0.16.6" "tinode-sdk": "^0.16.9-rc2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.10.2", "@babel/core": "^7.12.10",
"@babel/preset-env": "^7.10.2", "@babel/preset-env": "^7.12.10",
"@babel/preset-react": "^7.10.1", "@babel/preset-react": "^7.12.10",
"babel-loader": "^8.1.0", "babel-loader": "^8.2.2",
"babel-plugin-react-intl": "^7.5.20", "babel-plugin-react-intl": "^7.9.4",
"browserslist": "^4.12.0", "browserslist": "^4.16.0",
"copy-webpack-plugin": "^6.0.2", "copy-webpack-plugin": "^6.4.0",
"cssnano": "^4.1.10", "cssnano": "^4.1.10",
"postcss": "^7.0.32", "postcss": "^7.0.35",
"postcss-cli": "^7.1.1", "postcss-cli": "^7.1.2",
"terser-webpack-plugin": "^3.0.3", "terser-webpack-plugin": "^3.1.0",
"webpack": "^4.43.0", "webpack": "^4.44.2",
"webpack-cli": "^3.3.11" "webpack-cli": "^3.3.12"
} }
} }
+12 -3
View File
@@ -1,12 +1,16 @@
// Must be located at the root. // Must be located at the root.
importScripts('https://cdn.jsdelivr.net/npm/firebase@7.14.6/firebase-app.js'); importScripts('https://cdn.jsdelivr.net/npm/firebase@7.24.0/firebase-app.js');
importScripts('https://cdn.jsdelivr.net/npm/firebase@7.14.6/firebase-messaging.js'); importScripts('https://cdn.jsdelivr.net/npm/firebase@7.24.0/firebase-messaging.js');
importScripts('firebase-init.js'); importScripts('firebase-init.js');
firebase.initializeApp(FIREBASE_INIT); firebase.initializeApp(FIREBASE_INIT);
// Basic internationalization. // Basic internationalization.
const i18n = { const i18n = {
'de': {
'new_message': "Neue Nachricht",
'new_chat': "Neuer Chat",
},
'en': { 'en': {
'new_message': "New message", 'new_message': "New message",
'new_chat': "New chat", 'new_chat': "New chat",
@@ -19,6 +23,10 @@ const i18n = {
'new_message': "새로운 메시지", 'new_message': "새로운 메시지",
'new_chat': "새로운 채팅", 'new_chat': "새로운 채팅",
}, },
'ro': {
'new_message': "Mesaj nou",
'new_chat': "Chat nou",
},
'ru': { 'ru': {
'new_message': "Новое сообщение", 'new_message': "Новое сообщение",
'new_chat': "Новый чат", 'new_chat': "Новый чат",
@@ -31,7 +39,8 @@ const i18n = {
self.i18nMessage = function(id) { self.i18nMessage = function(id) {
// Choose translations: given something like 'de-CH', try 'de-CH' then 'de' then 'en'. // Choose translations: given something like 'de-CH', try 'de-CH' then 'de' then 'en'.
const lang = i18n[self.locale] || i18n[self.baseLocale] || i18n['en']; const lang = i18n[self.locale] || i18n[self.baseLocale] || i18n['en'];
// Try id in the specified language, if missing try English, otherwise use id as the last resort. // Try finding string by id in the specified language, if missing try English, otherwise use the id itself
// as the last resort.
return lang[id] || i18n['en'][id] || id; return lang[id] || i18n['en'][id] || id;
} }
+17 -10
View File
@@ -23,7 +23,9 @@ export const RECEIVED_DELAY = 500;
export const READ_DELAY = 1000; export const READ_DELAY = 1000;
// The shortest allowed tag length. Matches the value on the server. // The shortest allowed tag length. Matches the value on the server.
export const MIN_TAG_LENGTH = 4; export const MIN_TAG_LENGTH = 2;
// The greatest allowed tag length. Matches the value on the server.
export const MAX_TAG_LENGTH = 96;
// The maximum number of tags allowed. Matches the value on the server. // The maximum number of tags allowed. Matches the value on the server.
export const MAX_TAG_COUNT = 16; export const MAX_TAG_COUNT = 16;
@@ -31,6 +33,8 @@ export const MAX_TAG_COUNT = 16;
export const DEFAULT_P2P_ACCESS_MODE = 'JRWPS'; export const DEFAULT_P2P_ACCESS_MODE = 'JRWPS';
// Access mode for new group topics created by the current user. // Access mode for new group topics created by the current user.
export const NEW_GRP_ACCESS_MODE = 'JRWPSAO'; export const NEW_GRP_ACCESS_MODE = 'JRWPSAO';
// Access mode for a channel.
export const CHANNEL_ACCESS_MODE = 'JR';
// Access mode for no access. // Access mode for no access.
export const NO_ACCESS_MODE = 'N'; export const NO_ACCESS_MODE = 'N';
@@ -51,21 +55,24 @@ export const BROKEN_IMAGE_SIZE = 32;
// Number of chat messages to fetch in one call. // Number of chat messages to fetch in one call.
export const MESSAGES_PAGE = 24; export const MESSAGES_PAGE = 24;
// Maximum in-band (included directly into the message) attachment size which fits into // Default maximum in-band (included directly into the message) attachment size which fits into
// a message of 256K in size, assuming base64 encoding and 1024 bytes of overhead. // a message of 256K in size. The actual binary size of the attachment should be smaller due to
// This is size of an object *before* base64 encoding is applied. // base64 encoding expansion and some overhead, for instance 1024 bytes.
// Increase this limit to a greater value in production, if desired. Also increase // Increase this limit to a greater value in production, if desired. Also increase
// max_message_size in server config. // max_message_size in the server config.
// MAX_INBAND_ATTACHMENT_SIZE = base64DecodedLen(max_message_size) - 1024; export const MAX_INBAND_ATTACHMENT_SIZE = 262144;
export const MAX_INBAND_ATTACHMENT_SIZE = 195584;
// Absolute maximum attachment size to be used with the server = 8MB. Increase to // Absolute maximum attachment size to be used with the server = 8MB. Increase to
// something like 100MB in production. // something like 100MB in production.
export const MAX_EXTERN_ATTACHMENT_SIZE = 1 << 23; export const MAX_EXTERN_ATTACHMENT_SIZE = 1 << 23;
// Maximum allowed linear dimension of an inline image in pixels. You may want // Maximum allowed linear dimension of an inline image in pixels. Larer images will be shrunk
// to adjust it to 1600 or 2400 for production. // to make the larger dimension fit under this size. You may want to adjust it to 1600 or
export const MAX_IMAGE_DIM = 768; // 2400 for production.
export const MAX_IMAGE_DIM = 1024;
// Linear dimensions of image preview: shrink image under this size for previews.
export const IMAGE_PREVIEW_DIM = 64;
// Maximum number of online users to be shown in a topic title bar. Others will be // Maximum number of online users to be shown in a topic title bar. Others will be
// hidden under "+X more" // hidden under "+X more"
+176
View File
@@ -0,0 +1,176 @@
{
"validate_credential_action": "bestätigen",
"title_tag_manager": "Schlagworte (Nutzer entdecken)",
"label_user_contacts": "Kontakte:",
"button_add_another": "Hinzufügen",
"label_message_sound": "Benachrichtigungston:",
"label_push_notifications": "Benachrichtigungsmeldungen:",
"label_push_notifications_disabled": "Benachrichtigungsmeldungen (erfordert HTTPS):",
"label_incognito_mode": "inkognito-Modus:",
"delete_account": "Konto löschen",
"delete_account_arning": "Möchten Sie das Konto wirklich löschen? Das kann nicht rückgängig gemacht werden",
"label_password": "Passwort",
"password_unchanged_prompt": "unverändert",
"button_logout": "Abmelden",
"button_delete_account": "Konto löschen",
"label_default_access_mode": "Standard Zugriffsmodus:",
"blocked_contacts_link": "Blockierte Kontakte ({count})",
"link_contact_us": "Kontakt",
"link_terms_of_service": "Nutzungsbedingungen",
"link_privacy_policy": "Datenschutzerklärung",
"label_sdk": "SDK:",
"label_server_address": "Server Adresse:",
"archived_contacts": "Archivierte Kontakte ({count})",
"contacts_not_found": "Sie haben keine Unterhaltungen<br />¯∖_(ツ)_/¯",
"full_name_prompt": "Vollständiger Name, z.B. Lisa Musterfrau",
"email_prompt": "E-Mail, z.B. jdoe@beispiel.de",
"button_sign_up": "Anmelden",
"label_your_name": "Ihr Name",
"label_user_id": "ID:",
"button_edit": "Bearbeiten",
"requested_permissions": "Angefordert",
"granted_permissions": "Erteilt",
"menu_item_edit_permissions": "Berechtigungen ändern",
"label_other_user": "Andere",
"action_clear_messages": "Nachrichten leeren",
"clear_messages_warning": "Sind Sie sicher, dass Sie diese Unterhaltung für alle leeren wollen? Das kann nicht rückgängig gemacht werden.",
"action_delete_messages": "Nachrichten für alle löschen",
"delete_messages_warning": "Sind Sie sicher, dass Sie diese Unterhaltung für alle löschen wollen? Das kann nicht rückgängig gemacht werden.",
"action_leave_chat": "Unterhaltung verlassen",
"leave_chat_warning": "Möchten Sie diese Unterhaltung wirklich verlassen?",
"action_block_contact": "Kontakt blockieren",
"block_contact_warning": "Möchten Sie diesen Kontakt wirklich blockieren?",
"action_report_chat": "Unterhaltung melden",
"report_chat_warning": "Möchten Sie diese Unterhaltung wirklich blockieren und melden?",
"title_info": "Info",
"label_topic_name": "Name",
"label_private": "Privater Kommentar",
"private_editing_placeholder": "Nur für Sie sichtbar",
"label_muting_topic": "Stumm geschaltet:",
"action_more": "Mehr",
"label_your_permissions": "Ihre Berechtigungen:",
"label_permissions": "Berechtigungen:",
"label_you": "Sie:",
"label_default_access": "Standard Zugriffsmodus:",
"label_group_members": "Gruppenmitglieder:",
"button_add_members": "Mitglieder hinzufügen",
"group_has_no_members": "Keine Mitglieder",
"login_prompt": "Anmelden",
"password_prompt": "Passwort",
"stay_logged_in": "Angemeldet bleiben",
"forgot_password_link": "Passwort vergessen?",
"button_sign_in": "Anmelden",
"label_client": "Client:",
"label_server": "Server:",
"online_now": "jetzt online",
"last_seen_timestamp": "Zuletzt gesehen",
"title_not_found": "Nicht gefunden",
"unnamed_topic": "Unbenannt",
"messages_not_readable": "Neue Nachrichten können nicht gelesen werden",
"peers_messaging_disabled": "Gruppennachrichten sind deaktiviert",
"enable_peers_messaging": "Aktivieren",
"search_for_contacts": "Nutzen Sie die Suche um Kontakte zu finden",
"search_no_results": "Die Suche hatte keine Ergebnisse",
"tabtitle_find_user": "Suchen",
"tabtitle_new_group": "Neue Gruppe",
"tabtitle_group_by_id": "nach ID",
"new_password_placeholder": "Geben Sie ein neues Passwort ein",
"label_reset_password": "Passwort per E-Mail wiederherstellen:",
"credential_email_prompt": "E-Mail Adresse für Registrierung",
"button_reset": "Zurücksetzen",
"button_send_request": "Anfrage senden",
"label_server_to_use": "Server verwenden:",
"label_wire_transport": "Übertragung per Kabel:",
"button_update": "Aktualisieren",
"sidepanel_title_login": "Anmelden",
"sidepanel_title_register": "Konto erstellen",
"sidepanel_title_settings": "Einstellungen",
"sidepanel_title_account_settings": "Konto-Einstellungen",
"sidepanel_title_acc_general": "Allgemein",
"sidepanel_title_acc_security": "Sicherheit",
"sidepanel_title_acc_notifications": "Benachrichtigungen",
"sidepanel_title_acc_support": "Unterstützung",
"sidepanel_title_newtpk": "Neue Unterhaltung starten",
"sidepanel_title_cred": "Anmeldeinformationen bestätigen",
"sidepanel_title_reset": "Passwort zurücksetzen",
"sidepanel_title_archive": "Archivierte Unterhaltungen",
"sidepanel_title_blocked": "Blockierte Unterhaltungen",
"reconnect_countdown": "Getrennt. Wiederverbinden in {seconds}…",
"reconnect_now": "Jetzt probieren",
"push_init_failed": "Initialisierung von Push-Benachrichtigungen fehlgeschlagen",
"invalid_security_token": "Ungültiger Sicherheitsschlüssel",
"no_connection": "Keine Verbindung",
"code_doesnot_match": "Code stimmt nicht überein",
"update_available": "Aktualisierung verfügbar.",
"reload_update": "Neu laden",
"phone_dative": "Telefon",
"email_dative": "E-Mail",
"enter_confirmation_code_prompt": "Geben Sie den Bestätigungscode ein, der per {method} geschickt wurde:",
"numeric_confirmation_code_prompt": "Nur zahlen",
"button_confirm": "Bestätigen",
"save_attachment": "Speichern",
"deleted_content": "Inhalt gelöscht",
"invalid_content": "ungültiger Inhalt",
"user_not_found": "Nicht gefunden",
"badge_you": "Sie",
"badge_owner": "Besitzer",
"menu_item_info": "Info",
"menu_item_clear_messages": "Nachrichten leeren",
"menu_item_clear_messages_for_all": "Für alle leeren",
"menu_item_delete": "Löschen",
"menu_item_delete_for_all": "Für alle löschen",
"menu_item_send_retry": "Wiederholen",
"menu_item_mute": "Unterhaltung stumm schalten",
"menu_item_unmute": "Stumm schalten beenden",
"menu_item_delete_topic": "Entfernen",
"topic_delete_warning": "Möchten Sie diese Unterhaltung wirklich löschen?",
"menu_item_unblock": "Blockierung aufheben",
"menu_item_block": "Blockieren",
"topic_block_warning": "Möchten Sie diese Unterhaltung wirklich blockieren?",
"menu_item_member_delete": "Entfernen",
"menu_item_archive_topic": "Archivieren",
"action_cancel": "Abbrechen",
"upload_finishing": "wird abgeschlossen...",
"no_contacts": "Sie haben keine Kontakte :-(",
"contacts_not_found_short": "Keine Kontakte für ''{query}''",
"title_group_members": "Gruppenmitglieder",
"title_all_contacts": "Alle Kontakte",
"button_ok": "OK",
"button_cancel": "Abbrechen",
"more_online_members": "+{overflow} mehr",
"download_action": "herunterladen",
"label_file_name": "Dateiname:",
"label_content_type": "Inhaltsart:",
"label_size": "Größe:",
"chat_invitation": "Sie sind eingeladen, an einem neuen Chat teilzunehmen. Was möchten Sie tun?",
"chat_invitation_accept": "Akzeptieren",
"chat_invitation_ignore": "Ignorieren",
"chat_invitation_block": "Blockieren",
"error_invalid_id": "ungültige ID",
"group_user_id_prompt": "Gruppe oder Benutzer ID",
"button_subscribe": "Abbonieren",
"topic_name_editing_placeholder": "Freiform Name der Gruppe",
"button_create": "Erstellen",
"permission_join": "Beitreten ({val})",
"permission_read": "Lesen ({val})",
"permission_write": "Schreiben ({val})",
"permission_pres": "Benachrichtigt werden ({val})",
"permission_admin": "Bestätigen ({val})",
"permission_share": "Teilen ({val})",
"permission_delete": "Entfernen ({val})",
"permission_owner": "Besitzer ({val})",
"title_permissions": "Berechtigungen",
"message_sending": "wird gesendet...",
"message_sending_failed": "fehlgeschlagen",
"search_placeholder": "Liste: email:lisa@beispiel.de, tel:17025550003...",
"messaging_disabled_prompt": "Nachrichtenübermittlung deaktiviert",
"new_message_prompt": "Neue Nachricht",
"image_caption_prompt": "Bildunterschrift",
"file_attachment_too_large": "Die Dateigröße {size} überschreitet das Limit von {limit}.",
"cannot_initiate_file_upload": "Datei kann nicht hochgeladen werden.",
"tags_not_found": "Keine Schlagworte definiert. Erstellen Sie welche.",
"tags_editor_no_tags": "Schlagworte hinzufügen",
"title_manage_tags": "Verwalten",
"channel_prompt": "Dies ist ein Sender",
"channel": "Sender"
}
+2
View File
@@ -65,6 +65,7 @@
"online_now": "online now", "online_now": "online now",
"last_seen_timestamp": "Last seen", "last_seen_timestamp": "Last seen",
"title_not_found": "Not found", "title_not_found": "Not found",
"channel": "channel",
"unnamed_topic": "Unnamed", "unnamed_topic": "Unnamed",
"messages_not_readable": "no access to messages", "messages_not_readable": "no access to messages",
"peers_messaging_disabled": "Peer's messaging is disabled.", "peers_messaging_disabled": "Peer's messaging is disabled.",
@@ -150,6 +151,7 @@
"group_user_id_prompt": "Group or User ID", "group_user_id_prompt": "Group or User ID",
"button_subscribe": "Subscribe", "button_subscribe": "Subscribe",
"topic_name_editing_placeholder": "Freeform name of the group", "topic_name_editing_placeholder": "Freeform name of the group",
"channel_prompt": "This is a channel",
"button_create": "Create", "button_create": "Create",
"permission_join": "Join ({val})", "permission_join": "Join ({val})",
"permission_read": "Read ({val})", "permission_read": "Read ({val})",
+3 -1
View File
@@ -170,5 +170,7 @@
"cannot_initiate_file_upload": "No se pudo iniciar la carga del archivo.", "cannot_initiate_file_upload": "No se pudo iniciar la carga del archivo.",
"tags_not_found": "No hay etiquetas definidas. Añade unas.", "tags_not_found": "No hay etiquetas definidas. Añade unas.",
"tags_editor_no_tags": "Añadir etiquetas", "tags_editor_no_tags": "Añadir etiquetas",
"title_manage_tags": "Gestionar" "title_manage_tags": "Gestionar",
"channel_prompt": "Este es un canal",
"channel": "canal"
} }
+5 -3
View File
@@ -171,7 +171,9 @@
"tags_not_found": "태그가정의되지않았습니다.일부를추가하십시오.", "tags_not_found": "태그가정의되지않았습니다.일부를추가하십시오.",
"tags_editor_no_tags": "일부태그추가", "tags_editor_no_tags": "일부태그추가",
"title_manage_tags": "관리", "title_manage_tags": "관리",
"granted_permissions": ":!![MISSING]!!:Granted", "granted_permissions": "승낙하다",
"messages_not_readable": ":!![MISSING]!!:no access to messages", "messages_not_readable": "메시지에 액세스 할 수 없습니다",
"deleted_content": ":!![MISSING]!!:content deleted" "deleted_content": "내용이 삭제되었습니다",
"channel_prompt": "이 채널입니다",
"channel": "채널"
} }
+176
View File
@@ -0,0 +1,176 @@
{
"validate_credential_action": "confirmă",
"title_tag_manager": "Tag-uri (user discovery)",
"label_user_contacts": "Contacte:",
"button_add_another": "Adaugă",
"label_message_sound": "Sunet de mesaj:",
"label_push_notifications": "Alerte de notificare:",
"label_push_notifications_disabled": "Alerte de notificare (necesită HTTPS):",
"label_incognito_mode": "Mod incognito:",
"delete_account": "Șterge contul",
"delete_account_arning": "Ești sigur că dorești să ștergi contul? Acesta nu va putea fi recuperat.",
"label_password": "Parola",
"password_unchanged_prompt": "Neschimbată",
"button_logout": "Deconectare",
"button_delete_account": "Șterge cont",
"label_default_access_mode": "Default access mode:",
"blocked_contacts_link": "Contact blocate ({count})",
"link_contact_us": "Contactează-ne",
"link_terms_of_service": "Termenii Serviciului",
"link_privacy_policy": "Politica de Confidențialitate",
"label_sdk": "SDK:",
"label_server_address": "Adresa serverului:",
"archived_contacts": "Contacte arhivate ({count})",
"contacts_not_found": "Nu ai contacte salvate<br />¯∖_(ツ)_/¯",
"full_name_prompt": "Numele tău",
"email_prompt": "Adresa de e-mail",
"button_sign_up": "Creează cont",
"label_your_name": "Numele tău",
"label_user_id": "ID:",
"button_edit": "Editează",
"requested_permissions": "Necesare",
"granted_permissions": "Oferite",
"menu_item_edit_permissions": "Editează permisiuni",
"label_other_user": "Altele",
"action_clear_messages": "Șterge mesajele",
"clear_messages_warning": "Ești sigur că dorești să ștergi mesajele? Acestea nu pot fi recuperate.",
"action_delete_messages": "Șterge toate mesajele",
"delete_messages_warning": "Ești sigur că dorești să șterge mesajele din toate conversațiile? Acestea nu pot fi recuperate.",
"action_leave_chat": "Ieși din conversație",
"leave_chat_warning": "Ești sigur că dorești să ieși din conersație?",
"action_block_contact": "Blochează contact",
"block_contact_warning": "Ești sigur că dorești să blochezi acest contact?",
"action_report_chat": "Raportează conversația",
"report_chat_warning": "Ești sigur că dorești să blochezi și să raportezi această conversație?",
"title_info": "Info",
"label_topic_name": "Nume",
"label_private": "Comentariu privat",
"private_editing_placeholder": "Vizibil doar ție",
"label_muting_topic": "Mod silențios (muted):",
"action_more": "Mai multe",
"label_your_permissions": "Permisiuniile tale:",
"label_permissions": "Permisiuni:",
"label_you": "Tu:",
"label_default_access": "Modul default:",
"label_group_members": "Membrii grupului:",
"button_add_members": "Adaugă persoane",
"group_has_no_members": "În acest grup nu se află persoane",
"login_prompt": "Conectează-te",
"password_prompt": "Parola",
"stay_logged_in": "Rămâi conectat",
"forgot_password_link": "Ți-ai uitat parola?",
"button_sign_in": "Conectează-te",
"label_client": "Client:",
"label_server": "Server:",
"online_now": "online acum",
"last_seen_timestamp": "Văzut ultima dată",
"title_not_found": "Nu a fost găsit",
"channel": "canal",
"unnamed_topic": "Nedenumit",
"messages_not_readable": "Mesajele nu pot fi citite",
"peers_messaging_disabled": "Mesageria de tip Peer este dezactivată.",
"enable_peers_messaging": "Activează",
"search_for_contacts": "Caută în contacte",
"search_no_results": "Nu s-au găsit rezultate",
"tabtitle_find_user": "caută",
"tabtitle_new_group": "grup nou",
"tabtitle_group_by_id": "după id",
"new_password_placeholder": "Introdu parolă",
"label_reset_password": "Resetează parola:",
"credential_email_prompt": "Adresa de e-mail",
"button_reset": "Resetează",
"button_send_request": "Trimite cerere",
"label_server_to_use": "Server de utilizat:",
"label_wire_transport": "Transportul legat:",
"button_update": "Actualizează",
"sidepanel_title_login": "Conectează-te",
"sidepanel_title_register": "Creează Cont",
"sidepanel_title_settings": "Setări",
"sidepanel_title_account_settings": "Setările Contului",
"sidepanel_title_acc_general": "Generale",
"sidepanel_title_acc_security": "Securitate",
"sidepanel_title_acc_notifications": "Notificări",
"sidepanel_title_acc_support": "Ajutor",
"sidepanel_title_newtpk": "Creeză un nou Chat",
"sidepanel_title_cred": "Confirmă credențiale",
"sidepanel_title_reset": "Resetează Parola",
"sidepanel_title_archive": "Conversații Arhivate",
"sidepanel_title_blocked": "Conversații Blocate",
"reconnect_countdown": "Deconectat. Se încearcă conectarea în {seconds}…",
"reconnect_now": "Încearcă din nou",
"push_init_failed": "Nu s-a reușit inițializarea tip push notifications",
"invalid_security_token": "Codul de securitate este invalid",
"no_connection": "Nu există conexiune",
"code_doesnot_match": "Codul furnizat nu este valid",
"update_available": "Actualizare disponibilă.",
"reload_update": "Reîncarcă",
"phone_dative": "telefon",
"email_dative": "email",
"enter_confirmation_code_prompt": "Introu codul de confirmare trimis pe {method}:",
"numeric_confirmation_code_prompt": "Doar cifre",
"button_confirm": "Confirmă",
"save_attachment": "salvează",
"deleted_content": "conținut șters",
"invalid_content": "conținut invalid",
"user_not_found": "Utilizatorul nu a fost găsit",
"badge_you": "tu",
"badge_owner": "owner",
"menu_item_info": "Info",
"menu_item_clear_messages": "Șterge mesajele",
"menu_item_clear_messages_for_all": "Ștergele pe toate",
"menu_item_delete": "Șterge",
"menu_item_delete_for_all": "Ștergele pe toate",
"menu_item_send_retry": "Reîncearcă",
"menu_item_mute": "Mute",
"menu_item_unmute": "Unmute",
"menu_item_delete_topic": "Șterge",
"topic_delete_warning": "Ești sigur că dorești să șterge această conversație?",
"menu_item_unblock": "Deblochează",
"menu_item_block": "Blochează",
"topic_block_warning": "Ești sigur că dorești să blochezi această conversație?",
"menu_item_member_delete": "Șterge persoană",
"menu_item_archive_topic": "Arhivează",
"action_cancel": "anulează",
"upload_finishing": "se încarcă...",
"no_contacts": "Nu ai contacte adăugate :-(",
"contacts_not_found_short": "Niciun contact găsit după criteriile ''{query}''",
"title_group_members": "Persoanele din grup",
"title_all_contacts": "Toate Contactele",
"button_ok": "OK",
"button_cancel": "Anulează",
"more_online_members": "+{overflow} mai mult",
"download_action": "download",
"label_file_name": "Nume fișier:",
"label_content_type": "Tip conținut:",
"label_size": "Size:",
"chat_invitation": "Ai primit invitație într-o conversație. Accepți?",
"chat_invitation_accept": "Da, accept",
"chat_invitation_ignore": "Ignoră",
"chat_invitation_block": "Blochează",
"error_invalid_id": "ID Invalid",
"group_user_id_prompt": "Grup sau user ID",
"button_subscribe": "Subscribe",
"topic_name_editing_placeholder": "Numele grupului",
"channel_prompt": "Acesta este un canal",
"button_create": "Creează",
"permission_join": "Participare ({val})",
"permission_read": "Citire ({val})",
"permission_write": "Scriere ({val})",
"permission_pres": "Notificare ({val})",
"permission_admin": "Aprobare ({val})",
"permission_share": "Distribuire ({val})",
"permission_delete": "Ștergere ({val})",
"permission_owner": "Deținător ({val})",
"title_permissions": "Permisiuni",
"message_sending": "se trimite...",
"message_sending_failed": "eroare",
"search_placeholder": "Caută după e-mail sau număr telefon...",
"messaging_disabled_prompt": "Mesageria este dezactivată",
"new_message_prompt": "Mesaj nou",
"image_caption_prompt": "Titlul imaginii",
"file_attachment_too_large": "Dimensiunea fișiterului {size} depășește limita de {limit}.",
"cannot_initiate_file_upload": "Nu se poate face încărcarea.",
"tags_not_found": "Niciun tag definit. Adaugă.",
"tags_editor_no_tags": "Adaugă tag-uri",
"title_manage_tags": "Administrează"
}
+3 -1
View File
@@ -170,5 +170,7 @@
"push_init_failed": "Ошибка инициализации пуш уведомлений", "push_init_failed": "Ошибка инициализации пуш уведомлений",
"invalid_security_token": "Токен некорректен", "invalid_security_token": "Токен некорректен",
"no_connection": "Нет связи", "no_connection": "Нет связи",
"code_doesnot_match": "Код не совпадает" "code_doesnot_match": "Код не совпадает",
"channel_prompt": "Создать канал",
"channel": "канал"
} }
+3 -1
View File
@@ -170,5 +170,7 @@
"push_init_failed": "初始化推送通知失败", "push_init_failed": "初始化推送通知失败",
"invalid_security_token": "无效的安全令牌", "invalid_security_token": "无效的安全令牌",
"no_connection": "无连接", "no_connection": "无连接",
"code_doesnot_match": "代码不匹配" "code_doesnot_match": "代码不匹配",
"channel_prompt": "这是一个频道",
"channel": "频道"
} }
+52 -64
View File
@@ -1,5 +1,6 @@
// File and image helper functions. // File and image helper functions.
import { MAX_INBAND_ATTACHMENT_SIZE, MAX_IMAGE_DIM } from '../config.js'; import { MAX_INBAND_ATTACHMENT_SIZE, MAX_IMAGE_DIM } from '../config.js';
import { bytesToHumanSize } from './strformat.js'
// Supported image MIME types and corresponding file extensions. // Supported image MIME types and corresponding file extensions.
export const SUPPORTED_IMAGE_FORMATS = ['image/jpeg', 'image/gif', 'image/png', 'image/svg', 'image/svg+xml']; export const SUPPORTED_IMAGE_FORMATS = ['image/jpeg', 'image/gif', 'image/png', 'image/svg', 'image/svg+xml'];
@@ -55,105 +56,92 @@ export function fitImageSize(width, height, maxWidth, maxHeight, forceSquare) {
// Ensure file's extension matches mime content type // Ensure file's extension matches mime content type
export function fileNameForMime(fname, mime) { export function fileNameForMime(fname, mime) {
var idx = SUPPORTED_IMAGE_FORMATS.indexOf(mime); const idx = SUPPORTED_IMAGE_FORMATS.indexOf(mime);
var ext = MIME_EXTENSIONS[idx]; if (idx < 0 || !fname) {
// Unknown mime or empty name.
return fname;
}
const ext = MIME_EXTENSIONS[idx];
var at = fname.lastIndexOf('.'); const at = fname.lastIndexOf('.');
if (at >= 0) { if (at >= 0) {
fname = fname.substring(0, at); fname = fname.substring(0, at);
} }
return fname + '.' + ext; return fname + '.' + ext;
} }
// Convert uploaded image into a base64-encoded string possibly scaling // Scale uploaded image to fit under certain dimensions and byte size, optionally constraining to a square.
// linear dimensions or constraining to a square. // On success calls onSuccess callback with the scaled image as Blob.
export function imageFileScaledToBase64(file, width, height, forceSquare, onSuccess, onError) { export function imageScaled(fileOrBlob, maxWidth, maxHeight, maxSize, forceSquare, onSuccess, onError) {
var img = new Image(); const img = new Image();
img.crossOrigin = 'Anonymous'; img.crossOrigin = 'Anonymous';
img.onerror = function(err) { img.onerror = function(err) {
onError("Image format unrecognized"); onError("Image format unrecognized");
} }
img.onload = function() { img.onload = async function() {
var dim = fitImageSize(this.width, this.height, width, height, forceSquare); // Once the image is loaded, the URL is no longer needed.
URL.revokeObjectURL(img.src);
// Calculate the desired image dimensions.
const dim = fitImageSize(this.width, this.height, maxWidth, maxHeight, forceSquare);
if (!dim) { if (!dim) {
onError("Invalid image"); onError("Invalid image");
return; return;
} }
var canvas = document.createElement('canvas'); let canvas = document.createElement('canvas');
canvas.width = dim.dstWidth; canvas.width = dim.dstWidth;
canvas.height = dim.dstHeight; canvas.height = dim.dstHeight;
var ctx = canvas.getContext('2d'); let ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingEnabled = true;
ctx.drawImage(this, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight, ctx.drawImage(this, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight,
0, 0, dim.dstWidth, dim.dstHeight); 0, 0, dim.dstWidth, dim.dstHeight);
var mime = (this.width != dim.dstWidth || const mime = SUPPORTED_IMAGE_FORMATS.indexOf(fileOrBlob.type) < 0 ? 'image/jpeg' : fileOrBlob.type;
this.height != dim.dstHeight || // Generate blob to check size of the image.
SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) ? 'image/jpeg' : file.type; let blob = await new Promise(resolve => canvas.toBlob(resolve, mime));
var imageBits = canvas.toDataURL(mime); if (!blob) {
var parts = imageBits.split(',');
// Get actual image type: 'data:image/png;base64,'
mime = getMimeType(parts[0]);
if (!mime) {
onError("Unsupported image format"); onError("Unsupported image format");
return; return;
} }
// Ensure the image is not too large
var quality = 0.78; // Ensure the image is not too large. Shrink the image keeping the aspect ratio.
if (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE) { // Do nothing if maxsize is <= 0.
mime = 'image/jpeg'; while (maxSize > 0 && blob.length > maxSize) {
} dim.dstWidth = (dim.dstWidth * 0.70710678118) | 0;
if (mime == 'image/jpeg') { dim.dstHeight = (dim.dstHeight * 0.70710678118) | 0;
// Reduce size of the jpeg by reducing image quality canvas.width = dim.dstWidth;
while (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE && quality > 0.45) { canvas.height = dim.dstHeight;
imageBits = canvas.toDataURL(mime, quality); ctx = canvas.getContext('2d');
quality *= 0.84; ctx.clearRect(0, 0, canvas.width, canvas.height);
} ctx.drawImage(this, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight,
} 0, 0, dim.dstWidth, dim.dstHeight);
if (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE) { blob = await new Promise(resolve => canvas.toBlob(resolve, mime));
onError("The image size " + bytesToHumanSize(base64DecodedLen(imageBits.length)) +
" exceeds the " + bytesToHumanSize(MAX_INBAND_ATTACHMENT_SIZE) + " limit.", "err");
return;
} }
canvas = null; canvas = null;
onSuccess(imageBits.split(',')[1], mime, dim.dstWidth, dim.dstHeight, fileNameForMime(file.name, mime)); onSuccess(blob, mime, dim.dstWidth, dim.dstHeight, fileNameForMime(fileOrBlob.name, mime));
}; };
img.src = URL.createObjectURL(file); img.src = URL.createObjectURL(fileOrBlob);
} }
// Convert uploaded image file to base64-encoded string without scaling/converting the image // Convert File to base64 string.
export function imageFileToBase64(file, onSuccess, onError) { export function fileToBase64(file, onSuccess) {
var reader = new FileReader(); const reader = new FileReader();
reader.addEventListener('load', function() {
var parts = reader.result.split(',');
var mime = getMimeType(parts[0]);
if (!mime) {
onError("Failed to process image file");
return;
}
// Get image size.
var img = new Image();
img.crossOrigin = 'Anonymous';
img.onload = function() {
onSuccess(parts[1], mime, this.width, this.height, fileNameForMime(file.name, mime));
}
img.onerror = function(err) {
onError("Image format not recognized");
}
img.src = URL.createObjectURL(file);
}, false);
reader.readAsDataURL(file);
}
export function fileToBase64(file, onSuccess, onError) {
var reader = new FileReader();
reader.addEventListener('load', function() { reader.addEventListener('load', function() {
onSuccess(file.type, reader.result.split(',')[1], file.name); onSuccess(file.type, reader.result.split(',')[1], file.name);
}); });
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
// Convert Blob to base64 string.
export function blobToBase64(blob, onSuccess) {
const reader = new FileReader();
reader.addEventListener('load', function() {
onSuccess(blob.type, reader.result.split(',')[1]);
});
reader.readAsDataURL(blob);
}
// File pasted from the clipboard. It's either an inline image or a file attachment. // File pasted from the clipboard. It's either an inline image or a file attachment.
// FIXME: handle large files out of band. // FIXME: handle large files out of band.
export function filePasted(event, onImageSuccess, onAttachmentSuccess, onError) { export function filePasted(event, onImageSuccess, onAttachmentSuccess, onError) {
+9 -9
View File
@@ -5,8 +5,8 @@
export default class HashNavigation { export default class HashNavigation {
static parseUrlHash(hash) { static parseUrlHash(hash) {
// Split path from args, path -> parts[0], args->path[1] // Split path from args, path -> parts[0], args->path[1]
let parts = hash.split('?', 2); const parts = hash.split('?', 2);
let params = {}; const params = {};
let path = []; let path = [];
if (parts[0]) { if (parts[0]) {
path = parts[0].substr(1).split('/'); path = parts[0].substr(1).split('/');
@@ -27,9 +27,9 @@ export default class HashNavigation {
} }
static composeUrlHash(path, params) { static composeUrlHash(path, params) {
var url = path.join('/'); let url = path.join('/');
var args = []; const args = [];
for (var key in params) { for (const key in params) {
if (params.hasOwnProperty(key)) { if (params.hasOwnProperty(key)) {
args.push(key + '=' + params[key]); args.push(key + '=' + params[key]);
} }
@@ -41,25 +41,25 @@ export default class HashNavigation {
} }
static addUrlParam(hash, key, value) { static addUrlParam(hash, key, value) {
var parsed = this.parseUrlHash(hash); const parsed = this.parseUrlHash(hash);
parsed.params[key] = value; parsed.params[key] = value;
return this.composeUrlHash(parsed.path, parsed.params); return this.composeUrlHash(parsed.path, parsed.params);
} }
static removeUrlParam(hash, key) { static removeUrlParam(hash, key) {
var parsed = this.parseUrlHash(hash); const parsed = this.parseUrlHash(hash);
delete parsed.params[key]; delete parsed.params[key];
return this.composeUrlHash(parsed.path, parsed.params); return this.composeUrlHash(parsed.path, parsed.params);
} }
static setUrlSidePanel(hash, sidepanel) { static setUrlSidePanel(hash, sidepanel) {
var parsed = this.parseUrlHash(hash); const parsed = this.parseUrlHash(hash);
parsed.path[0] = sidepanel; parsed.path[0] = sidepanel;
return this.composeUrlHash(parsed.path, parsed.params); return this.composeUrlHash(parsed.path, parsed.params);
} }
static setUrlTopic(hash, topic) { static setUrlTopic(hash, topic) {
var parsed = this.parseUrlHash(hash); const parsed = this.parseUrlHash(hash);
parsed.path[1] = topic; parsed.path[1] = topic;
// Close InfoView on topic change. // Close InfoView on topic change.
delete parsed.params.info; delete parsed.params.info;
+7 -3
View File
@@ -88,11 +88,12 @@ export function asEmail(val) {
// http:/example.com - not ok. // http:/example.com - not ok.
// ' ↲ https://example.com' - not ok. (↲ means carriage return) // ' ↲ https://example.com' - not ok. (↲ means carriage return)
export function isUrlRelative(url) { export function isUrlRelative(url) {
return !/^\s*([a-z][a-z0-9+.-]*:|\/\/)/im.test(url); return url && !/^\s*([a-z][a-z0-9+.-]*:|\/\/)/im.test(url);
} }
// Ensure URL does not present an XSS risk. Optional allowedSchemes may contain an array of // Ensure URL does not present an XSS risk. Optional allowedSchemes may contain an array of
// strings with permitted URL schemes, such as ['ftp', 'ftps']; otherwise accept http and https only. // strings with permitted URL schemes, such as ['ftp', 'ftps']; otherwise accept http and https only.
// If apikey and/or token is defined, it's appended to relative URL.
export function sanitizeUrl(url, allowedSchemes) { export function sanitizeUrl(url, allowedSchemes) {
if (!url) { if (!url) {
return null; return null;
@@ -102,9 +103,11 @@ export function sanitizeUrl(url, allowedSchemes) {
url = url.replace(/[^\x20-\x7E]/gmi, '').trim(); url = url.replace(/[^\x20-\x7E]/gmi, '').trim();
// Relative URLs are safe. // Relative URLs are safe.
// Relative URL does not start with ':', abcd123: or '//'.
if (!/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(url)) { if (!/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(url)) {
return url; return url;
} }
// Blob URLs are safe. // Blob URLs are safe.
if (/^blob:http/.test(url)) { if (/^blob:http/.test(url)) {
return url; return url;
@@ -121,8 +124,9 @@ export function sanitizeUrl(url, allowedSchemes) {
} }
// Ensure URL is suitable for <img src="url"> field: the URL must be a relative URL or // Ensure URL is suitable for <img src="url"> field: the URL must be a relative URL or
// have http:, https:, or data: scheme. In case of data: scheme, the URL must start with // have http:, https:, blob: or data: scheme.
// a 'data:image/XXXX;base64,'. // The relative URL is appended with the API key and/or auth token.
// In case of data: scheme, the URL must start with a 'data:image/XXXX;base64,'.
export function sanitizeImageUrl(url) { export function sanitizeImageUrl(url) {
if (!url) { if (!url) {
return null; return null;
+369 -4
View File
@@ -1,4 +1,180 @@
{ {
"de": {
"validate_credential_action": "bestätigen",
"title_tag_manager": "Schlagworte (Nutzer entdecken)",
"label_user_contacts": "Kontakte:",
"button_add_another": "Hinzufügen",
"label_message_sound": "Benachrichtigungston:",
"label_push_notifications": "Benachrichtigungsmeldungen:",
"label_push_notifications_disabled": "Benachrichtigungsmeldungen (erfordert HTTPS):",
"label_incognito_mode": "inkognito-Modus:",
"delete_account": "Konto löschen",
"delete_account_arning": "Möchten Sie das Konto wirklich löschen? Das kann nicht rückgängig gemacht werden",
"label_password": "Passwort",
"password_unchanged_prompt": "unverändert",
"button_logout": "Abmelden",
"button_delete_account": "Konto löschen",
"label_default_access_mode": "Standard Zugriffsmodus:",
"blocked_contacts_link": "Blockierte Kontakte ({count})",
"link_contact_us": "Kontakt",
"link_terms_of_service": "Nutzungsbedingungen",
"link_privacy_policy": "Datenschutzerklärung",
"label_sdk": "SDK:",
"label_server_address": "Server Adresse:",
"archived_contacts": "Archivierte Kontakte ({count})",
"contacts_not_found": "Sie haben keine Unterhaltungen<br />¯∖_(ツ)_/¯",
"full_name_prompt": "Vollständiger Name, z.B. Lisa Musterfrau",
"email_prompt": "E-Mail, z.B. jdoe@beispiel.de",
"button_sign_up": "Anmelden",
"label_your_name": "Ihr Name",
"label_user_id": "ID:",
"button_edit": "Bearbeiten",
"requested_permissions": "Angefordert",
"granted_permissions": "Erteilt",
"menu_item_edit_permissions": "Berechtigungen ändern",
"label_other_user": "Andere",
"action_clear_messages": "Nachrichten leeren",
"clear_messages_warning": "Sind Sie sicher, dass Sie diese Unterhaltung für alle leeren wollen? Das kann nicht rückgängig gemacht werden.",
"action_delete_messages": "Nachrichten für alle löschen",
"delete_messages_warning": "Sind Sie sicher, dass Sie diese Unterhaltung für alle löschen wollen? Das kann nicht rückgängig gemacht werden.",
"action_leave_chat": "Unterhaltung verlassen",
"leave_chat_warning": "Möchten Sie diese Unterhaltung wirklich verlassen?",
"action_block_contact": "Kontakt blockieren",
"block_contact_warning": "Möchten Sie diesen Kontakt wirklich blockieren?",
"action_report_chat": "Unterhaltung melden",
"report_chat_warning": "Möchten Sie diese Unterhaltung wirklich blockieren und melden?",
"title_info": "Info",
"label_topic_name": "Name",
"label_private": "Privater Kommentar",
"private_editing_placeholder": "Nur für Sie sichtbar",
"label_muting_topic": "Stumm geschaltet:",
"action_more": "Mehr",
"label_your_permissions": "Ihre Berechtigungen:",
"label_permissions": "Berechtigungen:",
"label_you": "Sie:",
"label_default_access": "Standard Zugriffsmodus:",
"label_group_members": "Gruppenmitglieder:",
"button_add_members": "Mitglieder hinzufügen",
"group_has_no_members": "Keine Mitglieder",
"login_prompt": "Anmelden",
"password_prompt": "Passwort",
"stay_logged_in": "Angemeldet bleiben",
"forgot_password_link": "Passwort vergessen?",
"button_sign_in": "Anmelden",
"label_client": "Client:",
"label_server": "Server:",
"online_now": "jetzt online",
"last_seen_timestamp": "Zuletzt gesehen",
"title_not_found": "Nicht gefunden",
"unnamed_topic": "Unbenannt",
"messages_not_readable": "Neue Nachrichten können nicht gelesen werden",
"peers_messaging_disabled": "Gruppennachrichten sind deaktiviert",
"enable_peers_messaging": "Aktivieren",
"search_for_contacts": "Nutzen Sie die Suche um Kontakte zu finden",
"search_no_results": "Die Suche hatte keine Ergebnisse",
"tabtitle_find_user": "Suchen",
"tabtitle_new_group": "Neue Gruppe",
"tabtitle_group_by_id": "nach ID",
"new_password_placeholder": "Geben Sie ein neues Passwort ein",
"label_reset_password": "Passwort per E-Mail wiederherstellen:",
"credential_email_prompt": "E-Mail Adresse für Registrierung",
"button_reset": "Zurücksetzen",
"button_send_request": "Anfrage senden",
"label_server_to_use": "Server verwenden:",
"label_wire_transport": "Übertragung per Kabel:",
"button_update": "Aktualisieren",
"sidepanel_title_login": "Anmelden",
"sidepanel_title_register": "Konto erstellen",
"sidepanel_title_settings": "Einstellungen",
"sidepanel_title_account_settings": "Konto-Einstellungen",
"sidepanel_title_acc_general": "Allgemein",
"sidepanel_title_acc_security": "Sicherheit",
"sidepanel_title_acc_notifications": "Benachrichtigungen",
"sidepanel_title_acc_support": "Unterstützung",
"sidepanel_title_newtpk": "Neue Unterhaltung starten",
"sidepanel_title_cred": "Anmeldeinformationen bestätigen",
"sidepanel_title_reset": "Passwort zurücksetzen",
"sidepanel_title_archive": "Archivierte Unterhaltungen",
"sidepanel_title_blocked": "Blockierte Unterhaltungen",
"reconnect_countdown": "Getrennt. Wiederverbinden in {seconds}…",
"reconnect_now": "Jetzt probieren",
"push_init_failed": "Initialisierung von Push-Benachrichtigungen fehlgeschlagen",
"invalid_security_token": "Ungültiger Sicherheitsschlüssel",
"no_connection": "Keine Verbindung",
"code_doesnot_match": "Code stimmt nicht überein",
"update_available": "Aktualisierung verfügbar.",
"reload_update": "Neu laden",
"phone_dative": "Telefon",
"email_dative": "E-Mail",
"enter_confirmation_code_prompt": "Geben Sie den Bestätigungscode ein, der per {method} geschickt wurde:",
"numeric_confirmation_code_prompt": "Nur zahlen",
"button_confirm": "Bestätigen",
"save_attachment": "Speichern",
"deleted_content": "Inhalt gelöscht",
"invalid_content": "ungültiger Inhalt",
"user_not_found": "Nicht gefunden",
"badge_you": "Sie",
"badge_owner": "Besitzer",
"menu_item_info": "Info",
"menu_item_clear_messages": "Nachrichten leeren",
"menu_item_clear_messages_for_all": "Für alle leeren",
"menu_item_delete": "Löschen",
"menu_item_delete_for_all": "Für alle löschen",
"menu_item_send_retry": "Wiederholen",
"menu_item_mute": "Unterhaltung stumm schalten",
"menu_item_unmute": "Stumm schalten beenden",
"menu_item_delete_topic": "Entfernen",
"topic_delete_warning": "Möchten Sie diese Unterhaltung wirklich löschen?",
"menu_item_unblock": "Blockierung aufheben",
"menu_item_block": "Blockieren",
"topic_block_warning": "Möchten Sie diese Unterhaltung wirklich blockieren?",
"menu_item_member_delete": "Entfernen",
"menu_item_archive_topic": "Archivieren",
"action_cancel": "Abbrechen",
"upload_finishing": "wird abgeschlossen...",
"no_contacts": "Sie haben keine Kontakte :-(",
"contacts_not_found_short": "Keine Kontakte für ''{query}''",
"title_group_members": "Gruppenmitglieder",
"title_all_contacts": "Alle Kontakte",
"button_ok": "OK",
"button_cancel": "Abbrechen",
"more_online_members": "+{overflow} mehr",
"download_action": "herunterladen",
"label_file_name": "Dateiname:",
"label_content_type": "Inhaltsart:",
"label_size": "Größe:",
"chat_invitation": "Sie sind eingeladen, an einem neuen Chat teilzunehmen. Was möchten Sie tun?",
"chat_invitation_accept": "Akzeptieren",
"chat_invitation_ignore": "Ignorieren",
"chat_invitation_block": "Blockieren",
"error_invalid_id": "ungültige ID",
"group_user_id_prompt": "Gruppe oder Benutzer ID",
"button_subscribe": "Abbonieren",
"topic_name_editing_placeholder": "Freiform Name der Gruppe",
"button_create": "Erstellen",
"permission_join": "Beitreten ({val})",
"permission_read": "Lesen ({val})",
"permission_write": "Schreiben ({val})",
"permission_pres": "Benachrichtigt werden ({val})",
"permission_admin": "Bestätigen ({val})",
"permission_share": "Teilen ({val})",
"permission_delete": "Entfernen ({val})",
"permission_owner": "Besitzer ({val})",
"title_permissions": "Berechtigungen",
"message_sending": "wird gesendet...",
"message_sending_failed": "fehlgeschlagen",
"search_placeholder": "Liste: email:lisa@beispiel.de, tel:17025550003...",
"messaging_disabled_prompt": "Nachrichtenübermittlung deaktiviert",
"new_message_prompt": "Neue Nachricht",
"image_caption_prompt": "Bildunterschrift",
"file_attachment_too_large": "Die Dateigröße {size} überschreitet das Limit von {limit}.",
"cannot_initiate_file_upload": "Datei kann nicht hochgeladen werden.",
"tags_not_found": "Keine Schlagworte definiert. Erstellen Sie welche.",
"tags_editor_no_tags": "Schlagworte hinzufügen",
"title_manage_tags": "Verwalten",
"channel_prompt": "Dies ist ein Sender",
"channel": "Sender"
},
"en": { "en": {
"validate_credential_action": "confirm", "validate_credential_action": "confirm",
"title_tag_manager": "Tags (user discovery)", "title_tag_manager": "Tags (user discovery)",
@@ -66,6 +242,7 @@
"online_now": "online now", "online_now": "online now",
"last_seen_timestamp": "Last seen", "last_seen_timestamp": "Last seen",
"title_not_found": "Not found", "title_not_found": "Not found",
"channel": "channel",
"unnamed_topic": "Unnamed", "unnamed_topic": "Unnamed",
"messages_not_readable": "no access to messages", "messages_not_readable": "no access to messages",
"peers_messaging_disabled": "Peer's messaging is disabled.", "peers_messaging_disabled": "Peer's messaging is disabled.",
@@ -151,6 +328,7 @@
"group_user_id_prompt": "Group or User ID", "group_user_id_prompt": "Group or User ID",
"button_subscribe": "Subscribe", "button_subscribe": "Subscribe",
"topic_name_editing_placeholder": "Freeform name of the group", "topic_name_editing_placeholder": "Freeform name of the group",
"channel_prompt": "This is a channel",
"button_create": "Create", "button_create": "Create",
"permission_join": "Join ({val})", "permission_join": "Join ({val})",
"permission_read": "Read ({val})", "permission_read": "Read ({val})",
@@ -345,7 +523,9 @@
"cannot_initiate_file_upload": "No se pudo iniciar la carga del archivo.", "cannot_initiate_file_upload": "No se pudo iniciar la carga del archivo.",
"tags_not_found": "No hay etiquetas definidas. Añade unas.", "tags_not_found": "No hay etiquetas definidas. Añade unas.",
"tags_editor_no_tags": "Añadir etiquetas", "tags_editor_no_tags": "Añadir etiquetas",
"title_manage_tags": "Gestionar" "title_manage_tags": "Gestionar",
"channel_prompt": "Este es un canal",
"channel": "canal"
}, },
"ko": { "ko": {
"validate_credential_action": "확인", "validate_credential_action": "확인",
@@ -516,7 +696,188 @@
"cannot_initiate_file_upload": "파일업로드를시작할수없습니다.", "cannot_initiate_file_upload": "파일업로드를시작할수없습니다.",
"tags_not_found": "태그가정의되지않았습니다.일부를추가하십시오.", "tags_not_found": "태그가정의되지않았습니다.일부를추가하십시오.",
"tags_editor_no_tags": "일부태그추가", "tags_editor_no_tags": "일부태그추가",
"title_manage_tags": "관리" "title_manage_tags": "관리",
"granted_permissions": "승낙하다",
"messages_not_readable": "메시지에 액세스 할 수 없습니다",
"deleted_content": "내용이 삭제되었습니다",
"channel_prompt": "이 채널입니다",
"channel": "채널"
},
"ro": {
"validate_credential_action": "confirmă",
"title_tag_manager": "Tag-uri (user discovery)",
"label_user_contacts": "Contacte:",
"button_add_another": "Adaugă",
"label_message_sound": "Sunet de mesaj:",
"label_push_notifications": "Alerte de notificare:",
"label_push_notifications_disabled": "Alerte de notificare (necesită HTTPS):",
"label_incognito_mode": "Mod incognito:",
"delete_account": "Șterge contul",
"delete_account_arning": "Ești sigur că dorești să ștergi contul? Acesta nu va putea fi recuperat.",
"label_password": "Parola",
"password_unchanged_prompt": "Neschimbată",
"button_logout": "Deconectare",
"button_delete_account": "Șterge cont",
"label_default_access_mode": "Default access mode:",
"blocked_contacts_link": "Contact blocate ({count})",
"link_contact_us": "Contactează-ne",
"link_terms_of_service": "Termenii Serviciului",
"link_privacy_policy": "Politica de Confidențialitate",
"label_sdk": "SDK:",
"label_server_address": "Adresa serverului:",
"archived_contacts": "Contacte arhivate ({count})",
"contacts_not_found": "Nu ai contacte salvate<br />¯∖_(ツ)_/¯",
"full_name_prompt": "Numele tău",
"email_prompt": "Adresa de e-mail",
"button_sign_up": "Creează cont",
"label_your_name": "Numele tău",
"label_user_id": "ID:",
"button_edit": "Editează",
"requested_permissions": "Necesare",
"granted_permissions": "Oferite",
"menu_item_edit_permissions": "Editează permisiuni",
"label_other_user": "Altele",
"action_clear_messages": "Șterge mesajele",
"clear_messages_warning": "Ești sigur că dorești să ștergi mesajele? Acestea nu pot fi recuperate.",
"action_delete_messages": "Șterge toate mesajele",
"delete_messages_warning": "Ești sigur că dorești să șterge mesajele din toate conversațiile? Acestea nu pot fi recuperate.",
"action_leave_chat": "Ieși din conversație",
"leave_chat_warning": "Ești sigur că dorești să ieși din conersație?",
"action_block_contact": "Blochează contact",
"block_contact_warning": "Ești sigur că dorești să blochezi acest contact?",
"action_report_chat": "Raportează conversația",
"report_chat_warning": "Ești sigur că dorești să blochezi și să raportezi această conversație?",
"title_info": "Info",
"label_topic_name": "Nume",
"label_private": "Comentariu privat",
"private_editing_placeholder": "Vizibil doar ție",
"label_muting_topic": "Mod silențios (muted):",
"action_more": "Mai multe",
"label_your_permissions": "Permisiuniile tale:",
"label_permissions": "Permisiuni:",
"label_you": "Tu:",
"label_default_access": "Modul default:",
"label_group_members": "Membrii grupului:",
"button_add_members": "Adaugă persoane",
"group_has_no_members": "În acest grup nu se află persoane",
"login_prompt": "Conectează-te",
"password_prompt": "Parola",
"stay_logged_in": "Rămâi conectat",
"forgot_password_link": "Ți-ai uitat parola?",
"button_sign_in": "Conectează-te",
"label_client": "Client:",
"label_server": "Server:",
"online_now": "online acum",
"last_seen_timestamp": "Văzut ultima dată",
"title_not_found": "Nu a fost găsit",
"channel": "canal",
"unnamed_topic": "Nedenumit",
"messages_not_readable": "Mesajele nu pot fi citite",
"peers_messaging_disabled": "Mesageria de tip Peer este dezactivată.",
"enable_peers_messaging": "Activează",
"search_for_contacts": "Caută în contacte",
"search_no_results": "Nu s-au găsit rezultate",
"tabtitle_find_user": "caută",
"tabtitle_new_group": "grup nou",
"tabtitle_group_by_id": "după id",
"new_password_placeholder": "Introdu parolă",
"label_reset_password": "Resetează parola:",
"credential_email_prompt": "Adresa de e-mail",
"button_reset": "Resetează",
"button_send_request": "Trimite cerere",
"label_server_to_use": "Server de utilizat:",
"label_wire_transport": "Transportul legat:",
"button_update": "Actualizează",
"sidepanel_title_login": "Conectează-te",
"sidepanel_title_register": "Creează Cont",
"sidepanel_title_settings": "Setări",
"sidepanel_title_account_settings": "Setările Contului",
"sidepanel_title_acc_general": "Generale",
"sidepanel_title_acc_security": "Securitate",
"sidepanel_title_acc_notifications": "Notificări",
"sidepanel_title_acc_support": "Ajutor",
"sidepanel_title_newtpk": "Creeză un nou Chat",
"sidepanel_title_cred": "Confirmă credențiale",
"sidepanel_title_reset": "Resetează Parola",
"sidepanel_title_archive": "Conversații Arhivate",
"sidepanel_title_blocked": "Conversații Blocate",
"reconnect_countdown": "Deconectat. Se încearcă conectarea în {seconds}…",
"reconnect_now": "Încearcă din nou",
"push_init_failed": "Nu s-a reușit inițializarea tip push notifications",
"invalid_security_token": "Codul de securitate este invalid",
"no_connection": "Nu există conexiune",
"code_doesnot_match": "Codul furnizat nu este valid",
"update_available": "Actualizare disponibilă.",
"reload_update": "Reîncarcă",
"phone_dative": "telefon",
"email_dative": "email",
"enter_confirmation_code_prompt": "Introu codul de confirmare trimis pe {method}:",
"numeric_confirmation_code_prompt": "Doar cifre",
"button_confirm": "Confirmă",
"save_attachment": "salvează",
"deleted_content": "conținut șters",
"invalid_content": "conținut invalid",
"user_not_found": "Utilizatorul nu a fost găsit",
"badge_you": "tu",
"badge_owner": "owner",
"menu_item_info": "Info",
"menu_item_clear_messages": "Șterge mesajele",
"menu_item_clear_messages_for_all": "Ștergele pe toate",
"menu_item_delete": "Șterge",
"menu_item_delete_for_all": "Ștergele pe toate",
"menu_item_send_retry": "Reîncearcă",
"menu_item_mute": "Mute",
"menu_item_unmute": "Unmute",
"menu_item_delete_topic": "Șterge",
"topic_delete_warning": "Ești sigur că dorești să șterge această conversație?",
"menu_item_unblock": "Deblochează",
"menu_item_block": "Blochează",
"topic_block_warning": "Ești sigur că dorești să blochezi această conversație?",
"menu_item_member_delete": "Șterge persoană",
"menu_item_archive_topic": "Arhivează",
"action_cancel": "anulează",
"upload_finishing": "se încarcă...",
"no_contacts": "Nu ai contacte adăugate :-(",
"contacts_not_found_short": "Niciun contact găsit după criteriile ''{query}''",
"title_group_members": "Persoanele din grup",
"title_all_contacts": "Toate Contactele",
"button_ok": "OK",
"button_cancel": "Anulează",
"more_online_members": "+{overflow} mai mult",
"download_action": "download",
"label_file_name": "Nume fișier:",
"label_content_type": "Tip conținut:",
"label_size": "Size:",
"chat_invitation": "Ai primit invitație într-o conversație. Accepți?",
"chat_invitation_accept": "Da, accept",
"chat_invitation_ignore": "Ignoră",
"chat_invitation_block": "Blochează",
"error_invalid_id": "ID Invalid",
"group_user_id_prompt": "Grup sau user ID",
"button_subscribe": "Subscribe",
"topic_name_editing_placeholder": "Numele grupului",
"channel_prompt": "Acesta este un canal",
"button_create": "Creează",
"permission_join": "Participare ({val})",
"permission_read": "Citire ({val})",
"permission_write": "Scriere ({val})",
"permission_pres": "Notificare ({val})",
"permission_admin": "Aprobare ({val})",
"permission_share": "Distribuire ({val})",
"permission_delete": "Ștergere ({val})",
"permission_owner": "Deținător ({val})",
"title_permissions": "Permisiuni",
"message_sending": "se trimite...",
"message_sending_failed": "eroare",
"search_placeholder": "Caută după e-mail sau număr telefon...",
"messaging_disabled_prompt": "Mesageria este dezactivată",
"new_message_prompt": "Mesaj nou",
"image_caption_prompt": "Titlul imaginii",
"file_attachment_too_large": "Dimensiunea fișiterului {size} depășește limita de {limit}.",
"cannot_initiate_file_upload": "Nu se poate face încărcarea.",
"tags_not_found": "Niciun tag definit. Adaugă.",
"tags_editor_no_tags": "Adaugă tag-uri",
"title_manage_tags": "Administrează"
}, },
"ru": { "ru": {
"contacts_not_found": "Чатов нет<br />¯∖_(ツ)_/¯", "contacts_not_found": "Чатов нет<br />¯∖_(ツ)_/¯",
@@ -690,7 +1051,9 @@
"push_init_failed": "Ошибка инициализации пуш уведомлений", "push_init_failed": "Ошибка инициализации пуш уведомлений",
"invalid_security_token": "Токен некорректен", "invalid_security_token": "Токен некорректен",
"no_connection": "Нет связи", "no_connection": "Нет связи",
"code_doesnot_match": "Код не совпадает" "code_doesnot_match": "Код не совпадает",
"channel_prompt": "Создать канал",
"channel": "канал"
}, },
"zh": { "zh": {
"archived_contacts": "已归档联系人 ({count})", "archived_contacts": "已归档联系人 ({count})",
@@ -864,6 +1227,8 @@
"push_init_failed": "初始化推送通知失败", "push_init_failed": "初始化推送通知失败",
"invalid_security_token": "无效的安全令牌", "invalid_security_token": "无效的安全令牌",
"no_connection": "无连接", "no_connection": "无连接",
"code_doesnot_match": "代码不匹配" "code_doesnot_match": "代码不匹配",
"channel_prompt": "这是一个频道",
"channel": "频道"
} }
} }
+1 -1
View File
@@ -1,3 +1,3 @@
// This is a generated file. Don't edit. // This is a generated file. Don't edit.
export const PACKAGE_VERSION = "0.16.6"; export const PACKAGE_VERSION = "0.16.9-rc1";
+1
View File
@@ -157,6 +157,7 @@ export default class AccGeneralView extends React.Component {
title={title_tag_manager} title={title_tag_manager}
activated={false} activated={false}
tags={this.state.tags} tags={this.state.tags}
tinode={this.props.tinode}
onSubmit={this.handleTagsUpdated} /> onSubmit={this.handleTagsUpdated} />
}</FormattedMessage> }</FormattedMessage>
<div className="hr" /> <div className="hr" />
+20 -7
View File
@@ -106,6 +106,7 @@ class InfoView extends React.Component {
muted: false, muted: false,
address: null, address: null,
groupTopic: undefined, groupTopic: undefined,
channel: undefined,
fullName: undefined, fullName: undefined,
avatar: null, avatar: null,
private: null, private: null,
@@ -230,6 +231,7 @@ class InfoView extends React.Component {
private: topic.private ? topic.private.comment : null, private: topic.private ? topic.private.comment : null,
address: topic.name, address: topic.name,
groupTopic: (topic.getType() == 'grp'), groupTopic: (topic.getType() == 'grp'),
channel: topic.isChannel(),
showMemberPanel: false, showMemberPanel: false,
access: acs ? acs.getMode() : undefined, access: acs ? acs.getMode() : undefined,
modeGiven: acs ? acs.getGiven() : undefined, modeGiven: acs ? acs.getGiven() : undefined,
@@ -335,9 +337,17 @@ class InfoView extends React.Component {
case 'want': case 'want':
toEdit = this.state.modeWant; toEdit = this.state.modeWant;
toCompare = this.state.modeGiven; toCompare = this.state.modeGiven;
toSkip = this.state.owner ? 'O' : // Don't allow owner to unset 'O' permission. if (this.state.owner) {
// Don't allow owner to unset 'O' permission. All other permissions are OK.
toSkip = 'O';
} else {
// Allow accepting any of 'ASDO' permissions but don't allow asking for them. // Allow accepting any of 'ASDO' permissions but don't allow asking for them.
Tinode.AccessMode.encode(Tinode.AccessMode.diff('ASDO', this.state.modeGiven)); toSkip = Tinode.AccessMode.encode(Tinode.AccessMode.diff('ASDO', this.state.modeGiven));
if (this.state.channel) {
// Channels are read-only.
toSkip += 'W';
}
}
titleEdit = formatMessage(messages.requested); titleEdit = formatMessage(messages.requested);
titleCompare = formatMessage(messages.granted); titleCompare = formatMessage(messages.granted);
break; break;
@@ -357,11 +367,11 @@ class InfoView extends React.Component {
toSkip = 'O'; toSkip = 'O';
break; break;
case 'user': { case 'user': {
let topic = this.props.tinode.getTopic(this.props.topic); const topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) { if (!topic) {
return; return;
} }
var user = topic.subscriber(uid); const user = topic.subscriber(uid);
if (!user || !user.acs) { if (!user || !user.acs) {
return; return;
} }
@@ -687,6 +697,7 @@ class InfoView extends React.Component {
title={tags} title={tags}
tags={this.state.tags} tags={this.state.tags}
activated={false} activated={false}
tinode={this.props.tinode}
onSubmit={this.handleTagsUpdated} /> onSubmit={this.handleTagsUpdated} />
}</FormattedMessage> }</FormattedMessage>
<div className="hr" /> <div className="hr" />
@@ -695,11 +706,15 @@ class InfoView extends React.Component {
null null
} }
<div className="panel-form-column"> <div className="panel-form-column">
{!this.state.channel ?
<a href="#" className="flat-button" onClick={this.handleDeleteMessages}> <a href="#" className="flat-button" onClick={this.handleDeleteMessages}>
<i className="material-icons">delete_outline</i> &nbsp;{ <i className="material-icons">delete_outline</i> &nbsp;{
formatMessage(this.state.deleter ? messages.delete_messages : messages.clear_messages) formatMessage(this.state.deleter ? messages.delete_messages : messages.clear_messages)
} }
</a> </a>
:
null
}
<a href="#" className="red flat-button" onClick={this.handleLeave}> <a href="#" className="red flat-button" onClick={this.handleLeave}>
<i className="material-icons">exit_to_app</i> &nbsp;{formatMessage(messages.leave_chat)} <i className="material-icons">exit_to_app</i> &nbsp;{formatMessage(messages.leave_chat)}
</a> </a>
@@ -718,7 +733,7 @@ class InfoView extends React.Component {
null null
} }
</div> </div>
{this.state.groupTopic ? {this.state.groupTopic && this.state.sharer ?
<> <>
<div className="hr" /> <div className="hr" />
<div className="panel-form-column"> <div className="panel-form-column">
@@ -729,12 +744,10 @@ class InfoView extends React.Component {
</label> </label>
</div> </div>
<div className="panel-form-row"> <div className="panel-form-row">
{this.state.sharer ?
<a href="#" className="flat-button" onClick={this.handleShowAddMembers}> <a href="#" className="flat-button" onClick={this.handleShowAddMembers}>
<i className="material-icons">person_add</i> &nbsp;<FormattedMessage id="button_add_members" <i className="material-icons">person_add</i> &nbsp;<FormattedMessage id="button_add_members"
defaultMessage="Add members" description="Flat button [Add members] (to topic)" /> defaultMessage="Add members" description="Flat button [Add members] (to topic)" />
</a> </a>
: null}
</div> </div>
<FormattedMessage id="group_has_no_members" defaultMessage="No members" <FormattedMessage id="group_has_no_members" defaultMessage="No members"
description="Shown in place of group members">{ description="Shown in place of group members">{
+171 -62
View File
@@ -15,22 +15,23 @@ import LoadSpinner from '../widgets/load-spinner.jsx';
import LogoView from './logo-view.jsx'; import LogoView from './logo-view.jsx';
import SendMessage from '../widgets/send-message.jsx'; import SendMessage from '../widgets/send-message.jsx';
import { DEFAULT_P2P_ACCESS_MODE, KEYPRESS_DELAY, MESSAGES_PAGE, MAX_EXTERN_ATTACHMENT_SIZE, import { DEFAULT_P2P_ACCESS_MODE, IMAGE_PREVIEW_DIM, KEYPRESS_DELAY, MESSAGES_PAGE,
MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE, READ_DELAY } from '../config.js'; MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE, READ_DELAY } from '../config.js';
import { SUPPORTED_IMAGE_FORMATS, filePasted, fileToBase64, imageFileToBase64, import { SUPPORTED_IMAGE_FORMATS, blobToBase64, filePasted, fileToBase64,
imageFileScaledToBase64, makeImageUrl } from '../lib/blob-helpers.js'; imageScaled, makeImageUrl } from '../lib/blob-helpers.js';
import HashNavigation from '../lib/navigation.js';
import { bytesToHumanSize, shortDateFormat } from '../lib/strformat.js'; import { bytesToHumanSize, shortDateFormat } from '../lib/strformat.js';
// Run timer with this frequency (ms) for checking notification queue. // Run timer with this frequency (ms) for checking notification queue.
const NOTIFICATION_EXEC_INTERVAL = 300; const NOTIFICATION_EXEC_INTERVAL = 300;
const messages = defineMessages({ const messages = defineMessages({
online_now: { 'online_now': {
id: 'online_now', id: 'online_now',
defaultMessage: 'online now', defaultMessage: 'online now',
description: 'Indicator that the user or topic is currently online', description: 'Indicator that the user or topic is currently online',
}, },
last_seen: { 'last_seen': {
id: 'last_seen_timestamp', id: 'last_seen_timestamp',
defaultMessage: 'Last seen', defaultMessage: 'Last seen',
description: 'Label for the timestamp of when the user or topic was last online' description: 'Label for the timestamp of when the user or topic was last online'
@@ -39,6 +40,11 @@ const messages = defineMessages({
id: 'title_not_found', id: 'title_not_found',
defaultMessage: 'Not found', defaultMessage: 'Not found',
description: 'Title shown when topic is not found' description: 'Title shown when topic is not found'
},
'channel': {
id: 'channel',
defaultMessage: 'channel',
description: 'Subtitle shown for channels in MessagesView instead of last seen'
} }
}); });
@@ -85,6 +91,7 @@ class MessagesView extends React.Component {
this.handleEnablePeer = this.handleEnablePeer.bind(this); this.handleEnablePeer = this.handleEnablePeer.bind(this);
this.handleAttachFile = this.handleAttachFile.bind(this); this.handleAttachFile = this.handleAttachFile.bind(this);
this.handleAttachImage = this.handleAttachImage.bind(this); this.handleAttachImage = this.handleAttachImage.bind(this);
this.handleCancelUpload = this.handleCancelUpload.bind(this);
this.postReadNotification = this.postReadNotification.bind(this); this.postReadNotification = this.postReadNotification.bind(this);
this.clearNotificationQueue = this.clearNotificationQueue.bind(this); this.clearNotificationQueue = this.clearNotificationQueue.bind(this);
@@ -152,9 +159,13 @@ class MessagesView extends React.Component {
// Don't request the tags. They are useless unless the user // Don't request the tags. They are useless unless the user
// is the owner and is editing the topic. // is the owner and is editing the topic.
let getQuery = topic.startMetaQuery().withLaterDesc().withLaterSub(); let getQuery = topic.startMetaQuery().withLaterDesc();
if (this.state.isSharer || (newTopic && !topic.isChannel())) {
// Request subscriptions only if one of S,O,A is given or it's a new non-channel topic.
getQuery = getQuery.withLaterSub();
}
if (this.state.isReader || newTopic) { if (this.state.isReader || newTopic) {
// If reading is either permitted or we don't know because it's a new topic. Ask for messages. // Reading is either permitted or we don't know because it's a new topic. Ask for messages.
getQuery = getQuery.withLaterData(MESSAGES_PAGE); getQuery = getQuery.withLaterData(MESSAGES_PAGE);
if (this.state.isReader) { if (this.state.isReader) {
getQuery = getQuery.withLaterDel(); getQuery = getQuery.withLaterDel();
@@ -165,6 +176,11 @@ class MessagesView extends React.Component {
const setQuery = newTopic ? this.props.newTopicParams : undefined; const setQuery = newTopic ? this.props.newTopicParams : undefined;
topic.subscribe(getQuery.build(), setQuery) topic.subscribe(getQuery.build(), setQuery)
.then((ctrl) => { .then((ctrl) => {
if (ctrl.code == 303) {
// Redirect to another topic requested.
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', ctrl.params.topic));
return;
}
if (this.state.topic != ctrl.topic) { if (this.state.topic != ctrl.topic) {
this.setState({topic: ctrl.topic}); this.setState({topic: ctrl.topic});
} }
@@ -202,7 +218,8 @@ class MessagesView extends React.Component {
typingIndicator: false, typingIndicator: false,
scrollPosition: 0, scrollPosition: 0,
fetchingMessages: false, fetchingMessages: false,
peerMessagingDisabled: false peerMessagingDisabled: false,
channel: false
}; };
} else if (nextProps.topic != prevState.topic) { } else if (nextProps.topic != prevState.topic) {
const topic = nextProps.tinode.getTopic(nextProps.topic); const topic = nextProps.tinode.getTopic(nextProps.topic);
@@ -262,6 +279,9 @@ class MessagesView extends React.Component {
peerMessagingDisabled: false peerMessagingDisabled: false
}); });
} }
Object.assign(nextState, {
channel: topic.isChannel()
});
} else { } else {
// Invalid topic. // Invalid topic.
Object.assign(nextState, { Object.assign(nextState, {
@@ -269,7 +289,8 @@ class MessagesView extends React.Component {
onlineSubs: [], onlineSubs: [],
title: '', title: '',
avatar: null, avatar: null,
peerMessagingDisabled: false peerMessagingDisabled: false,
channel: false
}); });
} }
} }
@@ -284,6 +305,9 @@ class MessagesView extends React.Component {
if (!nextProps.acs.isReader('given') != prevState.readingBlocked) { if (!nextProps.acs.isReader('given') != prevState.readingBlocked) {
nextState.readingBlocked = !prevState.readingBlocked; nextState.readingBlocked = !prevState.readingBlocked;
} }
if (nextProps.acs.isSharer() != prevState.isSharer) {
nextState.isSharer = !prevState.isSharer;
}
} else { } else {
if (prevState.isWriter) { if (prevState.isWriter) {
nextState.isWriter = false; nextState.isWriter = false;
@@ -294,6 +318,9 @@ class MessagesView extends React.Component {
if (!prevState.readingBlocked) { if (!prevState.readingBlocked) {
prevState.readingBlocked = true; prevState.readingBlocked = true;
} }
if (prevState.isSharer) {
nextState.isSharer = false;
}
} }
if (isUnconfirmed(nextProps.acs) == !prevState.unconformed) { if (isUnconfirmed(nextProps.acs) == !prevState.unconformed) {
@@ -533,6 +560,9 @@ class MessagesView extends React.Component {
} }
handleClosePreview() { handleClosePreview() {
if (this.state.imagePreview && this.state.imagePreview.url) {
URL.revokeObjectURL(this.state.imagePreview.url);
}
this.setState({ imagePostview: null, imagePreview: null, docPreview: null }); this.setState({ imagePostview: null, imagePreview: null, docPreview: null });
} }
@@ -569,9 +599,11 @@ class MessagesView extends React.Component {
handleShowContextMenuMessage(params, messageSpecificMenuItems) { handleShowContextMenuMessage(params, messageSpecificMenuItems) {
params.topicName = this.state.topic; params.topicName = this.state.topic;
const menuItems = messageSpecificMenuItems || []; const menuItems = messageSpecificMenuItems || [];
menuItems.push('message_delete');
const topic = this.props.tinode.getTopic(params.topicName); const topic = this.props.tinode.getTopic(params.topicName);
if (topic) { if (topic) {
if (!topic.isChannel()) {
menuItems.push('message_delete');
}
const acs = topic.getAccessMode(); const acs = topic.getAccessMode();
if (acs && acs.isDeleter()) { if (acs && acs.isDeleter()) {
menuItems.push('message_delete_hard'); menuItems.push('message_delete_hard');
@@ -596,8 +628,15 @@ class MessagesView extends React.Component {
} }
} }
// Send attachment as Drafty message:
// - if file is too large, upload it and send a s link.
// - if file is small enough, just send it in-band.
sendFileAttachment(file) { sendFileAttachment(file) {
if (file.size > MAX_INBAND_ATTACHMENT_SIZE) { // Server-provided limit reduced for base64 encoding and overhead.
const maxInbandAttachmentSize = (this.props.tinode.getServerLimit('maxMessageSize',
MAX_INBAND_ATTACHMENT_SIZE) * 0.75 - 1024) | 0;
if (file.size > maxInbandAttachmentSize) {
// Too large to send inband - uploading out of band and sending as a link. // Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper(); const uploader = this.props.tinode.getLargeFileHelper();
if (!uploader) { if (!uploader) {
@@ -605,25 +644,33 @@ class MessagesView extends React.Component {
return; return;
} }
const uploadCompletionPromise = uploader.upload(file); const uploadCompletionPromise = uploader.upload(file);
const msg = Drafty.attachFile(null, file.type, null, file.name, file.size, uploadCompletionPromise); const msg = Drafty.attachFile(null, {
mime: file.type,
filename: file.name,
size: file.size,
urlPromise: uploadCompletionPromise
});
// Pass data and the uploader to the TinodeWeb. // Pass data and the uploader to the TinodeWeb.
this.props.sendMessage(msg, uploadCompletionPromise, uploader); this.props.sendMessage(msg, uploadCompletionPromise, uploader);
} else { } else {
// Small enough to send inband. // Small enough to send inband.
fileToBase64(file, fileToBase64(file,
(mime, bits, fname) => { (mime, bits, fname) => {
this.props.sendMessage(Drafty.attachFile(null, mime, bits, fname)); this.props.sendMessage(Drafty.attachFile(null, {mime: mime, data: bits, filename: fname}));
}, },
this.props.onError this.props.onError
); );
} }
} }
// handleAttachFile method is called when [Attach file] button is clicked.
handleAttachFile(file) { handleAttachFile(file) {
if (file.size > MAX_EXTERN_ATTACHMENT_SIZE) { const maxExternAttachmentSize = this.props.tinode.getServerLimit('maxFileUploadSize', MAX_EXTERN_ATTACHMENT_SIZE);
if (file.size > maxExternAttachmentSize) {
// Too large. // Too large.
this.props.onError(this.props.intl.formatMessage(messages.file_attachment_too_large, this.props.onError(this.props.intl.formatMessage({id: 'file_attachment_too_large'},
{size: bytesToHumanSize(file.size), limit: bytesToHumanSize(MAX_EXTERN_ATTACHMENT_SIZE)}), 'err'); {size: bytesToHumanSize(file.size), limit: bytesToHumanSize(maxExternAttachmentSize)}), 'err');
} else { } else {
this.setState({ docPreview: { this.setState({ docPreview: {
file: file, file: file,
@@ -634,48 +681,89 @@ class MessagesView extends React.Component {
} }
} }
sendImageAttachment(caption, mime, bits, width, height, fname) { // sendImageAttachment sends the image bits inband as Drafty message.
let msg = Drafty.insertImage(null, 0, mime, bits, width, height, fname); sendImageAttachment(caption, blob) {
const mime = this.state.imagePreview.type;
const width = this.state.imagePreview.width;
const height = this.state.imagePreview.height;
const fname = this.state.imagePreview.filename;
// Server-provided limit reduced for base64 encoding and overhead.
const maxInbandAttachmentSize = (this.props.tinode.getServerLimit('maxMessageSize',
MAX_INBAND_ATTACHMENT_SIZE) * 0.75 - 1024) | 0;
if (blob.size > maxInbandAttachmentSize) {
// Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper();
if (!uploader) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return;
}
const uploadCompletionPromise = uploader.upload(blob);
// Make small preview to show while uploading.
imageScaled(blob, IMAGE_PREVIEW_DIM, IMAGE_PREVIEW_DIM, -1, false,
(tinyBlob) => {
// Convert tiny image into base64 for serialization and previewing.
blobToBase64(tinyBlob, (blobMime, tinyBits64) => {
let msg = Drafty.insertImage(null, 0, {
mime: mime,
_tempPreview: tinyBits64, // This preview will not be serialized.
width: width,
height: height,
filename: fname,
size: blob.size,
urlPromise: uploadCompletionPromise
});
if (caption) {
msg = Drafty.appendLineBreak(msg);
msg = Drafty.append(msg, Drafty.init(caption));
}
// Pass data and the uploader to the TinodeWeb.
this.props.sendMessage(msg, uploadCompletionPromise, uploader);
}
)},
// Failure
(err) => {
this.props.onError(err, 'err');
}
);
return;
}
// Upload the image if it's too big to be send inband.
blobToBase64(blob, (blobMime, bits64) => {
let msg = Drafty.insertImage(null, 0, {
mime: blobMime,
preview: bits64, // Serializable preview
width: width,
height: height,
filename: fname,
size: blob.size
});
if (caption) { if (caption) {
msg = Drafty.appendLineBreak(msg); msg = Drafty.appendLineBreak(msg);
msg = Drafty.append(msg, Drafty.init(caption)); msg = Drafty.append(msg, Drafty.init(caption));
} }
this.props.sendMessage(msg); this.props.sendMessage(msg);
});
} }
// handleAttachImage method is called when [Attach image] button is clicked.
handleAttachImage(file) { handleAttachImage(file) {
// Check if the uploaded file is indeed an image and if it isn't too large. const maxExternAttachmentSize = this.props.tinode.getServerLimit('maxFileUploadSize', MAX_EXTERN_ATTACHMENT_SIZE);
if (file.size > MAX_INBAND_ATTACHMENT_SIZE || SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) {
// Convert image for size or format. // Get image dimensions and size, optionally scale it down.
imageFileScaledToBase64(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, false, imageScaled(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, maxExternAttachmentSize, false,
// Success // Success
(bits, mime, width, height, fname) => { (blob, mime, width, height, fname) => {
this.setState({imagePreview: { this.setState({imagePreview: {
url: URL.createObjectURL(file), url: URL.createObjectURL(blob),
bits: bits, blob: blob,
filename: fname, filename: fname,
width: width, width: width,
height: height, height: height,
size: bits.length, size: blob.size,
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 type: mime
}}); }});
}, },
@@ -685,6 +773,13 @@ class MessagesView extends React.Component {
} }
); );
} }
handleCancelUpload(seq, uploader) {
const found = this.state.messages.find(msg => msg.seq == seq);
if (found) {
found._cancelled = true;
}
uploader.cancel();
} }
render() { render() {
@@ -726,7 +821,8 @@ class MessagesView extends React.Component {
); );
} else { } else {
const topic = this.props.tinode.getTopic(this.state.topic); const topic = this.props.tinode.getTopic(this.state.topic);
const groupTopic = topic.getType() == 'grp'; const isChannel = topic.isChannel();
const groupTopic = topic.getType() == 'grp' && !isChannel;
let messageNodes = []; let messageNodes = [];
let previousFrom = null; let previousFrom = null;
let chatBoxClass = null; let chatBoxClass = null;
@@ -735,32 +831,33 @@ class MessagesView extends React.Component {
let nextFrom = null; let nextFrom = null;
if (i + 1 < this.state.messages.length) { if (i + 1 < this.state.messages.length) {
nextFrom = this.state.messages[i+1].from nextFrom = this.state.messages[i+1].from || 'chan';
} }
let sequence = 'single'; let sequence = 'single';
if (msg.from == previousFrom) { let thisFrom = msg.from || 'chan';
if (msg.from == nextFrom) { if (thisFrom == previousFrom) {
if (thisFrom == nextFrom) {
sequence = 'middle'; sequence = 'middle';
} else { } else {
sequence = 'last'; sequence = 'last';
} }
} else if (msg.from == nextFrom) { } else if (thisFrom == nextFrom) {
sequence = 'first'; sequence = 'first';
} }
previousFrom = msg.from; previousFrom = thisFrom;
const isReply = !(msg.from == this.props.myUserId); const isReply = !(thisFrom == this.props.myUserId);
const deliveryStatus = topic.msgStatus(msg); const deliveryStatus = topic.msgStatus(msg);
let userName, userAvatar, userFrom; let userName, userAvatar, userFrom;
if (groupTopic) { if (groupTopic) {
const user = topic.userDesc(msg.from); const user = topic.userDesc(thisFrom);
if (user && user.public) { if (user && user.public) {
userName = user.public.fn; userName = user.public.fn;
userAvatar = makeImageUrl(user.public.photo); userAvatar = makeImageUrl(user.public.photo);
} }
userFrom = msg.from; userFrom = thisFrom;
chatBoxClass='chat-box group'; chatBoxClass='chat-box group';
} else { } else {
chatBoxClass='chat-box'; chatBoxClass='chat-box';
@@ -769,23 +866,34 @@ class MessagesView extends React.Component {
messageNodes.push( messageNodes.push(
<ChatMessage <ChatMessage
tinode={this.props.tinode} tinode={this.props.tinode}
content={msg.content} deleted={msg.hi} content={msg.content}
deleted={msg.hi}
mimeType={msg.head ? msg.head.mime : null} mimeType={msg.head ? msg.head.mime : null}
timestamp={msg.ts} response={isReply} seq={msg.seq} timestamp={msg.ts}
userFrom={userFrom} userName={userName} userAvatar={userAvatar} response={isReply}
sequence={sequence} received={deliveryStatus} uploader={msg._uploader} seq={msg.seq}
userFrom={userFrom}
userName={userName}
userAvatar={userAvatar}
sequence={sequence}
received={deliveryStatus}
uploader={msg._uploader}
viewportWidth={this.props.viewportWidth} viewportWidth={this.props.viewportWidth}
showContextMenu={this.handleShowContextMenuMessage} showContextMenu={this.state.channel? false : this.handleShowContextMenuMessage}
onImagePreview={this.handleImagePostview} onImagePreview={this.handleImagePostview}
onFormResponse={this.handleFormResponse} onFormResponse={this.handleFormResponse}
onError={this.props.onError} onError={this.props.onError}
onCancelUpload={this.handleCancelUpload}
key={msg.seq} /> key={msg.seq} />
); );
} }
let lastSeen = null; let lastSeen = null;
if (isChannel) {
lastSeen = formatMessage(messages.channel);
} else {
const cont = this.props.tinode.getMeTopic().getContact(this.state.topic); const cont = this.props.tinode.getMeTopic().getContact(this.state.topic);
if (cont && Tinode.topicType(cont.topic) == 'p2p') { if (cont && Tinode.isP2PTopicName(cont.topic)) {
if (cont.online) { if (cont.online) {
lastSeen = formatMessage(messages.online_now); lastSeen = formatMessage(messages.online_now);
} else if (cont.seen) { } else if (cont.seen) {
@@ -794,6 +902,7 @@ class MessagesView extends React.Component {
// TODO: also handle user agent in c.seen.ua // TODO: also handle user agent in c.seen.ua
} }
} }
}
const avatar = this.state.avatar || true; const avatar = this.state.avatar || true;
const online = this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline'; const online = this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline';
@@ -811,7 +920,7 @@ class MessagesView extends React.Component {
avatar={avatar} avatar={avatar}
topic={this.state.topic} topic={this.state.topic}
title={this.state.title} /> title={this.state.title} />
<span className={online} /> {!isChannel ? <span className={online} /> : null}
</div> </div>
<div id="topic-title-group"> <div id="topic-title-group">
<div id="topic-title" className="panel-title">{ <div id="topic-title" className="panel-title">{
+6 -4
View File
@@ -59,13 +59,13 @@ class NewTopicView extends React.Component {
handleContactSelected(sel) { handleContactSelected(sel) {
if (this.state.tabSelected === 'p2p') { if (this.state.tabSelected === 'p2p') {
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab')); HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
this.props.onCreateTopic(sel, undefined); this.props.onCreateTopic(sel);
} }
} }
handleNewGroupSubmit(name, dataUrl, priv, tags) { handleNewGroupSubmit(name, dataUrl, priv, tags, isChannel) {
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab')); HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
this.props.onCreateTopic(undefined, vcard(name, dataUrl), priv, tags); this.props.onCreateTopic(undefined, vcard(name, dataUrl), priv, tags, isChannel);
} }
handleGroupByID(topicName) { handleGroupByID(topicName) {
@@ -100,7 +100,9 @@ class NewTopicView extends React.Component {
</li> </li>
</ul> </ul>
{this.state.tabSelected === 'grp' ? {this.state.tabSelected === 'grp' ?
<NewTopicGroup onSubmit={this.handleNewGroupSubmit} /> : <NewTopicGroup
tinode={this.props.tinode}
onSubmit={this.handleNewGroupSubmit} /> :
this.state.tabSelected === 'byid' ? this.state.tabSelected === 'byid' ?
<NewTopicById <NewTopicById
onSubmit={this.handleGroupByID} onSubmit={this.handleGroupByID}
+1
View File
@@ -218,6 +218,7 @@ class SidepanelView extends React.Component {
view === 'newtpk' ? view === 'newtpk' ?
<NewTopicView <NewTopicView
tinode={this.props.tinode}
searchResults={this.props.searchResults} searchResults={this.props.searchResults}
onInitFind={this.props.onInitFind} onInitFind={this.props.onInitFind}
onSearchContacts={this.props.onSearchContacts} onSearchContacts={this.props.onSearchContacts}
+22 -15
View File
@@ -108,7 +108,7 @@ class TinodeWeb extends React.Component {
this.togglePushToken = this.togglePushToken.bind(this); this.togglePushToken = this.togglePushToken.bind(this);
this.requestPushToken = this.requestPushToken.bind(this); this.requestPushToken = this.requestPushToken.bind(this);
this.handleSidepanelCancel = this.handleSidepanelCancel.bind(this); this.handleSidepanelCancel = this.handleSidepanelCancel.bind(this);
this.handleNewTopicRequest = this.handleNewTopicRequest.bind(this); this.handleStartTopicRequest = this.handleStartTopicRequest.bind(this);
this.handleNewTopicCreated = this.handleNewTopicCreated.bind(this); this.handleNewTopicCreated = this.handleNewTopicCreated.bind(this);
this.handleTopicUpdateRequest = this.handleTopicUpdateRequest.bind(this); this.handleTopicUpdateRequest = this.handleTopicUpdateRequest.bind(this);
this.handleChangePermissions = this.handleChangePermissions.bind(this); this.handleChangePermissions = this.handleChangePermissions.bind(this);
@@ -390,7 +390,7 @@ class TinodeWeb extends React.Component {
} else { } else {
this.tinode.connect().catch((err) => { this.tinode.connect().catch((err) => {
// Socket error // Socket error
this.setState({loginDisabled: false, autoLogin: false}); this.setState({loginDisabled: false, autoLogin: false, loadSpinnerVisible: false});
this.handleError(err.message, 'err'); this.handleError(err.message, 'err');
}); });
} }
@@ -634,7 +634,7 @@ class TinodeWeb extends React.Component {
// For chatList topics merge only p2p topics and convert them to the // For chatList topics merge only p2p topics and convert them to the
// same format as foundContacts. // same format as foundContacts.
for (const c of chatList) { for (const c of chatList) {
if (Tinode.topicType(c.topic) == 'p2p') { if (Tinode.isP2PTopicName(c.topic)) {
merged[c.topic] = { merged[c.topic] = {
user: c.topic, user: c.topic,
updated: c.updated, updated: c.updated,
@@ -726,7 +726,7 @@ class TinodeWeb extends React.Component {
} }
// User clicked on a contact in the side panel or deleted a contact. // User clicked on a contact in the side panel or deleted a contact.
handleTopicSelected(topicName, unused_index, online, acs) { handleTopicSelected(topicName) {
// Clear newTopicParams after use. // Clear newTopicParams after use.
if (this.state.newTopicParams && this.state.newTopicParams._topicName != topicName) { if (this.state.newTopicParams && this.state.newTopicParams._topicName != topicName) {
this.setState({ this.setState({
@@ -744,8 +744,8 @@ class TinodeWeb extends React.Component {
// Different contact selected. // Different contact selected.
if (this.state.topicSelected != topicName) { if (this.state.topicSelected != topicName) {
this.setState({ this.setState({
topicSelectedOnline: online, topicSelectedOnline: this.tinode.isTopicOnline(topicName),
topicSelectedAcs: acs topicSelectedAcs: this.tinode.getTopicAccessMode(topicName)
}); });
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', topicName)); HashNavigation.navigateTo(HashNavigation.setUrlTopic('', topicName));
} }
@@ -774,6 +774,9 @@ class TinodeWeb extends React.Component {
// User is sending a message, either plain text or a drafty object, possibly // User is sending a message, either plain text or a drafty object, possibly
// with attachments. // with attachments.
// - msg - Drafty message with content
// - promise - Promise to be resolved when the upload is completed
// - uploader - for tracking progress
handleSendMessage(msg, promise, uploader) { handleSendMessage(msg, promise, uploader) {
const topic = this.tinode.getTopic(this.state.topicSelected); const topic = this.tinode.getTopic(this.state.topicSelected);
@@ -813,7 +816,7 @@ class TinodeWeb extends React.Component {
// Accept given permissions. // Accept given permissions.
const mode = topic.getAccessMode().getGiven(); const mode = topic.getAccessMode().getGiven();
response = topic.setMeta({sub: {mode: mode}}); response = topic.setMeta({sub: {mode: mode}});
if (topic.getType() == 'p2p') { if (topic.isP2P()) {
// For P2P topics change 'given' permission of the peer too. // For P2P topics change 'given' permission of the peer too.
// In p2p topics the other user has the same name as the topic. // In p2p topics the other user has the same name as the topic.
response = response.then((ctrl) => { response = response.then((ctrl) => {
@@ -1060,22 +1063,26 @@ class TinodeWeb extends React.Component {
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, hash)); HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, hash));
} }
// Request to start a new topic. New P2P topic requires peer's name. // Request to start a topic, new or selected from search results, or "by ID".
handleNewTopicRequest(peerName, pub, priv, tags) { handleStartTopicRequest(topicName, pub, priv, tags, isChannel) {
// Check if topic is indeed new. If not, launch it.
if (topicName && this.tinode.isTopicCached(topicName)) {
this.handleTopicSelected(topicName);
return;
}
const topicName = peerName || this.tinode.newGroupTopicName(); const params = {};
const params = { if (Tinode.isP2PTopicName(topicName)) {
_topicName: topicName,
};
if (peerName) {
// Because we are initialing the subscription, set 'want' to all permissions. // Because we are initialing the subscription, set 'want' to all permissions.
params.sub = {mode: DEFAULT_P2P_ACCESS_MODE}; params.sub = {mode: DEFAULT_P2P_ACCESS_MODE};
// Give the other user all permissions too. // Give the other user all permissions too.
params.desc = {defacs: {auth: DEFAULT_P2P_ACCESS_MODE}}; params.desc = {defacs: {auth: DEFAULT_P2P_ACCESS_MODE}};
} else { } else {
topicName = topicName || this.tinode.newGroupTopicName(isChannel);
params.desc = {public: pub, private: {comment: priv}}; params.desc = {public: pub, private: {comment: priv}};
params.tags = tags; params.tags = tags;
} }
params._topicName = topicName;
this.setState({newTopicParams: params}, () => {this.handleTopicSelected(topicName)}); this.setState({newTopicParams: params}, () => {this.handleTopicSelected(topicName)});
} }
@@ -1458,7 +1465,7 @@ class TinodeWeb extends React.Component {
onCredDelete={this.handleCredDelete} onCredDelete={this.handleCredDelete}
onCredConfirm={this.handleCredConfirm} onCredConfirm={this.handleCredConfirm}
onTopicSelected={this.handleTopicSelected} onTopicSelected={this.handleTopicSelected}
onCreateTopic={this.handleNewTopicRequest} onCreateTopic={this.handleStartTopicRequest}
onLogout={this.handleLogout} onLogout={this.handleLogout}
onDeleteAccount={this.handleDeleteAccount} onDeleteAccount={this.handleDeleteAccount}
onShowAlert={this.handleShowAlert} onShowAlert={this.handleShowAlert}
+25 -12
View File
@@ -19,11 +19,21 @@ export default class Attachment extends React.Component {
} }
downloadFile(url, filename, mimetype) { downloadFile(url, filename, mimetype) {
var downloader = this.props.tinode.getLargeFileHelper(); if (!url) {
this.props.onError("Invalid download URL '" + url + "'");
return;
}
const downloader = this.props.tinode.getLargeFileHelper();
this.setState({downloader: downloader}); this.setState({downloader: downloader});
downloader.download(url, filename, mimetype, (loaded) => { downloader.download(url, filename, mimetype,
(loaded) => {
this.setState({progress: loaded / this.props.size}); this.setState({progress: loaded / this.props.size});
}).then(() => { },
(err) => {
this.props.onError(err, 'err');
}
).then(() => {
this.setState({downloader: null, progress: 0}); this.setState({downloader: null, progress: 0});
}).catch((err) => { }).catch((err) => {
if (err) { if (err) {
@@ -34,7 +44,7 @@ export default class Attachment extends React.Component {
} }
handleCancel() { handleCancel() {
if (this.props.uploader) { if (this.props.uploading) {
this.props.onCancelUpload(); this.props.onCancelUpload();
} else if (this.state.downloader) { } else if (this.state.downloader) {
this.state.downloader.cancel(); this.state.downloader.cancel();
@@ -54,7 +64,7 @@ export default class Attachment extends React.Component {
// If the URL is relative use LargeFileHelper to attach authentication // If the URL is relative use LargeFileHelper to attach authentication
// credentials to the request. // credentials to the request.
let url, helperFunc; let url, helperFunc;
if (!this.props.uploader && !this.state.downloader && isUrlRelative(this.props.downloadUrl)) { if (!this.props.uploading && !this.state.downloader && isUrlRelative(this.props.downloadUrl)) {
// Relative URL. Use download helper. // Relative URL. Use download helper.
url = '#'; url = '#';
helperFunc = (e) => { helperFunc = (e) => {
@@ -62,22 +72,25 @@ export default class Attachment extends React.Component {
this.downloadFile(this.props.downloadUrl, this.props.filename, this.props.mimetype); this.downloadFile(this.props.downloadUrl, this.props.filename, this.props.mimetype);
}; };
} else { } else {
url = sanitizeUrl(this.props.downloadUrl) || 'about:blank'; url = sanitizeUrl(this.props.downloadUrl);
helperFunc = null; helperFunc = null;
} }
const downloadWidget = <><i className="material-icons">file_download</i> <FormattedMessage id="save_attachment"
defaultMessage="save" description="Call to save an attachment" /></>;
return ( return (
<div className="attachment"> <div className="attachment">
<div><i className="material-icons big gray">insert_drive_file</i></div> <div><i className="material-icons big gray">insert_drive_file</i></div>
<div className="flex-column"> <div className="flex-column">
<div>{filename} {size}</div> <div>{filename} {size}</div>
{this.props.uploader || this.state.downloader ? {this.props.uploading || this.state.downloader ?
<FileProgress progress={this.props.uploader ? this.props.progress : this.state.progress} <FileProgress progress={this.props.uploading ? this.props.progress : this.state.progress}
onCancel={this.handleCancel} /> onCancel={this.handleCancel} />
: :
<div><a href={url} download={this.props.filename} onClick={helperFunc} > <div>{url ?
<i className="material-icons">file_download</i> <FormattedMessage id="save_attachment" <a href={url} download={this.props.filename} onClick={helperFunc}>{downloadWidget}</a>
defaultMessage="save" description="Call to save an attachment" /> :
</a></div> <span className="light-gray">{downloadWidget}</span>
}</div>
} }
</div> </div>
</div> </div>
+9 -6
View File
@@ -2,8 +2,8 @@ import React from 'react';
import LetterTile from './letter-tile.jsx'; import LetterTile from './letter-tile.jsx';
import { AVATAR_SIZE } from '../config.js'; import { AVATAR_SIZE, MAX_EXTERN_ATTACHMENT_SIZE } from '../config.js';
import { imageFileScaledToBase64, makeImageUrl } from '../lib/blob-helpers.js'; import { imageScaled, blobToBase64, makeImageUrl } from '../lib/blob-helpers.js';
export default class AvatarUpload extends React.Component { export default class AvatarUpload extends React.Component {
constructor(props) { constructor(props) {
@@ -23,12 +23,15 @@ export default class AvatarUpload extends React.Component {
} }
handleFileUpload(e) { handleFileUpload(e) {
imageFileScaledToBase64(e.target.files[0], AVATAR_SIZE, AVATAR_SIZE, true, imageScaled(e.target.files[0], AVATAR_SIZE, AVATAR_SIZE, MAX_EXTERN_ATTACHMENT_SIZE, true,
// Success // Image successfully scaled and converted.
(base64bits, mime) => { (mime, blob) => {
var du = makeImageUrl({data: base64bits, type: mime}); // Convert blob to base64-encoded bits.
blobToBase64(blob, (unused, base64bits) => {
const du = makeImageUrl({data: base64bits, type: mime});
this.setState({dataUrl: du}); this.setState({dataUrl: du});
this.props.onImageChanged(du); this.props.onImageChanged(du);
});
}, },
// Failure // Failure
(err) => { (err) => {
+15 -8
View File
@@ -6,6 +6,7 @@ import { Drafty } from 'tinode-sdk'
import Attachment from './attachment.jsx'; import Attachment from './attachment.jsx';
import LetterTile from './letter-tile.jsx'; import LetterTile from './letter-tile.jsx';
import ReceivedMarker from './received-marker.jsx' import ReceivedMarker from './received-marker.jsx'
import UploadingImage from './uploading-image.jsx'
import { sanitizeImageUrl, sanitizeUrl } from '../lib/utils.js'; import { sanitizeImageUrl, sanitizeUrl } from '../lib/utils.js';
export default class ChatMessage extends React.Component { export default class ChatMessage extends React.Component {
@@ -68,7 +69,7 @@ export default class ChatMessage extends React.Component {
} }
handleCancelUpload() { handleCancelUpload() {
this.props.uploader.cancel(); this.props.onCancelUpload(this.props.seq, this.props.uploader);
} }
render() { render() {
@@ -91,7 +92,7 @@ export default class ChatMessage extends React.Component {
attachments.push(<Attachment attachments.push(<Attachment
tinode={this.props.tinode} tinode={this.props.tinode}
downloadUrl={Drafty.getDownloadUrl(att)} downloadUrl={Drafty.getDownloadUrl(att)}
filename={att.name} uploader={Drafty.isUploading(att)} filename={att.name} uploading={Drafty.isProcessing(att)}
mimetype={att.mime} size={Drafty.getEntitySize(att)} mimetype={att.mime} size={Drafty.getEntitySize(att)}
progress={this.state.progress} progress={this.state.progress}
onCancelUpload={this.handleCancelUpload} onCancelUpload={this.handleCancelUpload}
@@ -136,7 +137,7 @@ export default class ChatMessage extends React.Component {
received={this.props.received} /> received={this.props.received} />
: null} : null}
</div> </div>
{this.props.deleted ? {this.props.deleted || !this.props.showContextMenu ?
null : null :
<span className="menuTrigger"> <span className="menuTrigger">
<a href="#" onClick={this.handleContextClick}> <a href="#" onClick={this.handleContextClick}>
@@ -167,24 +168,30 @@ import { fitImageSize } from '../lib/blob-helpers.js';
function draftyFormatter(style, data, values, key) { function draftyFormatter(style, data, values, key) {
let el = Drafty.tagName(style); let el = Drafty.tagName(style);
if (el) { if (el) {
let attr = Drafty.attrValue(style, data) || {}; const attr = Drafty.attrValue(style, data) || {};
attr.key = key; attr.key = key;
switch (style) { switch (style) {
case 'IM': case 'IM':
// Additional processing for images // Additional processing for images
if (data) { if (data) {
attr.className = 'inline-image'; attr.className = 'inline-image';
let dim = fitImageSize(data.width, data.height, const dim = fitImageSize(data.width, data.height,
Math.min(this.props.viewportWidth - REM_SIZE * 4, REM_SIZE * 36), REM_SIZE * 24, false); Math.min(this.props.viewportWidth - REM_SIZE * 4, REM_SIZE * 36), REM_SIZE * 24, false) ||
dim = dim || {dstWidth: BROKEN_IMAGE_SIZE, dstHeight: BROKEN_IMAGE_SIZE}; {dstWidth: BROKEN_IMAGE_SIZE, dstHeight: BROKEN_IMAGE_SIZE};
attr.style = { width: dim.dstWidth + 'px', height: dim.dstHeight + 'px' }; attr.style = { width: dim.dstWidth + 'px', height: dim.dstHeight + 'px' };
attr.src = sanitizeImageUrl(attr.src); if (!Drafty.isProcessing(data)) {
attr.src = this.props.tinode.authorizeURL(sanitizeImageUrl(attr.src));
attr.alt = data.name;
if (attr.src) { if (attr.src) {
attr.onClick = this.handleImagePreview; attr.onClick = this.handleImagePreview;
attr.className += ' image-clickable'; attr.className += ' image-clickable';
} else { } else {
attr.src = 'img/broken_image.png'; attr.src = 'img/broken_image.png';
} }
} else {
// Use custom element instead of <img>.
el = UploadingImage;
}
} }
break; break;
case 'BN': case 'BN':
+5 -1
View File
@@ -3,6 +3,8 @@
import React from 'react'; import React from 'react';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import Tinode from 'tinode-sdk';
import Contact from './contact.jsx'; import Contact from './contact.jsx';
import ContactAction from './contact-action.jsx'; import ContactAction from './contact-action.jsx';
@@ -55,6 +57,7 @@ class ContactList extends React.Component {
} }
} }
const isChannel = Tinode.isChannelTopicName(key);
const selected = showCheckmark ? const selected = showCheckmark ?
(this.props.topicSelected.indexOf(key) > -1) : (this.props.topicSelected.indexOf(key) > -1) :
(this.props.topicSelected === key); (this.props.topicSelected === key);
@@ -82,7 +85,8 @@ class ContactList extends React.Component {
badges={badges} badges={badges}
showCheckmark={showCheckmark} showCheckmark={showCheckmark}
selected={selected} selected={selected}
showOnline={this.props.showOnline} showOnline={this.props.showOnline && !isChannel}
isChannel={isChannel}
onSelected={this.props.onTopicSelected} onSelected={this.props.onTopicSelected}
showContextMenu={this.props.showContextMenu} showContextMenu={this.props.showContextMenu}
item={key} item={key}
+3 -1
View File
@@ -18,7 +18,8 @@ export default class Contact extends React.Component {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (this.props.onSelected) { if (this.props.onSelected) {
this.props.onSelected(this.props.item, this.props.index, this.props.now, this.props.acs); // Index this.props.index is used by GroupManager.
this.props.onSelected(this.props.item, this.props.index);
} }
} }
@@ -67,6 +68,7 @@ export default class Contact extends React.Component {
</div> </div>
<div className="text-box"> <div className="text-box">
<div><span className="contact-title">{title}</span> <div><span className="contact-title">{title}</span>
{this.props.isChannel ? <img src="/img/channel.png" className="channel" alt="channel" /> : null}
<UnreadBadge count={this.props.unread} /><ContactBadges badges={icon_badges} /> <UnreadBadge count={this.props.unread} /><ContactBadges badges={icon_badges} />
</div> </div>
{this.props.comment ? <div className="contact-comment">{this.props.comment}</div> : null} {this.props.comment ? <div className="contact-comment">{this.props.comment}</div> : null}
+3 -4
View File
@@ -30,8 +30,7 @@ export default class ImagePreview extends React.PureComponent {
handleSendImage(caption) { handleSendImage(caption) {
this.props.onClose(); this.props.onClose();
this.props.onSendMessage(caption, this.props.content.type, this.props.content.bits, this.props.onSendMessage(caption, this.props.content.blob);
this.props.content.width, this.props.content.height, this.props.content.filename);
} }
render() { render() {
@@ -47,7 +46,7 @@ export default class ImagePreview extends React.PureComponent {
size.maxHeight = '100%'; size.maxHeight = '100%';
let filename = this.props.content.filename; let filename = this.props.content.filename;
// Averate font aspect ratio is ~0.5; File name takes 1/3 of the viewport width. // Average 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); const maxlength = Math.max(((this.state.width / REM_SIZE / 1.5) | 0) - 2, 12);
if (filename.length > maxlength) { 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);
@@ -68,7 +67,7 @@ export default class ImagePreview extends React.PureComponent {
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a> <a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a>
</div> </div>
<div id="image-preview-container" ref={(node) => this.assignWidth(node)}> <div id="image-preview-container" ref={(node) => this.assignWidth(node)}>
<img src={this.props.content.url} style={size} /> <img src={this.props.content.url} style={size} className="image-preview" alt={this.props.content.filename} />
</div> </div>
{this.props.onSendMessage ? {this.props.onSendMessage ?
<SendMessage <SendMessage
+1 -1
View File
@@ -52,7 +52,7 @@ class NewTopicById extends React.PureComponent {
description="Prompt for entering user or group ID">{ description="Prompt for entering user or group ID">{
(prompt) => <input type="text" placeholder={prompt} (prompt) => <input type="text" placeholder={prompt}
value={this.state.groupId} onChange={this.handleChange} value={this.state.groupId} onChange={this.handleChange}
onKeyPress={this.handleKeyPress} required /> onKeyPress={this.handleKeyPress} required autoFocus />
}</FormattedMessage> }</FormattedMessage>
</div> </div>
<div className="dialog-buttons"> <div className="dialog-buttons">
+16 -3
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import AvatarUpload from './avatar-upload.jsx'; import AvatarUpload from './avatar-upload.jsx';
import CheckBox from './checkbox.jsx';
import TagManager from './tag-manager.jsx'; import TagManager from './tag-manager.jsx';
import { MAX_TITLE_LENGTH } from '../config.js'; import { MAX_TITLE_LENGTH } from '../config.js';
@@ -14,14 +15,15 @@ export default class NewTopicGroup extends React.PureComponent {
fn: '', // full/formatted name fn: '', // full/formatted name
private: '', private: '',
imageDataUrl: null, imageDataUrl: null,
tags: [] tags: [],
isChannel: false
}; };
this.handleFnChange = this.handleFnChange.bind(this); this.handleFnChange = this.handleFnChange.bind(this);
this.handlePrivateChange = this.handlePrivateChange.bind(this); this.handlePrivateChange = this.handlePrivateChange.bind(this);
this.handleImageChanged = this.handleImageChanged.bind(this); this.handleImageChanged = this.handleImageChanged.bind(this);
this.handleTagsChanged = this.handleTagsChanged.bind(this); this.handleTagsChanged = this.handleTagsChanged.bind(this);
this.handleTagsChanged = this.handleTagsChanged.bind(this); this.handleChannelToggle = this.handleChannelToggle.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
} }
@@ -41,13 +43,17 @@ export default class NewTopicGroup extends React.PureComponent {
this.setState({tags: tags}); this.setState({tags: tags});
} }
handleChannelToggle() {
this.setState({isChannel: !this.state.isChannel});
}
handleSubmit(e) { handleSubmit(e) {
e.preventDefault(); e.preventDefault();
const fn = this.state.fn.trim().substring(0, MAX_TITLE_LENGTH); const fn = this.state.fn.trim().substring(0, MAX_TITLE_LENGTH);
const comment = this.state.private.trim().substring(0, MAX_TITLE_LENGTH); const comment = this.state.private.trim().substring(0, MAX_TITLE_LENGTH);
if (fn) { if (fn) {
this.props.onSubmit(fn, this.state.imageDataUrl, comment, this.state.tags); this.props.onSubmit(fn, this.state.imageDataUrl, comment, this.state.tags, this.state.isChannel);
} }
} }
@@ -81,11 +87,18 @@ export default class NewTopicGroup extends React.PureComponent {
onError={this.props.onError} onError={this.props.onError}
onImageChanged={this.handleImageChanged} /> onImageChanged={this.handleImageChanged} />
</div> </div>
<div className="panel-form-row">
<CheckBox checked={this.state.isChannel} onChange={this.handleChannelToggle}/>&nbsp;
<label onClick={this.handleChannelToggle}><FormattedMessage id="channel_prompt"
defaultMessage="This is a channel"
description="Checkbox label when creating a channel" /></label>
</div>
<FormattedMessage id="title_tag_manager">{ <FormattedMessage id="title_tag_manager">{
(title) => <TagManager (title) => <TagManager
tags={this.state.tags} tags={this.state.tags}
activated={true} activated={true}
onTagsChanged={this.handleTagsChanged} onTagsChanged={this.handleTagsChanged}
tinode={this.props.tinode}
title={title} /> title={title} />
}</FormattedMessage> }</FormattedMessage>
<div className="dialog-buttons"> <div className="dialog-buttons">
+8 -3
View File
@@ -4,7 +4,7 @@ import { FormattedMessage } from 'react-intl';
import ChipInput from './chip-input.jsx'; import ChipInput from './chip-input.jsx';
import { MAX_TAG_COUNT, MIN_TAG_LENGTH } from '../config.js'; import { MAX_TAG_COUNT, MAX_TAG_LENGTH, MIN_TAG_LENGTH } from '../config.js';
import { arrayEqual } from '../lib/utils.js'; import { arrayEqual } from '../lib/utils.js';
export default class TagManager extends React.Component { export default class TagManager extends React.Component {
@@ -48,7 +48,9 @@ export default class TagManager extends React.Component {
} }
handleAddTag(tag) { handleAddTag(tag) {
if (tag.length > 0 && this.state.tags.length < MAX_TAG_COUNT) { const maxTagCount = this.props.tinode.getServerLimit('maxTagCount', MAX_TAG_COUNT);
if (tag.length > 0 && this.state.tags.length < maxTagCount) {
const tags = this.state.tags.slice(0); const tags = this.state.tags.slice(0);
tags.push(tag); tags.push(tag);
@@ -84,10 +86,13 @@ export default class TagManager extends React.Component {
} }
render() { render() {
const minTagLength = this.props.tinode.getServerLimit('minTagLength', MIN_TAG_LENGTH);
const maxTagLength = this.props.tinode.getServerLimit('maxTagLength', MAX_TAG_LENGTH);
let tags = []; let tags = [];
if (this.state.activated) { if (this.state.activated) {
this.state.tags.map((tag) => { this.state.tags.map((tag) => {
tags.push({user: tag, invalid: (tag.length < MIN_TAG_LENGTH)}); tags.push({user: tag, invalid: (tag.length < minTagLength || tag.length > maxTagLength)});
}); });
} else { } else {
this.state.tags.map((tag) => { this.state.tags.map((tag) => {
+22
View File
@@ -0,0 +1,22 @@
// Image view with progress bar and a cancel button.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import FileProgress from './file-progress.jsx';
export default class UploadingImage extends React.PureComponent {
constructor(props) {
super(props);
}
render() {
return (
<div className="inline-image">
{React.createElement('img', this.props)}
<div className="rounded-container">
<FileProgress progress={this.props.progress} onCancel={this.props.onCancelUpload} />
</div>
</div>
);
}
};
+417 -214
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
+394 -136
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
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB