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
[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).
@@ -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:
* English
* Chinese (simplified)
* German
* Korean
* Romanian
* Russian
* Spanish
+63 -7
View File
@@ -541,6 +541,15 @@ div.alert .content {
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 */
.in-place-edit {
display: inline-block;
@@ -819,12 +828,13 @@ i.search {
min-height: 3.5rem;
flex-grow: 0;
flex-shrink: 0;
border-bottom: 1px solid #eee;
border-collapse: collapse;
/* partial bottom border */
background: linear-gradient(to right, transparent 3.5rem, #eee 1px, #eee 100%) 0 100% / 100% 1px no-repeat, #fff;
overflow-x: hidden;
}
.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 {
background-color: #eceff1; /* blue-gray 50 highlight */
@@ -892,6 +902,13 @@ i.search {
cursor: pointer;
}
.contact-box img.channel {
width: 1rem;
height: 1rem;
vertical-align: middle;
margin-left: 0.3rem;
}
/* Online-offline indicator positioned inside avatar-box */
.avatar-box .online, .offline {
display: inline-block;
@@ -1520,15 +1537,54 @@ i.search {
color:#777;
}
/* Inline image styles */
.inline-image {
max-width: 36rem;
max-height: 24rem;
/* Inline and preview image styles */
.inline-image, .image-preview {
position: relative;
overflow: hidden;
border-radius: 0.1rem;
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 {
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-dom@16/umd/react-dom.development.js"></script>
<!-- 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 -->
<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.14.6/firebase-messaging.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.20.0/firebase-messaging.js"></script>
</head>
<body>
+3 -3
View File
@@ -24,9 +24,9 @@
<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-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/firebase@7.14.6/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/react-intl@4.7.6/dist/react-intl.min.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.20.0/firebase-messaging.js"></script>
</head>
<body>
<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",
"description": "Tinode messenger for the web",
"version": "0.16.6",
"version": "0.16.9-rc1",
"repository": {
"type": "git",
"url": "https://github.com/tinode/example-react-js.git"
@@ -68,25 +68,25 @@
"author": "Tinode Authors <info@tinode.co>",
"license": "Apache-2.0",
"dependencies": {
"firebase": "^7.14.6",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-intl": "^4.6.9",
"tinode-sdk": "^0.16.6"
"firebase": "^7.24.0",
"react": "^16.14.0",
"react-dom": "^16.14.0",
"react-intl": "^4.7.6",
"tinode-sdk": "^0.16.9-rc2"
},
"devDependencies": {
"@babel/core": "^7.10.2",
"@babel/preset-env": "^7.10.2",
"@babel/preset-react": "^7.10.1",
"babel-loader": "^8.1.0",
"babel-plugin-react-intl": "^7.5.20",
"browserslist": "^4.12.0",
"copy-webpack-plugin": "^6.0.2",
"@babel/core": "^7.12.10",
"@babel/preset-env": "^7.12.10",
"@babel/preset-react": "^7.12.10",
"babel-loader": "^8.2.2",
"babel-plugin-react-intl": "^7.9.4",
"browserslist": "^4.16.0",
"copy-webpack-plugin": "^6.4.0",
"cssnano": "^4.1.10",
"postcss": "^7.0.32",
"postcss-cli": "^7.1.1",
"terser-webpack-plugin": "^3.0.3",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11"
"postcss": "^7.0.35",
"postcss-cli": "^7.1.2",
"terser-webpack-plugin": "^3.1.0",
"webpack": "^4.44.2",
"webpack-cli": "^3.3.12"
}
}
+12 -3
View File
@@ -1,12 +1,16 @@
// 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.14.6/firebase-messaging.js');
importScripts('https://cdn.jsdelivr.net/npm/firebase@7.24.0/firebase-app.js');
importScripts('https://cdn.jsdelivr.net/npm/firebase@7.24.0/firebase-messaging.js');
importScripts('firebase-init.js');
firebase.initializeApp(FIREBASE_INIT);
// Basic internationalization.
const i18n = {
'de': {
'new_message': "Neue Nachricht",
'new_chat': "Neuer Chat",
},
'en': {
'new_message': "New message",
'new_chat': "New chat",
@@ -19,6 +23,10 @@ const i18n = {
'new_message': "새로운 메시지",
'new_chat': "새로운 채팅",
},
'ro': {
'new_message': "Mesaj nou",
'new_chat': "Chat nou",
},
'ru': {
'new_message': "Новое сообщение",
'new_chat': "Новый чат",
@@ -31,7 +39,8 @@ const i18n = {
self.i18nMessage = function(id) {
// Choose translations: given something like 'de-CH', try 'de-CH' then 'de' then 'en'.
const lang = i18n[self.locale] || i18n[self.baseLocale] || i18n['en'];
// Try 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;
}
+17 -10
View File
@@ -23,7 +23,9 @@ export const RECEIVED_DELAY = 500;
export const READ_DELAY = 1000;
// 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.
export const MAX_TAG_COUNT = 16;
@@ -31,6 +33,8 @@ export const MAX_TAG_COUNT = 16;
export const DEFAULT_P2P_ACCESS_MODE = 'JRWPS';
// Access mode for new group topics created by the current user.
export const NEW_GRP_ACCESS_MODE = 'JRWPSAO';
// Access mode for a channel.
export const CHANNEL_ACCESS_MODE = 'JR';
// Access mode for no access.
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.
export const MESSAGES_PAGE = 24;
// 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.
// This is size of an object *before* base64 encoding is applied.
// Default maximum in-band (included directly into the message) attachment size which fits into
// a message of 256K in size. The actual binary size of the attachment should be smaller due to
// base64 encoding expansion and some overhead, for instance 1024 bytes.
// Increase this limit to a greater value in production, if desired. Also increase
// max_message_size in server config.
// MAX_INBAND_ATTACHMENT_SIZE = base64DecodedLen(max_message_size) - 1024;
export const MAX_INBAND_ATTACHMENT_SIZE = 195584;
// max_message_size in the server config.
export const MAX_INBAND_ATTACHMENT_SIZE = 262144;
// Absolute maximum attachment size to be used with the server = 8MB. Increase to
// something like 100MB in production.
export const MAX_EXTERN_ATTACHMENT_SIZE = 1 << 23;
// Maximum allowed linear dimension of an inline image in pixels. You may want
// to adjust it to 1600 or 2400 for production.
export const MAX_IMAGE_DIM = 768;
// Maximum allowed linear dimension of an inline image in pixels. Larer images will be shrunk
// to make the larger dimension fit under this size. You may want to adjust it to 1600 or
// 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
// 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",
"last_seen_timestamp": "Last seen",
"title_not_found": "Not found",
"channel": "channel",
"unnamed_topic": "Unnamed",
"messages_not_readable": "no access to messages",
"peers_messaging_disabled": "Peer's messaging is disabled.",
@@ -150,6 +151,7 @@
"group_user_id_prompt": "Group or User ID",
"button_subscribe": "Subscribe",
"topic_name_editing_placeholder": "Freeform name of the group",
"channel_prompt": "This is a channel",
"button_create": "Create",
"permission_join": "Join ({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.",
"tags_not_found": "No hay etiquetas definidas. Añade unas.",
"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_editor_no_tags": "일부태그추가",
"title_manage_tags": "관리",
"granted_permissions": ":!![MISSING]!!:Granted",
"messages_not_readable": ":!![MISSING]!!:no access to messages",
"deleted_content": ":!![MISSING]!!:content deleted"
"granted_permissions": "승낙하다",
"messages_not_readable": "메시지에 액세스 할 수 없습니다",
"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": "Ошибка инициализации пуш уведомлений",
"invalid_security_token": "Токен некорректен",
"no_connection": "Нет связи",
"code_doesnot_match": "Код не совпадает"
"code_doesnot_match": "Код не совпадает",
"channel_prompt": "Создать канал",
"channel": "канал"
}
+3 -1
View File
@@ -170,5 +170,7 @@
"push_init_failed": "初始化推送通知失败",
"invalid_security_token": "无效的安全令牌",
"no_connection": "无连接",
"code_doesnot_match": "代码不匹配"
"code_doesnot_match": "代码不匹配",
"channel_prompt": "这是一个频道",
"channel": "频道"
}
+52 -64
View File
@@ -1,5 +1,6 @@
// File and image helper functions.
import { MAX_INBAND_ATTACHMENT_SIZE, MAX_IMAGE_DIM } from '../config.js';
import { bytesToHumanSize } from './strformat.js'
// Supported image MIME types and corresponding file extensions.
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
export function fileNameForMime(fname, mime) {
var idx = SUPPORTED_IMAGE_FORMATS.indexOf(mime);
var ext = MIME_EXTENSIONS[idx];
const idx = SUPPORTED_IMAGE_FORMATS.indexOf(mime);
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) {
fname = fname.substring(0, at);
}
return fname + '.' + ext;
}
// Convert uploaded image into a base64-encoded string possibly scaling
// linear dimensions or constraining to a square.
export function imageFileScaledToBase64(file, width, height, forceSquare, onSuccess, onError) {
var img = new Image();
// Scale uploaded image to fit under certain dimensions and byte size, optionally constraining to a square.
// On success calls onSuccess callback with the scaled image as Blob.
export function imageScaled(fileOrBlob, maxWidth, maxHeight, maxSize, forceSquare, onSuccess, onError) {
const img = new Image();
img.crossOrigin = 'Anonymous';
img.onerror = function(err) {
onError("Image format unrecognized");
}
img.onload = function() {
var dim = fitImageSize(this.width, this.height, width, height, forceSquare);
img.onload = async function() {
// 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) {
onError("Invalid image");
return;
}
var canvas = document.createElement('canvas');
let canvas = document.createElement('canvas');
canvas.width = dim.dstWidth;
canvas.height = dim.dstHeight;
var ctx = canvas.getContext('2d');
let ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.drawImage(this, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight,
0, 0, dim.dstWidth, dim.dstHeight);
var mime = (this.width != dim.dstWidth ||
this.height != dim.dstHeight ||
SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) ? 'image/jpeg' : file.type;
var imageBits = canvas.toDataURL(mime);
var parts = imageBits.split(',');
// Get actual image type: 'data:image/png;base64,'
mime = getMimeType(parts[0]);
if (!mime) {
const mime = SUPPORTED_IMAGE_FORMATS.indexOf(fileOrBlob.type) < 0 ? 'image/jpeg' : fileOrBlob.type;
// Generate blob to check size of the image.
let blob = await new Promise(resolve => canvas.toBlob(resolve, mime));
if (!blob) {
onError("Unsupported image format");
return;
}
// Ensure the image is not too large
var quality = 0.78;
if (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE) {
mime = 'image/jpeg';
}
if (mime == 'image/jpeg') {
// Reduce size of the jpeg by reducing image quality
while (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE && quality > 0.45) {
imageBits = canvas.toDataURL(mime, quality);
quality *= 0.84;
}
}
if (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE) {
onError("The image size " + bytesToHumanSize(base64DecodedLen(imageBits.length)) +
" exceeds the " + bytesToHumanSize(MAX_INBAND_ATTACHMENT_SIZE) + " limit.", "err");
return;
// Ensure the image is not too large. Shrink the image keeping the aspect ratio.
// Do nothing if maxsize is <= 0.
while (maxSize > 0 && blob.length > maxSize) {
dim.dstWidth = (dim.dstWidth * 0.70710678118) | 0;
dim.dstHeight = (dim.dstHeight * 0.70710678118) | 0;
canvas.width = dim.dstWidth;
canvas.height = dim.dstHeight;
ctx = canvas.getContext('2d');
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);
blob = await new Promise(resolve => canvas.toBlob(resolve, mime));
}
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
export function imageFileToBase64(file, onSuccess, onError) {
var 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();
// Convert File to base64 string.
export function fileToBase64(file, onSuccess) {
const reader = new FileReader();
reader.addEventListener('load', function() {
onSuccess(file.type, reader.result.split(',')[1], file.name);
});
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.
// FIXME: handle large files out of band.
export function filePasted(event, onImageSuccess, onAttachmentSuccess, onError) {
+9 -9
View File
@@ -5,8 +5,8 @@
export default class HashNavigation {
static parseUrlHash(hash) {
// Split path from args, path -> parts[0], args->path[1]
let parts = hash.split('?', 2);
let params = {};
const parts = hash.split('?', 2);
const params = {};
let path = [];
if (parts[0]) {
path = parts[0].substr(1).split('/');
@@ -27,9 +27,9 @@ export default class HashNavigation {
}
static composeUrlHash(path, params) {
var url = path.join('/');
var args = [];
for (var key in params) {
let url = path.join('/');
const args = [];
for (const key in params) {
if (params.hasOwnProperty(key)) {
args.push(key + '=' + params[key]);
}
@@ -41,25 +41,25 @@ export default class HashNavigation {
}
static addUrlParam(hash, key, value) {
var parsed = this.parseUrlHash(hash);
const parsed = this.parseUrlHash(hash);
parsed.params[key] = value;
return this.composeUrlHash(parsed.path, parsed.params);
}
static removeUrlParam(hash, key) {
var parsed = this.parseUrlHash(hash);
const parsed = this.parseUrlHash(hash);
delete parsed.params[key];
return this.composeUrlHash(parsed.path, parsed.params);
}
static setUrlSidePanel(hash, sidepanel) {
var parsed = this.parseUrlHash(hash);
const parsed = this.parseUrlHash(hash);
parsed.path[0] = sidepanel;
return this.composeUrlHash(parsed.path, parsed.params);
}
static setUrlTopic(hash, topic) {
var parsed = this.parseUrlHash(hash);
const parsed = this.parseUrlHash(hash);
parsed.path[1] = topic;
// Close InfoView on topic change.
delete parsed.params.info;
+7 -3
View File
@@ -88,11 +88,12 @@ export function asEmail(val) {
// http:/example.com - not ok.
// ' ↲ https://example.com' - not ok. (↲ means carriage return)
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
// 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) {
if (!url) {
return null;
@@ -102,9 +103,11 @@ export function sanitizeUrl(url, allowedSchemes) {
url = url.replace(/[^\x20-\x7E]/gmi, '').trim();
// Relative URLs are safe.
// Relative URL does not start with ':', abcd123: or '//'.
if (!/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(url)) {
return url;
}
// Blob URLs are safe.
if (/^blob:http/.test(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
// have http:, https:, or data: scheme. In case of data: scheme, the URL must start with
// a 'data:image/XXXX;base64,'.
// have http:, https:, blob: or data: scheme.
// 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) {
if (!url) {
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": {
"validate_credential_action": "confirm",
"title_tag_manager": "Tags (user discovery)",
@@ -66,6 +242,7 @@
"online_now": "online now",
"last_seen_timestamp": "Last seen",
"title_not_found": "Not found",
"channel": "channel",
"unnamed_topic": "Unnamed",
"messages_not_readable": "no access to messages",
"peers_messaging_disabled": "Peer's messaging is disabled.",
@@ -151,6 +328,7 @@
"group_user_id_prompt": "Group or User ID",
"button_subscribe": "Subscribe",
"topic_name_editing_placeholder": "Freeform name of the group",
"channel_prompt": "This is a channel",
"button_create": "Create",
"permission_join": "Join ({val})",
"permission_read": "Read ({val})",
@@ -345,7 +523,9 @@
"cannot_initiate_file_upload": "No se pudo iniciar la carga del archivo.",
"tags_not_found": "No hay etiquetas definidas. Añade unas.",
"tags_editor_no_tags": "Añadir etiquetas",
"title_manage_tags": "Gestionar"
"title_manage_tags": "Gestionar",
"channel_prompt": "Este es un canal",
"channel": "canal"
},
"ko": {
"validate_credential_action": "확인",
@@ -516,7 +696,188 @@
"cannot_initiate_file_upload": "파일업로드를시작할수없습니다.",
"tags_not_found": "태그가정의되지않았습니다.일부를추가하십시오.",
"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": {
"contacts_not_found": "Чатов нет<br />¯∖_(ツ)_/¯",
@@ -690,7 +1051,9 @@
"push_init_failed": "Ошибка инициализации пуш уведомлений",
"invalid_security_token": "Токен некорректен",
"no_connection": "Нет связи",
"code_doesnot_match": "Код не совпадает"
"code_doesnot_match": "Код не совпадает",
"channel_prompt": "Создать канал",
"channel": "канал"
},
"zh": {
"archived_contacts": "已归档联系人 ({count})",
@@ -864,6 +1227,8 @@
"push_init_failed": "初始化推送通知失败",
"invalid_security_token": "无效的安全令牌",
"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.
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}
activated={false}
tags={this.state.tags}
tinode={this.props.tinode}
onSubmit={this.handleTagsUpdated} />
}</FormattedMessage>
<div className="hr" />
+20 -7
View File
@@ -106,6 +106,7 @@ class InfoView extends React.Component {
muted: false,
address: null,
groupTopic: undefined,
channel: undefined,
fullName: undefined,
avatar: null,
private: null,
@@ -230,6 +231,7 @@ class InfoView extends React.Component {
private: topic.private ? topic.private.comment : null,
address: topic.name,
groupTopic: (topic.getType() == 'grp'),
channel: topic.isChannel(),
showMemberPanel: false,
access: acs ? acs.getMode() : undefined,
modeGiven: acs ? acs.getGiven() : undefined,
@@ -335,9 +337,17 @@ class InfoView extends React.Component {
case 'want':
toEdit = this.state.modeWant;
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.
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);
titleCompare = formatMessage(messages.granted);
break;
@@ -357,11 +367,11 @@ class InfoView extends React.Component {
toSkip = 'O';
break;
case 'user': {
let topic = this.props.tinode.getTopic(this.props.topic);
const topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
var user = topic.subscriber(uid);
const user = topic.subscriber(uid);
if (!user || !user.acs) {
return;
}
@@ -687,6 +697,7 @@ class InfoView extends React.Component {
title={tags}
tags={this.state.tags}
activated={false}
tinode={this.props.tinode}
onSubmit={this.handleTagsUpdated} />
}</FormattedMessage>
<div className="hr" />
@@ -695,11 +706,15 @@ class InfoView extends React.Component {
null
}
<div className="panel-form-column">
{!this.state.channel ?
<a href="#" className="flat-button" onClick={this.handleDeleteMessages}>
<i className="material-icons">delete_outline</i> &nbsp;{
formatMessage(this.state.deleter ? messages.delete_messages : messages.clear_messages)
}
</a>
:
null
}
<a href="#" className="red flat-button" onClick={this.handleLeave}>
<i className="material-icons">exit_to_app</i> &nbsp;{formatMessage(messages.leave_chat)}
</a>
@@ -718,7 +733,7 @@ class InfoView extends React.Component {
null
}
</div>
{this.state.groupTopic ?
{this.state.groupTopic && this.state.sharer ?
<>
<div className="hr" />
<div className="panel-form-column">
@@ -729,12 +744,10 @@ class InfoView extends React.Component {
</label>
</div>
<div className="panel-form-row">
{this.state.sharer ?
<a href="#" className="flat-button" onClick={this.handleShowAddMembers}>
<i className="material-icons">person_add</i> &nbsp;<FormattedMessage id="button_add_members"
defaultMessage="Add members" description="Flat button [Add members] (to topic)" />
</a>
: null}
</div>
<FormattedMessage id="group_has_no_members" defaultMessage="No 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 SendMessage from '../widgets/send-message.jsx';
import { DEFAULT_P2P_ACCESS_MODE, KEYPRESS_DELAY, MESSAGES_PAGE, MAX_EXTERN_ATTACHMENT_SIZE,
MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE, READ_DELAY } from '../config.js';
import { SUPPORTED_IMAGE_FORMATS, filePasted, fileToBase64, imageFileToBase64,
imageFileScaledToBase64, makeImageUrl } from '../lib/blob-helpers.js';
import { DEFAULT_P2P_ACCESS_MODE, IMAGE_PREVIEW_DIM, KEYPRESS_DELAY, MESSAGES_PAGE,
MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE, READ_DELAY } from '../config.js';
import { SUPPORTED_IMAGE_FORMATS, blobToBase64, filePasted, fileToBase64,
imageScaled, makeImageUrl } from '../lib/blob-helpers.js';
import HashNavigation from '../lib/navigation.js';
import { bytesToHumanSize, shortDateFormat } from '../lib/strformat.js';
// Run timer with this frequency (ms) for checking notification queue.
const NOTIFICATION_EXEC_INTERVAL = 300;
const messages = defineMessages({
online_now: {
'online_now': {
id: 'online_now',
defaultMessage: 'online now',
description: 'Indicator that the user or topic is currently online',
},
last_seen: {
'last_seen': {
id: 'last_seen_timestamp',
defaultMessage: 'Last seen',
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',
defaultMessage: '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.handleAttachFile = this.handleAttachFile.bind(this);
this.handleAttachImage = this.handleAttachImage.bind(this);
this.handleCancelUpload = this.handleCancelUpload.bind(this);
this.postReadNotification = this.postReadNotification.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
// 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 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);
if (this.state.isReader) {
getQuery = getQuery.withLaterDel();
@@ -165,6 +176,11 @@ class MessagesView extends React.Component {
const setQuery = newTopic ? this.props.newTopicParams : undefined;
topic.subscribe(getQuery.build(), setQuery)
.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) {
this.setState({topic: ctrl.topic});
}
@@ -202,7 +218,8 @@ class MessagesView extends React.Component {
typingIndicator: false,
scrollPosition: 0,
fetchingMessages: false,
peerMessagingDisabled: false
peerMessagingDisabled: false,
channel: false
};
} else if (nextProps.topic != prevState.topic) {
const topic = nextProps.tinode.getTopic(nextProps.topic);
@@ -262,6 +279,9 @@ class MessagesView extends React.Component {
peerMessagingDisabled: false
});
}
Object.assign(nextState, {
channel: topic.isChannel()
});
} else {
// Invalid topic.
Object.assign(nextState, {
@@ -269,7 +289,8 @@ class MessagesView extends React.Component {
onlineSubs: [],
title: '',
avatar: null,
peerMessagingDisabled: false
peerMessagingDisabled: false,
channel: false
});
}
}
@@ -284,6 +305,9 @@ class MessagesView extends React.Component {
if (!nextProps.acs.isReader('given') != prevState.readingBlocked) {
nextState.readingBlocked = !prevState.readingBlocked;
}
if (nextProps.acs.isSharer() != prevState.isSharer) {
nextState.isSharer = !prevState.isSharer;
}
} else {
if (prevState.isWriter) {
nextState.isWriter = false;
@@ -294,6 +318,9 @@ class MessagesView extends React.Component {
if (!prevState.readingBlocked) {
prevState.readingBlocked = true;
}
if (prevState.isSharer) {
nextState.isSharer = false;
}
}
if (isUnconfirmed(nextProps.acs) == !prevState.unconformed) {
@@ -533,6 +560,9 @@ class MessagesView extends React.Component {
}
handleClosePreview() {
if (this.state.imagePreview && this.state.imagePreview.url) {
URL.revokeObjectURL(this.state.imagePreview.url);
}
this.setState({ imagePostview: null, imagePreview: null, docPreview: null });
}
@@ -569,9 +599,11 @@ class MessagesView extends React.Component {
handleShowContextMenuMessage(params, messageSpecificMenuItems) {
params.topicName = this.state.topic;
const menuItems = messageSpecificMenuItems || [];
menuItems.push('message_delete');
const topic = this.props.tinode.getTopic(params.topicName);
if (topic) {
if (!topic.isChannel()) {
menuItems.push('message_delete');
}
const acs = topic.getAccessMode();
if (acs && acs.isDeleter()) {
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) {
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.
const uploader = this.props.tinode.getLargeFileHelper();
if (!uploader) {
@@ -605,25 +644,33 @@ class MessagesView extends React.Component {
return;
}
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.
this.props.sendMessage(msg, uploadCompletionPromise, uploader);
} else {
// Small enough to send inband.
fileToBase64(file,
(mime, bits, fname) => {
this.props.sendMessage(Drafty.attachFile(null, mime, bits, fname));
this.props.sendMessage(Drafty.attachFile(null, {mime: mime, data: bits, filename: fname}));
},
this.props.onError
);
}
}
// handleAttachFile method is called when [Attach file] button is clicked.
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.
this.props.onError(this.props.intl.formatMessage(messages.file_attachment_too_large,
{size: bytesToHumanSize(file.size), limit: bytesToHumanSize(MAX_EXTERN_ATTACHMENT_SIZE)}), 'err');
this.props.onError(this.props.intl.formatMessage({id: 'file_attachment_too_large'},
{size: bytesToHumanSize(file.size), limit: bytesToHumanSize(maxExternAttachmentSize)}), 'err');
} else {
this.setState({ docPreview: {
file: file,
@@ -634,48 +681,89 @@ class MessagesView extends React.Component {
}
}
sendImageAttachment(caption, mime, bits, width, height, fname) {
let msg = Drafty.insertImage(null, 0, mime, bits, width, height, fname);
// sendImageAttachment sends the image bits inband as Drafty message.
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) {
msg = Drafty.appendLineBreak(msg);
msg = Drafty.append(msg, Drafty.init(caption));
}
this.props.sendMessage(msg);
});
}
// handleAttachImage method is called when [Attach image] button is clicked.
handleAttachImage(file) {
// Check if the uploaded file is indeed an image and if it isn't too large.
if (file.size > MAX_INBAND_ATTACHMENT_SIZE || SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) {
// Convert image for size or format.
imageFileScaledToBase64(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, false,
const maxExternAttachmentSize = this.props.tinode.getServerLimit('maxFileUploadSize', MAX_EXTERN_ATTACHMENT_SIZE);
// Get image dimensions and size, optionally scale it down.
imageScaled(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, maxExternAttachmentSize, false,
// Success
(bits, mime, width, height, fname) => {
(blob, mime, width, height, fname) => {
this.setState({imagePreview: {
url: URL.createObjectURL(file),
bits: bits,
url: URL.createObjectURL(blob),
blob: blob,
filename: fname,
width: width,
height: height,
size: bits.length,
type: mime
}});
},
// Failure
(err) => {
this.props.onError(err, 'err');
});
} else {
// Image can be uploaded as is. No conversion is needed.
imageFileToBase64(file,
// Success
(bits, mime, width, height, fname) => {
this.setState({imagePreview: {
url: URL.createObjectURL(file),
bits: bits,
filename: fname,
width: width,
height: height,
size: bits.length,
size: blob.size,
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() {
@@ -726,7 +821,8 @@ class MessagesView extends React.Component {
);
} else {
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 previousFrom = null;
let chatBoxClass = null;
@@ -735,32 +831,33 @@ class MessagesView extends React.Component {
let nextFrom = null;
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';
if (msg.from == previousFrom) {
if (msg.from == nextFrom) {
let thisFrom = msg.from || 'chan';
if (thisFrom == previousFrom) {
if (thisFrom == nextFrom) {
sequence = 'middle';
} else {
sequence = 'last';
}
} else if (msg.from == nextFrom) {
} else if (thisFrom == nextFrom) {
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);
let userName, userAvatar, userFrom;
if (groupTopic) {
const user = topic.userDesc(msg.from);
const user = topic.userDesc(thisFrom);
if (user && user.public) {
userName = user.public.fn;
userAvatar = makeImageUrl(user.public.photo);
}
userFrom = msg.from;
userFrom = thisFrom;
chatBoxClass='chat-box group';
} else {
chatBoxClass='chat-box';
@@ -769,23 +866,34 @@ class MessagesView extends React.Component {
messageNodes.push(
<ChatMessage
tinode={this.props.tinode}
content={msg.content} deleted={msg.hi}
content={msg.content}
deleted={msg.hi}
mimeType={msg.head ? msg.head.mime : null}
timestamp={msg.ts} response={isReply} seq={msg.seq}
userFrom={userFrom} userName={userName} userAvatar={userAvatar}
sequence={sequence} received={deliveryStatus} uploader={msg._uploader}
timestamp={msg.ts}
response={isReply}
seq={msg.seq}
userFrom={userFrom}
userName={userName}
userAvatar={userAvatar}
sequence={sequence}
received={deliveryStatus}
uploader={msg._uploader}
viewportWidth={this.props.viewportWidth}
showContextMenu={this.handleShowContextMenuMessage}
showContextMenu={this.state.channel? false : this.handleShowContextMenuMessage}
onImagePreview={this.handleImagePostview}
onFormResponse={this.handleFormResponse}
onError={this.props.onError}
onCancelUpload={this.handleCancelUpload}
key={msg.seq} />
);
}
let lastSeen = null;
if (isChannel) {
lastSeen = formatMessage(messages.channel);
} else {
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) {
lastSeen = formatMessage(messages.online_now);
} else if (cont.seen) {
@@ -794,6 +902,7 @@ class MessagesView extends React.Component {
// TODO: also handle user agent in c.seen.ua
}
}
}
const avatar = this.state.avatar || true;
const online = this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline';
@@ -811,7 +920,7 @@ class MessagesView extends React.Component {
avatar={avatar}
topic={this.state.topic}
title={this.state.title} />
<span className={online} />
{!isChannel ? <span className={online} /> : null}
</div>
<div id="topic-title-group">
<div id="topic-title" className="panel-title">{
+6 -4
View File
@@ -59,13 +59,13 @@ class NewTopicView extends React.Component {
handleContactSelected(sel) {
if (this.state.tabSelected === 'p2p') {
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'));
this.props.onCreateTopic(undefined, vcard(name, dataUrl), priv, tags);
this.props.onCreateTopic(undefined, vcard(name, dataUrl), priv, tags, isChannel);
}
handleGroupByID(topicName) {
@@ -100,7 +100,9 @@ class NewTopicView extends React.Component {
</li>
</ul>
{this.state.tabSelected === 'grp' ?
<NewTopicGroup onSubmit={this.handleNewGroupSubmit} /> :
<NewTopicGroup
tinode={this.props.tinode}
onSubmit={this.handleNewGroupSubmit} /> :
this.state.tabSelected === 'byid' ?
<NewTopicById
onSubmit={this.handleGroupByID}
+1
View File
@@ -218,6 +218,7 @@ class SidepanelView extends React.Component {
view === 'newtpk' ?
<NewTopicView
tinode={this.props.tinode}
searchResults={this.props.searchResults}
onInitFind={this.props.onInitFind}
onSearchContacts={this.props.onSearchContacts}
+22 -15
View File
@@ -108,7 +108,7 @@ class TinodeWeb extends React.Component {
this.togglePushToken = this.togglePushToken.bind(this);
this.requestPushToken = this.requestPushToken.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.handleTopicUpdateRequest = this.handleTopicUpdateRequest.bind(this);
this.handleChangePermissions = this.handleChangePermissions.bind(this);
@@ -390,7 +390,7 @@ class TinodeWeb extends React.Component {
} else {
this.tinode.connect().catch((err) => {
// Socket error
this.setState({loginDisabled: false, autoLogin: false});
this.setState({loginDisabled: false, autoLogin: false, loadSpinnerVisible: false});
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
// same format as foundContacts.
for (const c of chatList) {
if (Tinode.topicType(c.topic) == 'p2p') {
if (Tinode.isP2PTopicName(c.topic)) {
merged[c.topic] = {
user: c.topic,
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.
handleTopicSelected(topicName, unused_index, online, acs) {
handleTopicSelected(topicName) {
// Clear newTopicParams after use.
if (this.state.newTopicParams && this.state.newTopicParams._topicName != topicName) {
this.setState({
@@ -744,8 +744,8 @@ class TinodeWeb extends React.Component {
// Different contact selected.
if (this.state.topicSelected != topicName) {
this.setState({
topicSelectedOnline: online,
topicSelectedAcs: acs
topicSelectedOnline: this.tinode.isTopicOnline(topicName),
topicSelectedAcs: this.tinode.getTopicAccessMode(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
// 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) {
const topic = this.tinode.getTopic(this.state.topicSelected);
@@ -813,7 +816,7 @@ class TinodeWeb extends React.Component {
// Accept given permissions.
const mode = topic.getAccessMode().getGiven();
response = topic.setMeta({sub: {mode: mode}});
if (topic.getType() == 'p2p') {
if (topic.isP2P()) {
// For P2P topics change 'given' permission of the peer too.
// In p2p topics the other user has the same name as the topic.
response = response.then((ctrl) => {
@@ -1060,22 +1063,26 @@ class TinodeWeb extends React.Component {
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, hash));
}
// Request to start a new topic. New P2P topic requires peer's name.
handleNewTopicRequest(peerName, pub, priv, tags) {
// Request to start a topic, new or selected from search results, or "by ID".
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 = {
_topicName: topicName,
};
if (peerName) {
const params = {};
if (Tinode.isP2PTopicName(topicName)) {
// Because we are initialing the subscription, set 'want' to all permissions.
params.sub = {mode: DEFAULT_P2P_ACCESS_MODE};
// Give the other user all permissions too.
params.desc = {defacs: {auth: DEFAULT_P2P_ACCESS_MODE}};
} else {
topicName = topicName || this.tinode.newGroupTopicName(isChannel);
params.desc = {public: pub, private: {comment: priv}};
params.tags = tags;
}
params._topicName = topicName;
this.setState({newTopicParams: params}, () => {this.handleTopicSelected(topicName)});
}
@@ -1458,7 +1465,7 @@ class TinodeWeb extends React.Component {
onCredDelete={this.handleCredDelete}
onCredConfirm={this.handleCredConfirm}
onTopicSelected={this.handleTopicSelected}
onCreateTopic={this.handleNewTopicRequest}
onCreateTopic={this.handleStartTopicRequest}
onLogout={this.handleLogout}
onDeleteAccount={this.handleDeleteAccount}
onShowAlert={this.handleShowAlert}
+25 -12
View File
@@ -19,11 +19,21 @@ export default class Attachment extends React.Component {
}
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});
downloader.download(url, filename, mimetype, (loaded) => {
downloader.download(url, filename, mimetype,
(loaded) => {
this.setState({progress: loaded / this.props.size});
}).then(() => {
},
(err) => {
this.props.onError(err, 'err');
}
).then(() => {
this.setState({downloader: null, progress: 0});
}).catch((err) => {
if (err) {
@@ -34,7 +44,7 @@ export default class Attachment extends React.Component {
}
handleCancel() {
if (this.props.uploader) {
if (this.props.uploading) {
this.props.onCancelUpload();
} else if (this.state.downloader) {
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
// credentials to the request.
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.
url = '#';
helperFunc = (e) => {
@@ -62,22 +72,25 @@ export default class Attachment extends React.Component {
this.downloadFile(this.props.downloadUrl, this.props.filename, this.props.mimetype);
};
} else {
url = sanitizeUrl(this.props.downloadUrl) || 'about:blank';
url = sanitizeUrl(this.props.downloadUrl);
helperFunc = null;
}
const downloadWidget = <><i className="material-icons">file_download</i> <FormattedMessage id="save_attachment"
defaultMessage="save" description="Call to save an attachment" /></>;
return (
<div className="attachment">
<div><i className="material-icons big gray">insert_drive_file</i></div>
<div className="flex-column">
<div>{filename} {size}</div>
{this.props.uploader || this.state.downloader ?
<FileProgress progress={this.props.uploader ? this.props.progress : this.state.progress}
{this.props.uploading || this.state.downloader ?
<FileProgress progress={this.props.uploading ? this.props.progress : this.state.progress}
onCancel={this.handleCancel} />
:
<div><a href={url} download={this.props.filename} onClick={helperFunc} >
<i className="material-icons">file_download</i> <FormattedMessage id="save_attachment"
defaultMessage="save" description="Call to save an attachment" />
</a></div>
<div>{url ?
<a href={url} download={this.props.filename} onClick={helperFunc}>{downloadWidget}</a>
:
<span className="light-gray">{downloadWidget}</span>
}</div>
}
</div>
</div>
+9 -6
View File
@@ -2,8 +2,8 @@ import React from 'react';
import LetterTile from './letter-tile.jsx';
import { AVATAR_SIZE } from '../config.js';
import { imageFileScaledToBase64, makeImageUrl } from '../lib/blob-helpers.js';
import { AVATAR_SIZE, MAX_EXTERN_ATTACHMENT_SIZE } from '../config.js';
import { imageScaled, blobToBase64, makeImageUrl } from '../lib/blob-helpers.js';
export default class AvatarUpload extends React.Component {
constructor(props) {
@@ -23,12 +23,15 @@ export default class AvatarUpload extends React.Component {
}
handleFileUpload(e) {
imageFileScaledToBase64(e.target.files[0], AVATAR_SIZE, AVATAR_SIZE, true,
// Success
(base64bits, mime) => {
var du = makeImageUrl({data: base64bits, type: mime});
imageScaled(e.target.files[0], AVATAR_SIZE, AVATAR_SIZE, MAX_EXTERN_ATTACHMENT_SIZE, true,
// Image successfully scaled and converted.
(mime, blob) => {
// Convert blob to base64-encoded bits.
blobToBase64(blob, (unused, base64bits) => {
const du = makeImageUrl({data: base64bits, type: mime});
this.setState({dataUrl: du});
this.props.onImageChanged(du);
});
},
// Failure
(err) => {
+15 -8
View File
@@ -6,6 +6,7 @@ import { Drafty } from 'tinode-sdk'
import Attachment from './attachment.jsx';
import LetterTile from './letter-tile.jsx';
import ReceivedMarker from './received-marker.jsx'
import UploadingImage from './uploading-image.jsx'
import { sanitizeImageUrl, sanitizeUrl } from '../lib/utils.js';
export default class ChatMessage extends React.Component {
@@ -68,7 +69,7 @@ export default class ChatMessage extends React.Component {
}
handleCancelUpload() {
this.props.uploader.cancel();
this.props.onCancelUpload(this.props.seq, this.props.uploader);
}
render() {
@@ -91,7 +92,7 @@ export default class ChatMessage extends React.Component {
attachments.push(<Attachment
tinode={this.props.tinode}
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)}
progress={this.state.progress}
onCancelUpload={this.handleCancelUpload}
@@ -136,7 +137,7 @@ export default class ChatMessage extends React.Component {
received={this.props.received} />
: null}
</div>
{this.props.deleted ?
{this.props.deleted || !this.props.showContextMenu ?
null :
<span className="menuTrigger">
<a href="#" onClick={this.handleContextClick}>
@@ -167,24 +168,30 @@ import { fitImageSize } from '../lib/blob-helpers.js';
function draftyFormatter(style, data, values, key) {
let el = Drafty.tagName(style);
if (el) {
let attr = Drafty.attrValue(style, data) || {};
const attr = Drafty.attrValue(style, data) || {};
attr.key = key;
switch (style) {
case 'IM':
// Additional processing for images
if (data) {
attr.className = 'inline-image';
let dim = fitImageSize(data.width, data.height,
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};
const dim = fitImageSize(data.width, data.height,
Math.min(this.props.viewportWidth - REM_SIZE * 4, REM_SIZE * 36), REM_SIZE * 24, false) ||
{dstWidth: BROKEN_IMAGE_SIZE, dstHeight: BROKEN_IMAGE_SIZE};
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) {
attr.onClick = this.handleImagePreview;
attr.className += ' image-clickable';
} else {
attr.src = 'img/broken_image.png';
}
} else {
// Use custom element instead of <img>.
el = UploadingImage;
}
}
break;
case 'BN':
+5 -1
View File
@@ -3,6 +3,8 @@
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import Tinode from 'tinode-sdk';
import Contact from './contact.jsx';
import ContactAction from './contact-action.jsx';
@@ -55,6 +57,7 @@ class ContactList extends React.Component {
}
}
const isChannel = Tinode.isChannelTopicName(key);
const selected = showCheckmark ?
(this.props.topicSelected.indexOf(key) > -1) :
(this.props.topicSelected === key);
@@ -82,7 +85,8 @@ class ContactList extends React.Component {
badges={badges}
showCheckmark={showCheckmark}
selected={selected}
showOnline={this.props.showOnline}
showOnline={this.props.showOnline && !isChannel}
isChannel={isChannel}
onSelected={this.props.onTopicSelected}
showContextMenu={this.props.showContextMenu}
item={key}
+3 -1
View File
@@ -18,7 +18,8 @@ export default class Contact extends React.Component {
e.preventDefault();
e.stopPropagation();
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 className="text-box">
<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} />
</div>
{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) {
this.props.onClose();
this.props.onSendMessage(caption, this.props.content.type, this.props.content.bits,
this.props.content.width, this.props.content.height, this.props.content.filename);
this.props.onSendMessage(caption, this.props.content.blob);
}
render() {
@@ -47,7 +46,7 @@ export default class ImagePreview extends React.PureComponent {
size.maxHeight = '100%';
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);
if (filename.length > maxlength) {
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>
</div>
<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>
{this.props.onSendMessage ?
<SendMessage
+1 -1
View File
@@ -52,7 +52,7 @@ class NewTopicById extends React.PureComponent {
description="Prompt for entering user or group ID">{
(prompt) => <input type="text" placeholder={prompt}
value={this.state.groupId} onChange={this.handleChange}
onKeyPress={this.handleKeyPress} required />
onKeyPress={this.handleKeyPress} required autoFocus />
}</FormattedMessage>
</div>
<div className="dialog-buttons">
+16 -3
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { FormattedMessage } from 'react-intl';
import AvatarUpload from './avatar-upload.jsx';
import CheckBox from './checkbox.jsx';
import TagManager from './tag-manager.jsx';
import { MAX_TITLE_LENGTH } from '../config.js';
@@ -14,14 +15,15 @@ export default class NewTopicGroup extends React.PureComponent {
fn: '', // full/formatted name
private: '',
imageDataUrl: null,
tags: []
tags: [],
isChannel: false
};
this.handleFnChange = this.handleFnChange.bind(this);
this.handlePrivateChange = this.handlePrivateChange.bind(this);
this.handleImageChanged = this.handleImageChanged.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);
}
@@ -41,13 +43,17 @@ export default class NewTopicGroup extends React.PureComponent {
this.setState({tags: tags});
}
handleChannelToggle() {
this.setState({isChannel: !this.state.isChannel});
}
handleSubmit(e) {
e.preventDefault();
const fn = this.state.fn.trim().substring(0, MAX_TITLE_LENGTH);
const comment = this.state.private.trim().substring(0, MAX_TITLE_LENGTH);
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}
onImageChanged={this.handleImageChanged} />
</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">{
(title) => <TagManager
tags={this.state.tags}
activated={true}
onTagsChanged={this.handleTagsChanged}
tinode={this.props.tinode}
title={title} />
}</FormattedMessage>
<div className="dialog-buttons">
+8 -3
View File
@@ -4,7 +4,7 @@ import { FormattedMessage } from 'react-intl';
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';
export default class TagManager extends React.Component {
@@ -48,7 +48,9 @@ export default class TagManager extends React.Component {
}
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);
tags.push(tag);
@@ -84,10 +86,13 @@ export default class TagManager extends React.Component {
}
render() {
const minTagLength = this.props.tinode.getServerLimit('minTagLength', MIN_TAG_LENGTH);
const maxTagLength = this.props.tinode.getServerLimit('maxTagLength', MAX_TAG_LENGTH);
let tags = [];
if (this.state.activated) {
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 {
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
+395 -137
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