Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6b3b135d5 | ||
|
|
7bbcf2b7b9 | ||
|
|
3984e098b3 | ||
|
|
365e3284c2 | ||
|
|
3e00c3422d |
@@ -46,7 +46,6 @@ More translations are welcome. See [instructions](https://github.com/tinode/chat
|
|||||||
* End-to-End encryption.
|
* End-to-End encryption.
|
||||||
* Emoji support is weak.
|
* Emoji support is weak.
|
||||||
* Mentions, hashtags.
|
* Mentions, hashtags.
|
||||||
* Replying or forwarding messages.
|
|
||||||
* Previews not generated for videos, audio, links or docs.
|
* Previews not generated for videos, audio, links or docs.
|
||||||
|
|
||||||
## Other
|
## Other
|
||||||
@@ -79,5 +78,5 @@ If you want to use the app with your own server and want web push notification t
|
|||||||
### Mobile screenshots
|
### Mobile screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<kbd><img src="web-mob-contacts.png" alt="Mobile web: contacts" width=250 /></kbd> <kbd><img src="web-mob-chat.png" alt="Mobile web: chat" width=250 /></kbd> <kbd><img src="web-mob-info.png" alt="Mobile web: topic info" width=250 /></kbd> <kbd><img src="web-mob-new-chat.png" alt="Mobile web: start new chat" width=250 /></kbd> <kbd><img src="web-mob-acc-settings.png" alt="Mobile web: account settings" width=250 /></kbd>
|
<kbd><img src="web-mob-contacts.png" alt="Mobile web: contacts" width=250 /></kbd> <kbd><img src="web-mob-chat.png" alt="Mobile web: chat" width=250 /></kbd> <kbd><img src="web-mob-info.png" alt="Mobile web: topic info" width=250 /></kbd> <kbd><img src="web-mob-new-chat.png" alt="Mobile web: start new chat" width=250 /></kbd> <kbd><img src="web-mob-acc-settings.png" alt="Mobile web: account settings" width=250 /></kbd> <kbd><img src="web-mob-avatar.png" alt="Mobile web: change avatar" width=250 /></kbd>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -34,8 +34,8 @@
|
|||||||
<!-- React-Intl for translations -->
|
<!-- React-Intl for translations -->
|
||||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@5/react-intl.iife.js"></script>
|
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@5/react-intl.iife.js"></script>
|
||||||
<!-- Firebase for push notifications -->
|
<!-- Firebase for push notifications -->
|
||||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@8/firebase-app.js"></script>
|
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@9/firebase-app.js"></script>
|
||||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@8/firebase-messaging.js"></script>
|
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@9/firebase-messaging.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -25,8 +25,8 @@
|
|||||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react@17.0.2/umd/react.production.min.js"></script>
|
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react@17.0.2/umd/react.production.min.js"></script>
|
||||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@17.0.2/umd/react-dom.production.min.js"></script>
|
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@17.0.2/umd/react-dom.production.min.js"></script>
|
||||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@5.20.7/react-intl.iife.min.js"></script>
|
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@5.20.7/react-intl.iife.min.js"></script>
|
||||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@8.9.1/firebase-app.js"></script>
|
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@9.4.0/firebase-app.js"></script>
|
||||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@8.9.1/firebase-messaging.js"></script>
|
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@9.4.0/firebase-messaging.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
|
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "tinode-webapp",
|
"name": "tinode-webapp",
|
||||||
"description": "Tinode messenger for the web",
|
"description": "Tinode messenger for the web",
|
||||||
"version": "0.17.10",
|
"version": "0.18.0-beta1",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/tinode/example-react-js.git"
|
"url": "https://github.com/tinode/example-react-js.git"
|
||||||
@@ -11,9 +11,11 @@
|
|||||||
"build:css": "postcss css/base.css > css/base.min.css",
|
"build:css": "postcss css/base.css > css/base.min.css",
|
||||||
"build:dev": "webpack --mode development",
|
"build:dev": "webpack --mode development",
|
||||||
"build:prod": "webpack --mode production",
|
"build:prod": "webpack --mode production",
|
||||||
"build:i18n": "node scripts/pack-messages.js",
|
"build:i18n": "node scripts/i18n.js",
|
||||||
|
"i18n:missing": "node scripts/i18n.js missing",
|
||||||
|
"i18n:obsolete": "node scripts/i18n.js obsolete",
|
||||||
"vers": "node scripts/gen-version.js",
|
"vers": "node scripts/gen-version.js",
|
||||||
"extract": "formatjs extract src/**/*.jsx --out-file src/i18n/ex/base-en.json --id-interpolation-pattern '[sha512:contenthash:base64:6]'",
|
"extract": "formatjs extract \"src/**/*.{js,jsx}\" --out-file src/i18n/ex/base-en.json --id-interpolation-pattern '[sha512:contenthash:base64:6]'",
|
||||||
"compile": "formatjs compile"
|
"compile": "formatjs compile"
|
||||||
},
|
},
|
||||||
"browserslist": "> 0.5%, not IE 11, not op_mini all, not and_uc >0",
|
"browserslist": "> 0.5%, not IE 11, not op_mini all, not and_uc >0",
|
||||||
@@ -64,11 +66,11 @@
|
|||||||
"author": "Tinode Authors <info@tinode.co>",
|
"author": "Tinode Authors <info@tinode.co>",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"firebase": "^8.9.1",
|
"firebase": "^9.4.0",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"react-intl": "^5.20.2",
|
"react-intl": "^5.20.2",
|
||||||
"tinode-sdk": "^0.17.10"
|
"tinode-sdk": "^0.18.0-beta1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.14.5",
|
"@babel/core": "^7.14.5",
|
||||||
@@ -83,7 +85,7 @@
|
|||||||
"glob": "^7.1.7",
|
"glob": "^7.1.7",
|
||||||
"mkdirp": "^1.0.4",
|
"mkdirp": "^1.0.4",
|
||||||
"postcss": "^8.3.2",
|
"postcss": "^8.3.2",
|
||||||
"postcss-cli": "^8.3.1",
|
"postcss-cli": "^9.0.2",
|
||||||
"webpack": "^5.38.1",
|
"webpack": "^5.38.1",
|
||||||
"webpack-cli": "^4.7.2"
|
"webpack-cli": "^4.7.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// 1. Updates translations files with new messages, mark no longer needed translations as obsolete.
|
||||||
|
// 2. Takes all translations and groups them into a single file.
|
||||||
|
// 3. When called with parameters 'missing' or 'obsolete' writes missing and obsolete translations to console.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const globSync = require('glob').sync;
|
||||||
|
const mkdirpSync = require('mkdirp').sync;
|
||||||
|
|
||||||
|
const EXTRACTED_STRINGS = './src/i18n/ex/base-en.json';
|
||||||
|
const BASE_LANG = 'en';
|
||||||
|
const LANG_DIR = './src/i18n/';
|
||||||
|
const LANG_PATTERN = './src/i18n/*.json';
|
||||||
|
const LANG_PATTERN_ONE = './src/i18n/%s.json';
|
||||||
|
const FINAL_FILE = './src/messages.json';
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
function getLocaleFromFileName(filename) {
|
||||||
|
const parts = filename.split('/');
|
||||||
|
return parts[parts.length - 1].split('.json')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function printf(str) {
|
||||||
|
const args = [].slice.call(arguments, 1);
|
||||||
|
let i = 0;
|
||||||
|
return str.replace(/%s/g, () => args[i++]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called with one or two parameters?
|
||||||
|
if (args[0] == 'missing' || args[0] == 'obsolete') {
|
||||||
|
// Extract missing or obsolete translations as requested.
|
||||||
|
// Read either all or just one translation.
|
||||||
|
const pattern = args[1] ? printf(LANG_PATTERN_ONE, args[1]) : LANG_PATTERN;
|
||||||
|
const extracted = globSync(pattern)
|
||||||
|
.map((filename) => {
|
||||||
|
const locale = getLocaleFromFileName(filename);
|
||||||
|
const translated = JSON.parse(fs.readFileSync(filename, 'utf8'));
|
||||||
|
|
||||||
|
const dest = {};
|
||||||
|
// Copy messages while skipping missing and obsolete ones.
|
||||||
|
for (let key in translated) {
|
||||||
|
if ((args[0] == 'missing' && translated[key].missing) ||
|
||||||
|
(args[0] == 'obsolete' && translated[key].obsolete)) {
|
||||||
|
dest[key] = translated[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { [locale]: dest };
|
||||||
|
})
|
||||||
|
.reduce((collection, descriptors) => {
|
||||||
|
return { ...collection, ...descriptors };
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
console.log(JSON.stringify(extracted, null, 2)+"\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The formatjs/cli saves extracted messages into a file {'message_id': {defaultMessage: 'default message text', description: '...'}, ...}.
|
||||||
|
// Rewrite them into a single file en.json {'message_id': 'default message text', }.
|
||||||
|
|
||||||
|
// Read master-set of messages in default language.
|
||||||
|
const baseMessages = JSON.parse(fs.readFileSync(EXTRACTED_STRINGS, 'utf8'));
|
||||||
|
|
||||||
|
// Find messages present in baseMessages but missing in translations and vice versa.
|
||||||
|
// Mark missing or obsolete messages as such.
|
||||||
|
globSync(LANG_PATTERN)
|
||||||
|
.map((filename) => {
|
||||||
|
const translated = JSON.parse(fs.readFileSync(filename, 'utf8'));
|
||||||
|
const locale = getLocaleFromFileName(filename);
|
||||||
|
|
||||||
|
// Refresh existing entries with possible new descriptions and default messages, add missing.
|
||||||
|
Object.entries(baseMessages).forEach((ent) => {
|
||||||
|
const [key, value] = ent;
|
||||||
|
let translation = translated[key] ? translated[key].translation : "";
|
||||||
|
if (!translation && locale == BASE_LANG) {
|
||||||
|
translation = value.defaultMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
translated[key] = {
|
||||||
|
translation: translation,
|
||||||
|
defaultMessage: value.defaultMessage,
|
||||||
|
description: value.description,
|
||||||
|
missing: !translation,
|
||||||
|
obsolete: false
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find no longer needed messages.
|
||||||
|
Object.entries(translated).forEach((ent) => {
|
||||||
|
const [key, value] = ent;
|
||||||
|
if (!baseMessages[key] && translated[key]) {
|
||||||
|
// Obsolete, no longer missing.
|
||||||
|
translated[key].missing = !translated[key].translation;
|
||||||
|
translated[key].obsolete = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(filename, JSON.stringify(translated, null, 2)+"\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine all translations into a single json {"lang_1": {...}, "lang_2: {...}"}
|
||||||
|
// while removing messages marked as missing or obsolete.
|
||||||
|
|
||||||
|
// Read all translations.
|
||||||
|
const messages = globSync(LANG_PATTERN)
|
||||||
|
.map((filename) => {
|
||||||
|
const locale = getLocaleFromFileName(filename);
|
||||||
|
const translated = JSON.parse(fs.readFileSync(filename, 'utf8'));
|
||||||
|
|
||||||
|
const dest = {};
|
||||||
|
// Copy messages while skipping missing and obsolete ones.
|
||||||
|
for (let key in translated) {
|
||||||
|
if (!translated[key].missing && !translated[key].obsolete) {
|
||||||
|
dest[key] = translated[key].translation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { [locale]: dest };
|
||||||
|
})
|
||||||
|
.reduce((collection, descriptors) => {
|
||||||
|
return { ...collection, ...descriptors };
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Write a file with all translations combined.
|
||||||
|
mkdirpSync(path.dirname(FINAL_FILE));
|
||||||
|
fs.writeFileSync(FINAL_FILE, JSON.stringify(messages));
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
// 1. Takes file with messages extracted via the React Intl Babel plugin and converts it into a file containing
|
|
||||||
// `id: message` pairs in "en" locale.
|
|
||||||
//
|
|
||||||
// 2. Takes all translations and groups them into a single file.
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const globSync = require('glob').sync;
|
|
||||||
const mkdirpSync = require('mkdirp').sync;
|
|
||||||
|
|
||||||
const EXTRACTED_STRINGS = './src/i18n/ex/base-en.json';
|
|
||||||
const DEFAULT_TRANSLATION = 'en.json';
|
|
||||||
const LANG_DIR = './src/i18n/';
|
|
||||||
const LANG_PATTERN = './src/i18n/*.json';
|
|
||||||
const FINAL_FILE = './src/messages.json';
|
|
||||||
const TRANSLATIONS = './src/translations.js'
|
|
||||||
|
|
||||||
const MISSING_MARKER = ':!![MISSING]!!:';
|
|
||||||
const OBSOLETE_MARKER = ':!![OBSOLETE]!!:';
|
|
||||||
|
|
||||||
// The formatjs/cli saves extracted messages into a file {'message_id': {defaultMessage: 'default message text', description: '...'}, ...}.
|
|
||||||
// Rewrite them into a single file en.json {'message_id': 'default message text', }.
|
|
||||||
|
|
||||||
// Read master-set of messages in default language.
|
|
||||||
const baseMessages = JSON.parse(fs.readFileSync(EXTRACTED_STRINGS, 'utf8'));
|
|
||||||
// Convert.
|
|
||||||
const en = {};
|
|
||||||
for (const [id, msg] of Object.entries(baseMessages)) {
|
|
||||||
en[id] = msg.defaultMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write extracted strings into default language file.
|
|
||||||
let filename = path.join(LANG_DIR, DEFAULT_TRANSLATION);
|
|
||||||
mkdirpSync(path.dirname(filename));
|
|
||||||
fs.writeFileSync(filename, JSON.stringify(en, null, 2)+"\n");
|
|
||||||
|
|
||||||
// Find messages present in en.json but missing in translations and vice versa.
|
|
||||||
// Mark missing or obsolete messages as such in translation files.
|
|
||||||
globSync(LANG_PATTERN)
|
|
||||||
.map((filename) => {
|
|
||||||
const parts = filename.split('/');
|
|
||||||
if (parts[parts.length-1] != DEFAULT_TRANSLATION) {
|
|
||||||
const translated = JSON.parse(fs.readFileSync(filename, 'utf8'));
|
|
||||||
// Find missing messages.
|
|
||||||
Object.entries(baseMessages).forEach((ent) => {
|
|
||||||
if (!translated.hasOwnProperty(ent[0])) {
|
|
||||||
// Missing translation.
|
|
||||||
translated[ent[0]] = MISSING_MARKER + ent[1];
|
|
||||||
} else if (translated[ent[0]].indexOf(OBSOLETE_MARKER) === 0) {
|
|
||||||
// No longer obsolete. Remove obsolete marker.
|
|
||||||
translated[ent[0]] = translated[ent[0]].substring(0, OBSOLETE_MARKER.length);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Find no longer needed messages.
|
|
||||||
Object.entries(translated).forEach((ent) => {
|
|
||||||
if (!baseMessages.hasOwnProperty(ent[0]) && translated.hasOwnProperty(ent[0])) {
|
|
||||||
let val = translated[ent[0]];
|
|
||||||
if (val.indexOf(MISSING_MARKER) === 0) {
|
|
||||||
// No longer missing.
|
|
||||||
val = val.substring(0, MISSING_MARKER.length);
|
|
||||||
}
|
|
||||||
if (val.indexOf(OBSOLETE_MARKER) !== 0) {
|
|
||||||
val = OBSOLETE_MARKER + val;
|
|
||||||
}
|
|
||||||
translated[ent[0]] = val;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
fs.writeFileSync(filename, JSON.stringify(translated, null, 2)+"\n");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// List of all available translations.
|
|
||||||
const languageList = {};
|
|
||||||
// Combine all translations into a single json {"lang_1": {...}, "lang_2: {...}"}
|
|
||||||
// while removing messages marked as missing or obsolete.
|
|
||||||
|
|
||||||
// Read all translations.
|
|
||||||
const messages = globSync(LANG_PATTERN)
|
|
||||||
.map((filename) => {
|
|
||||||
const parts = filename.split('/');
|
|
||||||
const locale = parts[parts.length - 1].split('.json')[0];
|
|
||||||
const translated = JSON.parse(fs.readFileSync(filename, 'utf8'));
|
|
||||||
languageList[locale] = locale;
|
|
||||||
// Strip messages prefixed with MISSING or OBSOLETE markers.
|
|
||||||
for (let id in translated) {
|
|
||||||
// process.stdout.write("-- handling translation " + id + "; "+ String(translated[id]) + "; in " + String(filename) + "; --\n");
|
|
||||||
if (translated[id].indexOf(OBSOLETE_MARKER) == 0 ||
|
|
||||||
translated[id].indexOf(MISSING_MARKER) == 0) {
|
|
||||||
delete translated[id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { [locale]: translated };
|
|
||||||
})
|
|
||||||
.reduce((collection, descriptors) => {
|
|
||||||
return { ...collection, ...descriptors };
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
// Write file with all translations combined.
|
|
||||||
mkdirpSync(path.dirname(FINAL_FILE));
|
|
||||||
fs.writeFileSync(FINAL_FILE, JSON.stringify(messages, null, 2)+"\n");
|
|
||||||
@@ -57,6 +57,7 @@ const fbMessaging = firebase.messaging();
|
|||||||
// This method shows the push notifications while the window is in background.
|
// This method shows the push notifications while the window is in background.
|
||||||
fbMessaging.onBackgroundMessage((payload) => {
|
fbMessaging.onBackgroundMessage((payload) => {
|
||||||
if (payload.data.silent == 'true') {
|
if (payload.data.silent == 'true') {
|
||||||
|
// TODO: if this is an 'msg', fetch the new message in the background.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,8 +69,8 @@ fbMessaging.onBackgroundMessage((payload) => {
|
|||||||
const pushType = payload.data.what || 'msg';
|
const pushType = payload.data.what || 'msg';
|
||||||
const title = payload.data.title || self.i18nMessage(pushType == 'msg' ? 'new_message' : 'new_chat');
|
const title = payload.data.title || self.i18nMessage(pushType == 'msg' ? 'new_message' : 'new_chat');
|
||||||
const options = {
|
const options = {
|
||||||
body: payload.data.content || '',
|
body: payload.data.content || '', // TODO: content for 'sub' should be topic's or user's title.
|
||||||
icon: '/img/logo96.png',
|
icon: '/img/logo96.png', // TODO: use topic's or user's avatar (would have to fetch for 'sub', read from db for 'msg').
|
||||||
badge: '/img/badge96.png',
|
badge: '/img/badge96.png',
|
||||||
tag: payload.data.topic || undefined,
|
tag: payload.data.topic || undefined,
|
||||||
data: {
|
data: {
|
||||||
@@ -135,7 +136,10 @@ self.addEventListener('fetch', event => {
|
|||||||
event.respondWith((async () => {
|
event.respondWith((async () => {
|
||||||
// Try to find the response in the cache.
|
// Try to find the response in the cache.
|
||||||
const cache = await caches.open(PACKAGE_VERSION);
|
const cache = await caches.open(PACKAGE_VERSION);
|
||||||
const cachedResponse = await cache.match(event.request);
|
|
||||||
|
const reqUrl = new URL(event.request.url);
|
||||||
|
// Using ignoreSearch=true to read cached images and docs despite different auth signatures.
|
||||||
|
const cachedResponse = await cache.match(event.request, {ignoreSearch: (self.location.origin == reqUrl.origin)});
|
||||||
if (cachedResponse) {
|
if (cachedResponse) {
|
||||||
return cachedResponse;
|
return cachedResponse;
|
||||||
}
|
}
|
||||||
@@ -144,7 +148,7 @@ self.addEventListener('fetch', event => {
|
|||||||
if (!response || response.status != 200 || response.type != 'basic') {
|
if (!response || response.status != 200 || response.type != 'basic') {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
if (event.request.url && (event.request.url.startsWith('http://') || event.request.url.startsWith('https://'))) {
|
if (reqUrl.protocol == 'http:' || reqUrl.protocol == 'https:') {
|
||||||
await cache.put(event.request, response.clone());
|
await cache.put(event.request, response.clone());
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@@ -45,9 +45,12 @@ export const MEDIA_BREAKPOINT = 640;
|
|||||||
// Size of css 'rem' unit in pixels. Default 1rem = 10pt = 13px.
|
// Size of css 'rem' unit in pixels. Default 1rem = 10pt = 13px.
|
||||||
export const REM_SIZE = 13;
|
export const REM_SIZE = 13;
|
||||||
|
|
||||||
// Size of the avatar image: When an avatar image is uploaded, it's resized to
|
// Size of the avatar image: when image dimensions are greater or the image is not square, it's resized to
|
||||||
// a square of this size.
|
// a square of this size or less.
|
||||||
export const AVATAR_SIZE = 128;
|
export const AVATAR_SIZE = 384;
|
||||||
|
|
||||||
|
// Maximum size of an avatar in bytes for sending in-band. Bigger avatars will be sent out of band (as uploads).
|
||||||
|
export const MAX_AVATAR_BYTES = 4096;
|
||||||
|
|
||||||
// Size of the broken_image shown in MessagesView
|
// Size of the broken_image shown in MessagesView
|
||||||
export const BROKEN_IMAGE_SIZE = 32;
|
export const BROKEN_IMAGE_SIZE = 32;
|
||||||
@@ -75,16 +78,26 @@ export const MAX_IMAGE_DIM = 1024;
|
|||||||
// Linear dimensions of image preview: shrink image under this size for previews.
|
// Linear dimensions of image preview: shrink image under this size for previews.
|
||||||
export const IMAGE_PREVIEW_DIM = 64;
|
export const IMAGE_PREVIEW_DIM = 64;
|
||||||
|
|
||||||
|
// Linear dimensions of image thumbnail: shrink image under this size for thumbnails in reply quote previews.
|
||||||
|
export const IMAGE_THUMBNAIL_DIM = 36;
|
||||||
|
|
||||||
// Maximum number of online users to be shown in a topic title bar. Others will be
|
// Maximum number of online users to be shown in a topic title bar. Others will be
|
||||||
// hidden under "+X more"
|
// hidden under "+X more"
|
||||||
export const MAX_ONLINE_IN_TOPIC = 4;
|
export const MAX_ONLINE_IN_TOPIC = 4;
|
||||||
|
|
||||||
// Maximum length of user name, topic title, and private comment.
|
// Maximum length of user name, topic title, and private comment.
|
||||||
export const MAX_TITLE_LENGTH = 60;
|
export const MAX_TITLE_LENGTH = 60;
|
||||||
|
// Maximum length of topic description.
|
||||||
|
export const MAX_TOPIC_DESCRIPTION_LENGTH = 360;
|
||||||
|
|
||||||
// Length of message previews in chat list, in characters.
|
// Length of message previews in chat list, in characters.
|
||||||
export const MESSAGE_PREVIEW_LENGTH = 80;
|
export const MESSAGE_PREVIEW_LENGTH = 80;
|
||||||
|
|
||||||
|
// Length of a quote in quoted reply (for outgoing messages).
|
||||||
|
export const QUOTED_REPLY_LENGTH = 30;
|
||||||
|
// Length of a preview of a forwarded message.
|
||||||
|
export const FORWARDED_PREVIEW_LENGTH = 96;
|
||||||
|
|
||||||
// Link for "Contact Us".
|
// Link for "Contact Us".
|
||||||
export const LINK_CONTACT_US = 'email:support@tinode.co';
|
export const LINK_CONTACT_US = 'email:support@tinode.co';
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,6 @@
|
|||||||
"defaultMessage": "Leave Conversation",
|
"defaultMessage": "Leave Conversation",
|
||||||
"description": "Flat button [Leave Conversation]"
|
"description": "Flat button [Leave Conversation]"
|
||||||
},
|
},
|
||||||
"action_more": {
|
|
||||||
"defaultMessage": "More",
|
|
||||||
"description": "Action for showing more content"
|
|
||||||
},
|
|
||||||
"action_report_chat": {
|
"action_report_chat": {
|
||||||
"defaultMessage": "Report Conversation",
|
"defaultMessage": "Report Conversation",
|
||||||
"description": "Flat button [Report Group]"
|
"description": "Flat button [Report Group]"
|
||||||
@@ -31,10 +27,22 @@
|
|||||||
"defaultMessage": "Archived contacts ({count})",
|
"defaultMessage": "Archived contacts ({count})",
|
||||||
"description": "Label for archived chats"
|
"description": "Label for archived chats"
|
||||||
},
|
},
|
||||||
|
"badge_danger": {
|
||||||
|
"defaultMessage": "Untrustworthy",
|
||||||
|
"description": "Suspicious or untrustworthy account or topic badge"
|
||||||
|
},
|
||||||
"badge_owner": {
|
"badge_owner": {
|
||||||
"defaultMessage": "owner",
|
"defaultMessage": "owner",
|
||||||
"description": "Badge for indicating the owner"
|
"description": "Badge for indicating the owner"
|
||||||
},
|
},
|
||||||
|
"badge_staff": {
|
||||||
|
"defaultMessage": "Staff-managed",
|
||||||
|
"description": "Explanation of a staff-managed account or topic badge"
|
||||||
|
},
|
||||||
|
"badge_verified": {
|
||||||
|
"defaultMessage": "Verified/official",
|
||||||
|
"description": "Explanation of a verified account or topic badge"
|
||||||
|
},
|
||||||
"badge_you": {
|
"badge_you": {
|
||||||
"defaultMessage": "you",
|
"defaultMessage": "you",
|
||||||
"description": "Badge for indicating the current user"
|
"description": "Badge for indicating the current user"
|
||||||
@@ -87,6 +95,10 @@
|
|||||||
"defaultMessage": "Reset",
|
"defaultMessage": "Reset",
|
||||||
"description": "Button [Reset]"
|
"description": "Button [Reset]"
|
||||||
},
|
},
|
||||||
|
"button_security": {
|
||||||
|
"defaultMessage": "Security",
|
||||||
|
"description": "Navigaton button for security panel."
|
||||||
|
},
|
||||||
"button_send_request": {
|
"button_send_request": {
|
||||||
"defaultMessage": "Send request",
|
"defaultMessage": "Send request",
|
||||||
"description": "Button [Send request]"
|
"description": "Button [Send request]"
|
||||||
@@ -159,7 +171,7 @@
|
|||||||
"defaultMessage": "Delete account",
|
"defaultMessage": "Delete account",
|
||||||
"description": "Title for delete account warning"
|
"description": "Title for delete account warning"
|
||||||
},
|
},
|
||||||
"delete_account_arning": {
|
"delete_account_warning": {
|
||||||
"defaultMessage": "Are you sure you want to delete your account? It cannot be undone.",
|
"defaultMessage": "Are you sure you want to delete your account? It cannot be undone.",
|
||||||
"description": "Warning message when deleting an account"
|
"description": "Warning message when deleting an account"
|
||||||
},
|
},
|
||||||
@@ -171,6 +183,10 @@
|
|||||||
"defaultMessage": "content deleted",
|
"defaultMessage": "content deleted",
|
||||||
"description": "Shown when messages are deleted"
|
"description": "Shown when messages are deleted"
|
||||||
},
|
},
|
||||||
|
"description_editing_placeholder": {
|
||||||
|
"defaultMessage": "Description (optional)",
|
||||||
|
"description": "Placeholder for editing topic description"
|
||||||
|
},
|
||||||
"download_action": {
|
"download_action": {
|
||||||
"defaultMessage": "download",
|
"defaultMessage": "download",
|
||||||
"description": "Call to action [download]"
|
"description": "Call to action [download]"
|
||||||
@@ -215,6 +231,13 @@
|
|||||||
"defaultMessage": "Forgot password?",
|
"defaultMessage": "Forgot password?",
|
||||||
"description": "Link to Reset password form"
|
"description": "Link to Reset password form"
|
||||||
},
|
},
|
||||||
|
"forward_to": {
|
||||||
|
"defaultMessage": "Forward to"
|
||||||
|
},
|
||||||
|
"forward_to_search_placeholder": {
|
||||||
|
"defaultMessage": "Search contacts",
|
||||||
|
"description": "Contact search prompt when forwarding a message."
|
||||||
|
},
|
||||||
"full_name_prompt": {
|
"full_name_prompt": {
|
||||||
"defaultMessage": "Full name, e.g. John Doe",
|
"defaultMessage": "Full name, e.g. John Doe",
|
||||||
"description": "Input placeholder for person's full name"
|
"description": "Input placeholder for person's full name"
|
||||||
@@ -237,7 +260,7 @@
|
|||||||
},
|
},
|
||||||
"invalid_content": {
|
"invalid_content": {
|
||||||
"defaultMessage": "invalid content",
|
"defaultMessage": "invalid content",
|
||||||
"description": "Shown when message is unreadable"
|
"description": "Shown when the message is unreadable"
|
||||||
},
|
},
|
||||||
"invalid_security_token": {
|
"invalid_security_token": {
|
||||||
"defaultMessage": "Invalid security token",
|
"defaultMessage": "Invalid security token",
|
||||||
@@ -251,14 +274,14 @@
|
|||||||
"defaultMessage": "Content type:",
|
"defaultMessage": "Content type:",
|
||||||
"description": "Label for file content type (mime)"
|
"description": "Label for file content type (mime)"
|
||||||
},
|
},
|
||||||
"label_default_access": {
|
|
||||||
"defaultMessage": "Default access mode:",
|
|
||||||
"description": "Section title"
|
|
||||||
},
|
|
||||||
"label_default_access_mode": {
|
"label_default_access_mode": {
|
||||||
"defaultMessage": "Default access mode:",
|
"defaultMessage": "Default access mode:",
|
||||||
"description": "Label for default access mode"
|
"description": "Label for default access mode"
|
||||||
},
|
},
|
||||||
|
"label_description": {
|
||||||
|
"defaultMessage": "Description",
|
||||||
|
"description": "Label for editing topic description"
|
||||||
|
},
|
||||||
"label_file_name": {
|
"label_file_name": {
|
||||||
"defaultMessage": "File name:",
|
"defaultMessage": "File name:",
|
||||||
"description": "Label for a file name"
|
"description": "Label for a file name"
|
||||||
@@ -329,6 +352,10 @@
|
|||||||
"defaultMessage": "Name",
|
"defaultMessage": "Name",
|
||||||
"description": "Label for editing topic name"
|
"description": "Label for editing topic name"
|
||||||
},
|
},
|
||||||
|
"label_unarchive_topic": {
|
||||||
|
"defaultMessage": "Archived:",
|
||||||
|
"description": "Label for unarchiving the topic"
|
||||||
|
},
|
||||||
"label_user_contacts": {
|
"label_user_contacts": {
|
||||||
"defaultMessage": "Contacts:",
|
"defaultMessage": "Contacts:",
|
||||||
"description": "Label for user contacts"
|
"description": "Label for user contacts"
|
||||||
@@ -409,6 +436,10 @@
|
|||||||
"defaultMessage": "Edit permissions",
|
"defaultMessage": "Edit permissions",
|
||||||
"description": "Menu item [Edit permissions]"
|
"description": "Menu item [Edit permissions]"
|
||||||
},
|
},
|
||||||
|
"menu_item_forward": {
|
||||||
|
"defaultMessage": "Forward",
|
||||||
|
"description": "Forward message"
|
||||||
|
},
|
||||||
"menu_item_info": {
|
"menu_item_info": {
|
||||||
"defaultMessage": "Info",
|
"defaultMessage": "Info",
|
||||||
"description": "Show extended topic information"
|
"description": "Show extended topic information"
|
||||||
@@ -421,6 +452,10 @@
|
|||||||
"defaultMessage": "Mute",
|
"defaultMessage": "Mute",
|
||||||
"description": "Turn off notifications"
|
"description": "Turn off notifications"
|
||||||
},
|
},
|
||||||
|
"menu_item_reply": {
|
||||||
|
"defaultMessage": "Reply",
|
||||||
|
"description": "Reply to message"
|
||||||
|
},
|
||||||
"menu_item_restore_topic": {
|
"menu_item_restore_topic": {
|
||||||
"defaultMessage": "Restore",
|
"defaultMessage": "Restore",
|
||||||
"description": "Restore topic from archive"
|
"description": "Restore topic from archive"
|
||||||
@@ -481,6 +516,26 @@
|
|||||||
"defaultMessage": "online now",
|
"defaultMessage": "online now",
|
||||||
"description": "Indicator that the user or topic is currently online"
|
"description": "Indicator that the user or topic is currently online"
|
||||||
},
|
},
|
||||||
|
"panel_title_crop": {
|
||||||
|
"defaultMessage": "Drag to Adjust",
|
||||||
|
"description": "Title for AvatarCropView."
|
||||||
|
},
|
||||||
|
"panel_title_general": {
|
||||||
|
"defaultMessage": "General",
|
||||||
|
"description": "Title for TopicCommon."
|
||||||
|
},
|
||||||
|
"panel_title_info": {
|
||||||
|
"defaultMessage": "Info",
|
||||||
|
"description": "Title for InfoView"
|
||||||
|
},
|
||||||
|
"panel_title_members": {
|
||||||
|
"defaultMessage": "Members",
|
||||||
|
"description": "Title for managing group members view."
|
||||||
|
},
|
||||||
|
"panel_title_security": {
|
||||||
|
"defaultMessage": "Security",
|
||||||
|
"description": "Title for TopicSecirity and AccSecurity."
|
||||||
|
},
|
||||||
"password_prompt": {
|
"password_prompt": {
|
||||||
"defaultMessage": "Password",
|
"defaultMessage": "Password",
|
||||||
"description": "Placeholder/prompt for entering password"
|
"description": "Placeholder/prompt for entering password"
|
||||||
@@ -529,6 +584,18 @@
|
|||||||
"defaultMessage": "Write ({val})",
|
"defaultMessage": "Write ({val})",
|
||||||
"description": "Name of W permission"
|
"description": "Name of W permission"
|
||||||
},
|
},
|
||||||
|
"permissions_anonymous": {
|
||||||
|
"defaultMessage": "Anonymous",
|
||||||
|
"description": "Title for editing default anonymous permissions"
|
||||||
|
},
|
||||||
|
"permissions_authenticated": {
|
||||||
|
"defaultMessage": "Authenticated",
|
||||||
|
"description": "Title for editing default authenticated permissions"
|
||||||
|
},
|
||||||
|
"permissions_user": {
|
||||||
|
"defaultMessage": "User's Permissions",
|
||||||
|
"description": "Title for editing user's permissions"
|
||||||
|
},
|
||||||
"phone_dative": {
|
"phone_dative": {
|
||||||
"defaultMessage": "phone",
|
"defaultMessage": "phone",
|
||||||
"description": "Dative case of 'phone', i.e. 'phone' in 'by phone'"
|
"description": "Dative case of 'phone', i.e. 'phone' in 'by phone'"
|
||||||
@@ -577,25 +644,17 @@
|
|||||||
"defaultMessage": "List like email:alice@example.com, tel:17025550003...",
|
"defaultMessage": "List like email:alice@example.com, tel:17025550003...",
|
||||||
"description": "Placeholder in contacts search field"
|
"description": "Placeholder in contacts search field"
|
||||||
},
|
},
|
||||||
"sidepanel_title_acc_general": {
|
|
||||||
"defaultMessage": "General",
|
|
||||||
"description": "Sidepanel title for AccGeneralView."
|
|
||||||
},
|
|
||||||
"sidepanel_title_acc_notifications": {
|
"sidepanel_title_acc_notifications": {
|
||||||
"defaultMessage": "Notifications",
|
"defaultMessage": "Notifications",
|
||||||
"description": "Sidepanel title for AccNotificationsView."
|
"description": "Sidepanel title for AccNotificationsView."
|
||||||
},
|
},
|
||||||
"sidepanel_title_acc_security": {
|
|
||||||
"defaultMessage": "Security",
|
|
||||||
"description": "Sidepanel title for AccSecurityView."
|
|
||||||
},
|
|
||||||
"sidepanel_title_acc_support": {
|
"sidepanel_title_acc_support": {
|
||||||
"defaultMessage": "Support",
|
"defaultMessage": "Support",
|
||||||
"description": "Sidepanel title for AccSupportView."
|
"description": "Sidepanel title for AccSupportView."
|
||||||
},
|
},
|
||||||
"sidepanel_title_account_settings": {
|
"sidepanel_title_account_settings": {
|
||||||
"defaultMessage": "Account Settings",
|
"defaultMessage": "Account Settings",
|
||||||
"description": "Sidepanel title for EditAccountView."
|
"description": "Sidepanel title for AccountSettingsView."
|
||||||
},
|
},
|
||||||
"sidepanel_title_archive": {
|
"sidepanel_title_archive": {
|
||||||
"defaultMessage": "Archived Chats",
|
"defaultMessage": "Archived Chats",
|
||||||
@@ -661,10 +720,6 @@
|
|||||||
"defaultMessage": "Group Members",
|
"defaultMessage": "Group Members",
|
||||||
"description": "Section title"
|
"description": "Section title"
|
||||||
},
|
},
|
||||||
"title_info": {
|
|
||||||
"defaultMessage": "Info",
|
|
||||||
"description": "Title for InfoView"
|
|
||||||
},
|
|
||||||
"title_manage_tags": {
|
"title_manage_tags": {
|
||||||
"defaultMessage": "Manage",
|
"defaultMessage": "Manage",
|
||||||
"description": "Section title for the list of tags"
|
"description": "Section title for the list of tags"
|
||||||
@@ -678,7 +733,7 @@
|
|||||||
"description": "Section title"
|
"description": "Section title"
|
||||||
},
|
},
|
||||||
"title_tag_manager": {
|
"title_tag_manager": {
|
||||||
"defaultMessage": "Tags (user discovery)",
|
"defaultMessage": "Tags (search & discovery)",
|
||||||
"description": "Section title for TagManager"
|
"description": "Section title for TagManager"
|
||||||
},
|
},
|
||||||
"topic_block_warning": {
|
"topic_block_warning": {
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ const language = (params && params.hl) ||
|
|||||||
const baseLanguage = language.toLowerCase().split(/[-_]/)[0];
|
const baseLanguage = language.toLowerCase().split(/[-_]/)[0];
|
||||||
|
|
||||||
// Try the full locale first, then the locale without the region code, fallback to 'en'.
|
// Try the full locale first, then the locale without the region code, fallback to 'en'.
|
||||||
const messages =
|
const htmlLang = allMessages[language] ? langauge : allMessages[baseLanguage] ? baseLanguage : 'en';
|
||||||
allMessages[language] ||
|
const messages = allMessages[htmlLang];
|
||||||
allMessages[baseLanguage] ||
|
|
||||||
allMessages.en;
|
// Set lang attribute of the HTML element: <html lang="XX">
|
||||||
|
document.getElementsByTagName('html')[0].setAttribute('lang', htmlLang);
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { bytesToHumanSize } from './strformat.js'
|
|||||||
export const SUPPORTED_IMAGE_FORMATS = ['image/jpeg', 'image/gif', 'image/png', 'image/svg', 'image/svg+xml'];
|
export const SUPPORTED_IMAGE_FORMATS = ['image/jpeg', 'image/gif', 'image/png', 'image/svg', 'image/svg+xml'];
|
||||||
export const MIME_EXTENSIONS = ['jpg', 'gif', 'png', 'svg', 'svg'];
|
export const MIME_EXTENSIONS = ['jpg', 'gif', 'png', 'svg', 'svg'];
|
||||||
|
|
||||||
// Make a data: URL from public.photo
|
// Get an URL from a theCard photo: either create a data: URL or return reference URL.
|
||||||
export function makeImageDataUrl(photo) {
|
export function makeImageUrl(photo) {
|
||||||
if (photo) {
|
if (photo && typeof photo == 'object') {
|
||||||
if (photo.data && photo.type) {
|
if (photo.data && photo.type) {
|
||||||
const mime = photo.type.startsWith('image/') ? photo.type : ('image/' + photo.type);
|
const mime = photo.type.startsWith('image/') ? photo.type : ('image/' + photo.type);
|
||||||
return 'data:' + mime + ';base64,' + photo.data;
|
return 'data:' + mime + ';base64,' + photo.data;
|
||||||
@@ -77,99 +77,145 @@ export function fileNameForMime(fname, mime) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Scale uploaded image to fit under certain dimensions and byte size, optionally constraining to a square.
|
// 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.
|
// Returns a promise which is resolven on success or rejected on failure.
|
||||||
export function imageScaled(fileOrBlob, maxWidth, maxHeight, maxSize, forceSquare, onSuccess, onError) {
|
export function imageScaled(fileOrBlob, maxWidth, maxHeight, maxSize, forceSquare) {
|
||||||
const img = new Image();
|
return new Promise((resolve, reject) => {
|
||||||
img.crossOrigin = 'Anonymous';
|
const img = new Image();
|
||||||
img.onerror = function(err) {
|
img.crossOrigin = 'Anonymous';
|
||||||
onError("Image format unrecognized");
|
img.onerror = function(err) {
|
||||||
}
|
reject(new Error("Image format unrecognized"));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
let canvas = document.createElement('canvas');
|
img.onload = async function() {
|
||||||
canvas.width = dim.dstWidth;
|
// Once the image is loaded, the URL is no longer needed.
|
||||||
canvas.height = dim.dstHeight;
|
URL.revokeObjectURL(img.src);
|
||||||
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);
|
|
||||||
|
|
||||||
const mime = SUPPORTED_IMAGE_FORMATS.includes(fileOrBlob.type) ? fileOrBlob.type : 'image/jpeg';
|
// Calculate the desired image dimensions.
|
||||||
// Generate blob to check size of the image.
|
const dim = fitImageSize(img.width, img.height, maxWidth, maxHeight, forceSquare);
|
||||||
let blob = await new Promise(resolve => canvas.toBlob(resolve, mime));
|
if (!dim) {
|
||||||
if (!blob) {
|
reject(new Error("Invalid image"));
|
||||||
onError("Unsupported image format");
|
return;
|
||||||
return;
|
}
|
||||||
}
|
let canvas = document.createElement('canvas');
|
||||||
|
|
||||||
// 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.width = dim.dstWidth;
|
||||||
canvas.height = dim.dstHeight;
|
canvas.height = dim.dstHeight;
|
||||||
ctx = canvas.getContext('2d');
|
let ctx = canvas.getContext('2d');
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.imageSmoothingEnabled = true;
|
||||||
ctx.drawImage(this, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight,
|
ctx.drawImage(img, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight,
|
||||||
0, 0, dim.dstWidth, dim.dstHeight);
|
0, 0, dim.dstWidth, dim.dstHeight);
|
||||||
blob = await new Promise(resolve => canvas.toBlob(resolve, mime));
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas = null;
|
const mime = SUPPORTED_IMAGE_FORMATS.includes(fileOrBlob.type) ? fileOrBlob.type : 'image/jpeg';
|
||||||
onSuccess(mime, blob, dim.dstWidth, dim.dstHeight, fileNameForMime(fileOrBlob.name, mime));
|
// Generate blob to check size of the image.
|
||||||
};
|
let blob = await new Promise(resolve => canvas.toBlob(resolve, mime));
|
||||||
img.src = URL.createObjectURL(fileOrBlob);
|
if (!blob) {
|
||||||
|
reject(new Error("Unsupported image format"));
|
||||||
|
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(img, 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;
|
||||||
|
resolve({mime: mime, blob: blob, width: dim.dstWidth, height: dim.dstHeight, name: fileNameForMime(fileOrBlob.name, mime)});
|
||||||
|
};
|
||||||
|
img.src = URL.createObjectURL(fileOrBlob);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert File to base64 string.
|
// Scale and crop image according to specified dimensions.
|
||||||
export function fileToBase64(file, onSuccess) {
|
// The coordinates are in unscaled image pixels, i.e. cut the rectangle first then scale it.
|
||||||
const reader = new FileReader();
|
// returns a promise.
|
||||||
reader.addEventListener('load', function() {
|
export function imageCrop(mime, objURL, left, top, width, height, scale) {
|
||||||
onSuccess(file.type, reader.result.split(',')[1], file.name);
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'Anonymous';
|
||||||
|
img.onerror = (err) => {
|
||||||
|
reject(new Error("Image format unrecognized"));
|
||||||
|
};
|
||||||
|
img.onload = () => {
|
||||||
|
// Once the image is loaded, the URL is no longer needed.
|
||||||
|
URL.revokeObjectURL(img.src);
|
||||||
|
|
||||||
|
let canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width * scale;
|
||||||
|
canvas.height = height * scale;
|
||||||
|
let ctx = canvas.getContext('2d');
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.drawImage(img, left, top, width, height, 0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
mime = SUPPORTED_IMAGE_FORMATS.includes(mime) ? mime : 'image/jpeg';
|
||||||
|
// Generate blob to check size of the image.
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
// Allow GC.
|
||||||
|
canvas = null;
|
||||||
|
if (blob) {
|
||||||
|
resolve({mime: mime, blob: blob, width: width, height: height});
|
||||||
|
} else {
|
||||||
|
reject(new Error("Unsupported image format"));
|
||||||
|
}
|
||||||
|
}, mime);
|
||||||
|
};
|
||||||
|
img.src = objURL;
|
||||||
});
|
});
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert Blob to base64 string.
|
// Convert file to base64 string.
|
||||||
export function blobToBase64(blob, onSuccess) {
|
export function fileToBase64(file) {
|
||||||
const reader = new FileReader();
|
return new Promise((resolve, reject) => {
|
||||||
reader.addEventListener('load', function() {
|
const reader = new FileReader();
|
||||||
onSuccess(blob.type, reader.result.split(',')[1]);
|
reader.onerror = (evt) => {
|
||||||
|
reject(reader.error);
|
||||||
|
};
|
||||||
|
reader.onload = () => {
|
||||||
|
resolve({mime: file.type, bits: reader.result.split(',')[1], name: file.name});
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert Blob to base64 string. Returns a promise resolved with the base64 string and mime.
|
||||||
|
export function blobToBase64(blob) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onerror = (evt) => {
|
||||||
|
reject(reader.error);
|
||||||
|
};
|
||||||
|
reader.onload = () => {
|
||||||
|
resolve({mime: blob.type, bits: reader.result.split(',')[1]});
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
});
|
});
|
||||||
reader.readAsDataURL(blob);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// File pasted from the clipboard. It's either an inline image or a file attachment.
|
// File pasted from the clipboard. It's either an inline image or a file attachment.
|
||||||
// FIXME: handle large files out of band.
|
|
||||||
export function filePasted(event, onImageSuccess, onAttachmentSuccess, onError) {
|
export function filePasted(event, onImageSuccess, onAttachmentSuccess, onError) {
|
||||||
var items = (event.clipboardData || event.originalEvent.clipboardData || {}).items;
|
const items = (event.clipboardData || event.originalEvent.clipboardData || {}).items;
|
||||||
for (var i in items) {
|
if (!Array.isArray(items)) {
|
||||||
var item = items[i];
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i in items) {
|
||||||
|
const item = items[i];
|
||||||
if (item.kind === 'file') {
|
if (item.kind === 'file') {
|
||||||
var file = item.getAsFile();
|
const file = item.getAsFile();
|
||||||
if (!file) {
|
if (!file) {
|
||||||
console.log("Failed to get file object from pasted file item", item.kind, item.type);
|
console.error("Failed to get file object from pasted file item", item.kind, item.type);
|
||||||
|
onError("Failed to get file object from pasted file item");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (file.type && file.type.split('/')[0] == 'image') {
|
if (file.type && file.type.split('/')[0] == 'image') {
|
||||||
// Handle inline image
|
onImageSuccess(file);
|
||||||
if (file.size > MAX_INBAND_ATTACHMENT_SIZE || SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) {
|
|
||||||
imageFileScaledToBase64(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, false, onImageSuccess, onError);
|
|
||||||
} else {
|
|
||||||
imageFileToBase64(file, onImageSuccess, onError);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Handle file attachment
|
onAttachmentSuccess(file);
|
||||||
fileToBase64(file, onAttachmentSuccess, onError)
|
|
||||||
}
|
}
|
||||||
// Indicate that the pasted data contains a file.
|
// Indicate that the pasted data contains a file.
|
||||||
return true;
|
return true;
|
||||||
@@ -205,9 +251,33 @@ export function base64ReEncode(str) {
|
|||||||
try {
|
try {
|
||||||
str = btoa(atob(str));
|
str = btoa(atob(str));
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
console.log("Failed to base64 re-encode string.", err);
|
console.error("Failed to base64 re-encode string.", err);
|
||||||
str = null;
|
str = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert a base64 encoded string with the provided mime type into a Blob.
|
||||||
|
export function base64ToBlob(str, mime) {
|
||||||
|
if (!str) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Make blob.
|
||||||
|
const bin = atob(str);
|
||||||
|
const length = bin.length;
|
||||||
|
const buf = new ArrayBuffer(length);
|
||||||
|
const arr = new Uint8Array(buf);
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
arr[i] = bin.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Blob([buf], { type: mime });
|
||||||
|
} catch(err) {
|
||||||
|
console.error("Failed to convert base64 to blob: ", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { defineMessages } from 'react-intl';
|
||||||
|
|
||||||
|
import { Drafty } from 'tinode-sdk';
|
||||||
|
|
||||||
|
import LazyImage from '../widgets/lazy-image.jsx'
|
||||||
|
import UploadingImage from '../widgets/uploading-image.jsx'
|
||||||
|
|
||||||
|
import { IMAGE_THUMBNAIL_DIM, BROKEN_IMAGE_SIZE, REM_SIZE } from '../config.js';
|
||||||
|
import { base64ToBlob, blobToBase64, fitImageSize, imageScaled } from './blob-helpers.js';
|
||||||
|
import { idToColorClass, shortenFileName } from './strformat.js';
|
||||||
|
import { cancelablePromise, sanitizeImageUrl } from './utils.js';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
drafty_form: {
|
||||||
|
id: 'drafty_form',
|
||||||
|
defaultMessage: 'Form: ',
|
||||||
|
description: 'Comment for form in drafty preview'
|
||||||
|
},
|
||||||
|
drafty_attachment: {
|
||||||
|
id: 'drafty_attachment',
|
||||||
|
defaultMessage: 'Attachment',
|
||||||
|
description: 'Comment for attachment in drafty preview'
|
||||||
|
},
|
||||||
|
drafty_image: {
|
||||||
|
id: 'drafty_image',
|
||||||
|
defaultMessage: 'Picture',
|
||||||
|
description: 'Comment for embedded images in drafty preview'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Size the already scaled image.
|
||||||
|
function handleImageData(el, data, attr) {
|
||||||
|
if (!data) {
|
||||||
|
attr.src = 'img/broken_image.png';
|
||||||
|
attr.style = {
|
||||||
|
width: IMAGE_THUMBNAIL_DIM + 'px',
|
||||||
|
height: IMAGE_THUMBNAIL_DIM + 'px',
|
||||||
|
};
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
attr.className = 'inline-image';
|
||||||
|
const dim = fitImageSize(data.width, data.height,
|
||||||
|
this.viewportWidth > 0 ? Math.min(this.viewportWidth - REM_SIZE * 6.5, REM_SIZE * 34.5) :
|
||||||
|
REM_SIZE * 34.5, REM_SIZE * 24, false) ||
|
||||||
|
{dstWidth: BROKEN_IMAGE_SIZE, dstHeight: BROKEN_IMAGE_SIZE};
|
||||||
|
attr.style = {
|
||||||
|
width: dim.dstWidth + 'px',
|
||||||
|
height: dim.dstHeight + 'px',
|
||||||
|
// Looks like a Chrome bug: broken image does not respect 'width' and 'height'.
|
||||||
|
minWidth: dim.dstWidth + 'px',
|
||||||
|
minHeight: dim.dstHeight + 'px'
|
||||||
|
};
|
||||||
|
if (!Drafty.isProcessing(data)) {
|
||||||
|
attr.src = this.authorizeURL(sanitizeImageUrl(attr.src));
|
||||||
|
attr.alt = data.name;
|
||||||
|
if (attr.src) {
|
||||||
|
if (Math.max(data.width || 0, data.height || 0) > IMAGE_THUMBNAIL_DIM) {
|
||||||
|
// Allow previews for large enough images.
|
||||||
|
attr.onClick = this.onImagePreview;
|
||||||
|
attr.className += ' image-clickable';
|
||||||
|
}
|
||||||
|
attr.loading = 'lazy';
|
||||||
|
} else {
|
||||||
|
attr.src = 'img/broken_image.png';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use custom element instead of <img>.
|
||||||
|
el = UploadingImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotedImage(data) {
|
||||||
|
let promise;
|
||||||
|
// Get the blob from the image data.
|
||||||
|
if (data.val) {
|
||||||
|
const blob = base64ToBlob(data.val, data.mime);
|
||||||
|
promise = blob ? Promise.resolve(blob) : Prmise.reject(new Error("Invalid image"));
|
||||||
|
} else {
|
||||||
|
promise = fetch(this.authorizeURL(sanitizeImageUrl(data.ref))).then(evt => {
|
||||||
|
if (evt.ok) {
|
||||||
|
return evt.blob();
|
||||||
|
} else {
|
||||||
|
throw new Error(`Image fetch unsuccessful: ${evt.status} ${evt.statusText}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale the blob.
|
||||||
|
return promise
|
||||||
|
.then(blob => {
|
||||||
|
return imageScaled(blob, IMAGE_THUMBNAIL_DIM, IMAGE_THUMBNAIL_DIM, -1, true)
|
||||||
|
}).then(scaled => {
|
||||||
|
data.mime = scaled.mime;
|
||||||
|
data.size = scaled.blob.size;
|
||||||
|
data.width = scaled.width;
|
||||||
|
data.height = scaled.height;
|
||||||
|
delete data.ref;
|
||||||
|
// Keeping the original file name, if provided: ex.data.name;
|
||||||
|
|
||||||
|
return blobToBase64(scaled.blob);
|
||||||
|
}).then(b64 => {
|
||||||
|
data.val = b64.bits;
|
||||||
|
return data;
|
||||||
|
}).catch(err => {
|
||||||
|
delete data.val;
|
||||||
|
delete data.name;
|
||||||
|
data.width = IMAGE_THUMBNAIL_DIM;
|
||||||
|
data.height = IMAGE_THUMBNAIL_DIM;
|
||||||
|
data.maxWidth = IMAGE_THUMBNAIL_DIM;
|
||||||
|
data.maxHeight = IMAGE_THUMBNAIL_DIM;
|
||||||
|
// Rethrow.
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The main Drafty formatter: converts Drafty elements into React classes. 'this' is set by the caller.
|
||||||
|
// 'this' must contain:
|
||||||
|
// viewportWidth: this.props.viewportWidth;
|
||||||
|
// authorizeURL: this.props.tinode.authorizeURL
|
||||||
|
// onImagePreview: this.handleImagePreview
|
||||||
|
// onFormButtonClick: this.handleFormButtonClick
|
||||||
|
// onQuoteClick: this.handleQuoteClick (optional)
|
||||||
|
export function fullFormatter(style, data, values, key) {
|
||||||
|
if (!style) {
|
||||||
|
// Unformatted.
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style == 'HD') {
|
||||||
|
// Hidden.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let el = Drafty.tagName(style);
|
||||||
|
const attr = Drafty.attrValue(style, data) || {};
|
||||||
|
attr.key = key;
|
||||||
|
switch (style) {
|
||||||
|
case 'HL':
|
||||||
|
// Highlighted text. Assign class name.
|
||||||
|
attr.className = 'highlight';
|
||||||
|
break;
|
||||||
|
case 'IM':
|
||||||
|
// Additional processing for images
|
||||||
|
el = handleImageData.call(this, el, data, attr);
|
||||||
|
// Image element cannot have content.
|
||||||
|
values = null;
|
||||||
|
break;
|
||||||
|
case 'BN':
|
||||||
|
// Button
|
||||||
|
attr.onClick = this.onFormButtonClick;
|
||||||
|
let inner = React.Children.map(values, (child) => {
|
||||||
|
return typeof child == 'string' ? child : undefined;
|
||||||
|
});
|
||||||
|
if (!inner || inner.length == 0) {
|
||||||
|
inner = [attr.name]
|
||||||
|
}
|
||||||
|
// Get text which will be sent back when the button is clicked.
|
||||||
|
attr['data-title'] = inner.join('');
|
||||||
|
break;
|
||||||
|
case 'MN':
|
||||||
|
// Mention
|
||||||
|
attr.className = 'mention'
|
||||||
|
if (data) {
|
||||||
|
attr.className += ' ' + idToColorClass(data.val, false, true);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'FM':
|
||||||
|
// Form
|
||||||
|
attr.className = 'bot-form';
|
||||||
|
break;
|
||||||
|
case 'RW':
|
||||||
|
// Form element formatting is dependent on element content.
|
||||||
|
break;
|
||||||
|
case 'QQ':
|
||||||
|
// Quote/citation.
|
||||||
|
attr.className = 'reply-quote'
|
||||||
|
attr.onClick = this.onQuoteClick;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (el == '_UNKN') {
|
||||||
|
// Unknown element.
|
||||||
|
el = React.Fragment;
|
||||||
|
values = [<i className="material-icons gray">extension</i>, ' '].concat(values || []);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!el) {
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
return React.createElement(el, attr, values);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Converts Drafty object into a one-line preview. 'this' is set by the caller.
|
||||||
|
// 'this' must contain:
|
||||||
|
// formatMessage: this.props.intl.formatMessage
|
||||||
|
// messages: formatjs messages defined with defineMessages.
|
||||||
|
export function previewFormatter(style, data, values, key) {
|
||||||
|
if (!style) {
|
||||||
|
// Unformatted.
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style == 'HD') {
|
||||||
|
// Hidden.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let el = Drafty.tagName(style);
|
||||||
|
const attr = { key: key };
|
||||||
|
switch (style) {
|
||||||
|
case 'BR':
|
||||||
|
// Replace new line with a space.
|
||||||
|
el = React.Fragment;
|
||||||
|
values = [' '];
|
||||||
|
break;
|
||||||
|
case 'HL':
|
||||||
|
// Make highlight less prominent in preview.
|
||||||
|
attr.className = 'highlight preview';
|
||||||
|
break;
|
||||||
|
case 'LN':
|
||||||
|
case 'MN':
|
||||||
|
// Disable links in previews.
|
||||||
|
el = 'span';
|
||||||
|
break;
|
||||||
|
case 'IM':
|
||||||
|
// Replace image with '[icon] Image'.
|
||||||
|
el = React.Fragment;
|
||||||
|
values = [<i key="im" className="material-icons">photo</i>, this.formatMessage(messages.drafty_image)];
|
||||||
|
break;
|
||||||
|
case 'BN':
|
||||||
|
el = 'span';
|
||||||
|
attr.className = 'flat-button faux';
|
||||||
|
break;
|
||||||
|
case 'FM':
|
||||||
|
el = React.Fragment;
|
||||||
|
values = [<i key="fm" className="material-icons">dashboard</i>,
|
||||||
|
this.formatMessage(messages.drafty_form)].concat(' ', values || []);
|
||||||
|
break;
|
||||||
|
case 'RW':
|
||||||
|
el = React.Fragment;
|
||||||
|
break;
|
||||||
|
case 'EX':
|
||||||
|
if (data) {
|
||||||
|
if (data.mime == 'application/json') {
|
||||||
|
// Ignore JSON attachments: they are form response payloads.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Clear payload.
|
||||||
|
delete data.val;
|
||||||
|
delete data.ref;
|
||||||
|
}
|
||||||
|
el = React.Fragment;
|
||||||
|
values = [<i key="ex" className="material-icons">attachment</i>, this.formatMessage(messages.drafty_attachment)];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (el == '_UNKN') {
|
||||||
|
el = React.Fragment;
|
||||||
|
values = [<i key="unkn" className="material-icons">extension</i>, ' '].concat(values || []);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!el) {
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
return React.createElement(el, attr, values);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Converts Drafty object into a quoted reply. 'this' is set by the caller.
|
||||||
|
function inlineImageAttr(attr, data) {
|
||||||
|
attr.style = {
|
||||||
|
width: IMAGE_THUMBNAIL_DIM + 'px',
|
||||||
|
height: IMAGE_THUMBNAIL_DIM + 'px',
|
||||||
|
maxWidth: IMAGE_THUMBNAIL_DIM + 'px',
|
||||||
|
maxHeight: IMAGE_THUMBNAIL_DIM + 'px',
|
||||||
|
}
|
||||||
|
attr.className = 'inline-image';
|
||||||
|
attr.alt = this.formatMessage(messages.drafty_image);
|
||||||
|
if (!data) {
|
||||||
|
attr.src = 'img/broken_image.png';
|
||||||
|
}
|
||||||
|
attr.title = attr.alt;
|
||||||
|
return attr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Displays a portion of Drafty within 'QQ' quotes. 'this' is set by the caller.
|
||||||
|
// 'this' must contain:
|
||||||
|
// formatMessage: this.props.intl.formatMessage
|
||||||
|
// messages: formatjs messages defined with defineMessages.
|
||||||
|
// authorizeURL: this.props.tinode.authorizeURL
|
||||||
|
// onQuoteClick: this.handleQuoteClick (optional)
|
||||||
|
export function quoteFormatter(style, data, values, key) {
|
||||||
|
if (['BR', 'EX', 'IM', 'MN', 'QQ'].includes(style)) {
|
||||||
|
let el = Drafty.tagName(style);
|
||||||
|
let attr = Drafty.attrValue(style, data) || {};
|
||||||
|
attr.key = key;
|
||||||
|
switch(style) {
|
||||||
|
case 'IM':
|
||||||
|
attr = inlineImageAttr.call(this, attr, data);
|
||||||
|
values = [React.createElement('img', attr, null), ' ', attr.alt];
|
||||||
|
el = React.Fragment;
|
||||||
|
// Fragment attributes.
|
||||||
|
attr = {key: key};
|
||||||
|
break;
|
||||||
|
case 'MN':
|
||||||
|
el = 'span';
|
||||||
|
attr.className = 'mention'
|
||||||
|
if (data) {
|
||||||
|
attr.className += ' ' + idToColorClass(data.val, false, true);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'QQ':
|
||||||
|
attr.className = 'reply-quote';
|
||||||
|
attr.onClick = this.onQuoteClick;
|
||||||
|
break;
|
||||||
|
case 'EX':
|
||||||
|
let fname;
|
||||||
|
if (data) {
|
||||||
|
if (data.mime == 'application/json') {
|
||||||
|
// Ignore JSON attachments: they are form response payloads.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
fname = data.name;
|
||||||
|
// Clear payload.
|
||||||
|
delete data.val;
|
||||||
|
delete data.ref;
|
||||||
|
}
|
||||||
|
el = React.Fragment;
|
||||||
|
values = [<i key="ex" className="material-icons">attachment</i>,
|
||||||
|
shortenFileName(fname, 16) || this.formatMessage(messages.drafty_attachment)];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return React.createElement(el, attr, values);
|
||||||
|
}
|
||||||
|
return previewFormatter.call(this, style, data, values, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create image thumbnail suitable for inclusion in a quote.
|
||||||
|
function quoteImage(data) {
|
||||||
|
let promise;
|
||||||
|
// Get the blob from the image data.
|
||||||
|
if (data.val) {
|
||||||
|
const blob = base64ToBlob(data.val, data.mime);
|
||||||
|
promise = blob ? Promise.resolve(blob) : Promise.reject(new Error("Invalid image"));
|
||||||
|
} else {
|
||||||
|
promise = fetch(this.authorizeURL(sanitizeImageUrl(data.ref))).then(evt => {
|
||||||
|
if (evt.ok) {
|
||||||
|
return evt.blob();
|
||||||
|
} else {
|
||||||
|
throw new Error(`Image fetch unsuccessful: ${evt.status} ${evt.statusText}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale the blob.
|
||||||
|
return promise
|
||||||
|
.then(blob => {
|
||||||
|
// Cut the square from the center of the image and shrink it.
|
||||||
|
return imageScaled(blob, IMAGE_THUMBNAIL_DIM, IMAGE_THUMBNAIL_DIM, -1, true)
|
||||||
|
}).then(scaled => {
|
||||||
|
data.mime = scaled.mime;
|
||||||
|
data.size = scaled.blob.size;
|
||||||
|
data.width = scaled.width;
|
||||||
|
data.height = scaled.height;
|
||||||
|
delete data.ref;
|
||||||
|
// Keeping the original file name, if provided: ex.data.name;
|
||||||
|
|
||||||
|
data.src = URL.createObjectURL(scaled.blob);
|
||||||
|
return blobToBase64(scaled.blob);
|
||||||
|
}).then(b64 => {
|
||||||
|
data.val = b64.bits;
|
||||||
|
return data;
|
||||||
|
}).catch(err => {
|
||||||
|
delete data.val;
|
||||||
|
delete data.src;
|
||||||
|
data.width = IMAGE_THUMBNAIL_DIM;
|
||||||
|
data.height = IMAGE_THUMBNAIL_DIM;
|
||||||
|
// Rethrow.
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a preview of a reply.
|
||||||
|
export function replyFormatter(style, data, values, key) {
|
||||||
|
if (style != 'IM') {
|
||||||
|
return quoteFormatter.call(this, style, data, values, key);
|
||||||
|
}
|
||||||
|
const attr = inlineImageAttr.call(this, {key: key}, data);
|
||||||
|
attr.whenDone = cancelablePromise(quoteImage.call(this, data));
|
||||||
|
values = [React.createElement(LazyImage, attr, null), ' ', attr.alt];
|
||||||
|
return React.createElement(React.Fragment, {key: key}, values);
|
||||||
|
}
|
||||||
@@ -9,11 +9,11 @@ export default class HashNavigation {
|
|||||||
const params = {};
|
const params = {};
|
||||||
let path = [];
|
let path = [];
|
||||||
if (parts[0]) {
|
if (parts[0]) {
|
||||||
path = parts[0].substr(1).split('/');
|
path = parts[0].replace('#', '').split('/');
|
||||||
}
|
}
|
||||||
if (parts[1]) {
|
if (parts[1]) {
|
||||||
parts[1].split('&').forEach(function(part) {
|
parts[1].split('&').forEach(function(part) {
|
||||||
let item = part.split('=');
|
const item = part.split('=');
|
||||||
if (item[0]) {
|
if (item[0]) {
|
||||||
params[decodeURIComponent(item[0])] = decodeURIComponent(item[1]);
|
params[decodeURIComponent(item[0])] = decodeURIComponent(item[1]);
|
||||||
}
|
}
|
||||||
@@ -58,6 +58,16 @@ export default class HashNavigation {
|
|||||||
return this.composeUrlHash(parsed.path, parsed.params);
|
return this.composeUrlHash(parsed.path, parsed.params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static setUrlInfoPanel(hash, infopanel) {
|
||||||
|
const parsed = this.parseUrlHash(hash);
|
||||||
|
if (infopanel) {
|
||||||
|
parsed.params.info = infopanel;
|
||||||
|
} else {
|
||||||
|
delete parsed.params.info;
|
||||||
|
}
|
||||||
|
return this.composeUrlHash(parsed.path, parsed.params);
|
||||||
|
}
|
||||||
|
|
||||||
static setUrlTopic(hash, topic) {
|
static setUrlTopic(hash, topic) {
|
||||||
const parsed = this.parseUrlHash(hash);
|
const parsed = this.parseUrlHash(hash);
|
||||||
parsed.path[1] = topic;
|
parsed.path[1] = topic;
|
||||||
|
|||||||
@@ -37,9 +37,18 @@ export function bytesToHumanSize(bytes) {
|
|||||||
return count.toFixed(round) + ' ' + sizes[bucket];
|
return count.toFixed(round) + ' ' + sizes[bucket];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shorten a file name to be under maxLength by clipping out the middle.
|
||||||
|
export function shortenFileName(filename, maxLength) {
|
||||||
|
if (typeof filename != 'string') {
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
return filename.length > maxLength ?
|
||||||
|
filename.slice(0, maxLength/2 - 1) + '…' + filename.slice(1 - maxLength/2) : filename;
|
||||||
|
}
|
||||||
|
|
||||||
// Get 32 bit integer hash value for a string. Ideally it should produce the same value
|
// Get 32 bit integer hash value for a string. Ideally it should produce the same value
|
||||||
// as Java's String#hash().
|
// as Java's String#hash().
|
||||||
export function stringHash(value) {
|
function stringToColorHash(value) {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
value = '' + value;
|
value = '' + value;
|
||||||
for (let i = 0; i < value.length; i++) {
|
for (let i = 0; i < value.length; i++) {
|
||||||
@@ -48,3 +57,17 @@ export function stringHash(value) {
|
|||||||
}
|
}
|
||||||
return hash;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User avatar letter color id.
|
||||||
|
export function letterTileColorId(userId) {
|
||||||
|
return Math.abs(stringToColorHash(userId)) % 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converts user or topic ID to a CSS color class. Ideally it should produce the same color value as Java version.
|
||||||
|
// params:
|
||||||
|
// - {string} id: user or topic ID
|
||||||
|
// - {boolean} light: light or dark version of the color.
|
||||||
|
// - {boolean} fg: foreground (text) or background color.
|
||||||
|
export function idToColorClass(id, light, fg) {
|
||||||
|
return (light ? 'lt-' : 'dk-') + (fg ? 'fg-' : 'bg-') + letterTileColorId(id);
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ export function updateFavicon(count) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create theCard which represents user's or topic's "public" info.
|
// Create theCard which represents user's or topic's "public" info.
|
||||||
export function theCard(fn, imageUrl, imageMimeType) {
|
export function theCard(fn, imageUrl, imageMimeType, note) {
|
||||||
let card = null;
|
let card = null;
|
||||||
fn = fn && fn.trim();
|
fn = fn && fn.trim();
|
||||||
|
note = note && note.trim();
|
||||||
|
|
||||||
if (fn) {
|
if (fn) {
|
||||||
card = {
|
card = {
|
||||||
@@ -30,6 +31,11 @@ export function theCard(fn, imageUrl, imageMimeType) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof note == 'string') {
|
||||||
|
card = card || {};
|
||||||
|
card.note = note ? note : Tinode.DEL_CHAR;
|
||||||
|
}
|
||||||
|
|
||||||
if (imageUrl) {
|
if (imageUrl) {
|
||||||
card = card || {};
|
card = card || {};
|
||||||
let mimeType = imageMimeType;
|
let mimeType = imageMimeType;
|
||||||
@@ -38,10 +44,12 @@ export function theCard(fn, imageUrl, imageMimeType) {
|
|||||||
if (matches) {
|
if (matches) {
|
||||||
mimeType = matches[1];
|
mimeType = matches[1];
|
||||||
card.photo = {
|
card.photo = {
|
||||||
data: imageUrl.substring(imageUrl.indexOf(',') + 1)
|
data: imageUrl.substring(imageUrl.indexOf(',') + 1),
|
||||||
|
ref: Tinode.DEL_CHAR
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
card.photo = {
|
card.photo = {
|
||||||
|
data: Tinode.DEL_CHAR,
|
||||||
ref: imageUrl
|
ref: imageUrl
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -107,10 +115,9 @@ export function isUrlRelative(url) {
|
|||||||
|
|
||||||
// Ensure URL does not present an XSS risk. Optional allowedSchemes may contain an array of
|
// Ensure URL does not present an XSS risk. Optional allowedSchemes may contain an array of
|
||||||
// strings with permitted URL schemes, such as ['ftp', 'ftps']; otherwise accept http and https only.
|
// strings with permitted URL schemes, such as ['ftp', 'ftps']; otherwise accept http and https only.
|
||||||
// If apikey and/or token is defined, it's appended to relative URL.
|
|
||||||
export function sanitizeUrl(url, allowedSchemes) {
|
export function sanitizeUrl(url, allowedSchemes) {
|
||||||
if (!url) {
|
if (typeof url != 'string') {
|
||||||
return null;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip control characters and whitespace. They are not valid URL characters anyway.
|
// Strip control characters and whitespace. They are not valid URL characters anyway.
|
||||||
@@ -139,7 +146,6 @@ export function sanitizeUrl(url, allowedSchemes) {
|
|||||||
|
|
||||||
// Ensure URL is suitable for <img src="url"> field: the URL must be a relative URL or
|
// Ensure URL is suitable for <img src="url"> field: the URL must be a relative URL or
|
||||||
// have http:, https:, blob: or data: scheme.
|
// 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,'.
|
// In case of data: scheme, the URL must start with a 'data:image/XXXX;base64,'.
|
||||||
export function sanitizeImageUrl(url) {
|
export function sanitizeImageUrl(url) {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
@@ -165,7 +171,7 @@ export function deliveryMarker(received) {
|
|||||||
case Tinode.MESSAGE_STATUS_SENDING:
|
case Tinode.MESSAGE_STATUS_SENDING:
|
||||||
return { name: 'access_time' }; // watch face
|
return { name: 'access_time' }; // watch face
|
||||||
case Tinode.MESSAGE_STATUS_FAILED:
|
case Tinode.MESSAGE_STATUS_FAILED:
|
||||||
return { name: 'warning', color: 'amber' }; // yellow icon /!\
|
return { name: 'warning', color: 'danger-color' }; // yellow icon /!\
|
||||||
case Tinode.MESSAGE_STATUS_SENT:
|
case Tinode.MESSAGE_STATUS_SENT:
|
||||||
return { name: 'done' }; // checkmark
|
return { name: 'done' }; // checkmark
|
||||||
case Tinode.MESSAGE_STATUS_RECEIVED:
|
case Tinode.MESSAGE_STATUS_RECEIVED:
|
||||||
@@ -175,3 +181,22 @@ export function deliveryMarker(received) {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wraps a promise to make it cancelable.
|
||||||
|
export function cancelablePromise(promise) {
|
||||||
|
let hasCanceled = false;
|
||||||
|
|
||||||
|
const wrappedPromise = new Promise((resolve, reject) => {
|
||||||
|
promise.then(
|
||||||
|
result => hasCanceled ? reject({isCanceled: true}) : resolve(result),
|
||||||
|
error => hasCanceled ? reject({isCanceled: true}) : reject(error)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
promise: wrappedPromise,
|
||||||
|
cancel( ) {
|
||||||
|
hasCanceled = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
// This is a generated file. Don't edit.
|
// This is a generated file. Don't edit.
|
||||||
|
|
||||||
export const PACKAGE_VERSION = "0.17.10";
|
export const PACKAGE_VERSION = "0.18.0-beta1";
|
||||||
|
|||||||
@@ -1,189 +0,0 @@
|
|||||||
// Edit account parameters.
|
|
||||||
import React from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import Tinode from 'tinode-sdk';
|
|
||||||
|
|
||||||
import AvatarUpload from '../widgets/avatar-upload.jsx';
|
|
||||||
import InPlaceEdit from '../widgets/in-place-edit.jsx';
|
|
||||||
import TagManager from '../widgets/tag-manager.jsx';
|
|
||||||
|
|
||||||
import { MAX_TITLE_LENGTH } from '../config.js';
|
|
||||||
import { makeImageDataUrl } from '../lib/blob-helpers.js';
|
|
||||||
import { arrayEqual, asEmail, asPhone, theCard } from '../lib/utils.js';
|
|
||||||
|
|
||||||
export default class AccGeneralView extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
const me = this.props.tinode.getMeTopic();
|
|
||||||
this.state = {
|
|
||||||
fullName: me.public ? me.public.fn : undefined,
|
|
||||||
avatar: makeImageDataUrl(me.public ? me.public.photo : null),
|
|
||||||
tags: me.tags(),
|
|
||||||
credentials: me.getCredentials() || [],
|
|
||||||
addCredActive: false,
|
|
||||||
addCredInvalid: false,
|
|
||||||
newCred: '',
|
|
||||||
previousOnTags: me.onTagsUpdated
|
|
||||||
};
|
|
||||||
|
|
||||||
this.tnNewTags = this.tnNewTags.bind(this);
|
|
||||||
this.tnCredsUpdated = this.tnCredsUpdated.bind(this);
|
|
||||||
this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this);
|
|
||||||
this.handleImageChanged = this.handleImageChanged.bind(this);
|
|
||||||
this.handleCredChange = this.handleCredChange.bind(this);
|
|
||||||
this.handleCredKeyDown = this.handleCredKeyDown.bind(this);
|
|
||||||
this.handleCredEntered = this.handleCredEntered.bind(this);
|
|
||||||
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
const me = this.props.tinode.getMeTopic();
|
|
||||||
me.onCredsUpdated = this.tnCredsUpdated;
|
|
||||||
me.onTagsUpdated = this.tnNewTags;
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
const me = this.props.tinode.getMeTopic();
|
|
||||||
me.onTagsUpdated = this.state.previousOnTags;
|
|
||||||
me.onCredsUpdated = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
tnNewTags(tags) {
|
|
||||||
this.setState({tags: tags});
|
|
||||||
}
|
|
||||||
|
|
||||||
tnCredsUpdated(creds) {
|
|
||||||
this.setState({credentials: creds || []});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFullNameUpdate(fn) {
|
|
||||||
fn = fn.trim().substring(0, MAX_TITLE_LENGTH);
|
|
||||||
if (fn) {
|
|
||||||
this.setState({fullName: fn});
|
|
||||||
this.props.onUpdateAccount(undefined, theCard(fn, null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleImageChanged(img) {
|
|
||||||
this.setState({avatar: img});
|
|
||||||
this.props.onUpdateAccount(undefined, theCard(null, img || Tinode.DEL_CHAR));
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCredChange(e) {
|
|
||||||
this.setState({newCred: e.target.value, addCredInvalid: false});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCredKeyDown(e) {
|
|
||||||
if (e.keyCode === 27) {
|
|
||||||
// Escape pressed
|
|
||||||
this.setState({newCred: '', addCredActive: false});
|
|
||||||
} else if (e.keyCode === 13) {
|
|
||||||
// Enter pressed
|
|
||||||
this.handleCredEntered(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCredEntered(e) {
|
|
||||||
let value = this.state.newCred.trim();
|
|
||||||
if (!value) {
|
|
||||||
this.setState({addCredActive: false, addCredInvalid: false});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let val = asPhone(value);
|
|
||||||
let method;
|
|
||||||
if (val) {
|
|
||||||
method = 'tel';
|
|
||||||
} else {
|
|
||||||
val = asEmail(value);
|
|
||||||
if (val) {
|
|
||||||
method = 'email';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (method) {
|
|
||||||
this.props.onCredAdd(method, val);
|
|
||||||
this.setState({addCredActive: false, newCred: ''});
|
|
||||||
} else {
|
|
||||||
this.setState({addCredInvalid: true});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleTagsUpdated(tags) {
|
|
||||||
// Check if tags have actually changed.
|
|
||||||
if (arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.props.onUpdateTags(tags);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const credentials = [];
|
|
||||||
this.state.credentials.map((cred) => {
|
|
||||||
credentials.push(<div key={cred.meth + ":" + cred.val + ":" + cred.done}>{cred.meth}: <tt>{cred.val}</tt>
|
|
||||||
<span > {!cred.done ?
|
|
||||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onCredConfirm(cred.meth, cred.val);}}>
|
|
||||||
<FormattedMessage id="validate_credential_action" defaultMessage="confirm"
|
|
||||||
description="Validate credentail call to action" />
|
|
||||||
</a>
|
|
||||||
: null} <a href="#" onClick={(e) => {e.preventDefault(); this.props.onCredDelete(cred.meth, cred.val);}}><i
|
|
||||||
className="material-icons gray">delete_outline</i></a></span></div>);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="scrollable-panel">
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<div className="panel-form-column">
|
|
||||||
<label className="small"><FormattedMessage id="label_your_name" defaultMessage="Your name"
|
|
||||||
description="Label for full name editing" /></label>
|
|
||||||
<div><FormattedMessage id="full_name_prompt" defaultMessage="Full name, e.g. John Doe"
|
|
||||||
description="Input placeholder for person's full name">{
|
|
||||||
(full_name_placeholder) => <InPlaceEdit
|
|
||||||
placeholder={full_name_placeholder}
|
|
||||||
value={this.state.fullName}
|
|
||||||
onFinished={this.handleFullNameUpdate} />
|
|
||||||
}</FormattedMessage></div>
|
|
||||||
</div>
|
|
||||||
<AvatarUpload
|
|
||||||
avatar={this.state.avatar}
|
|
||||||
uid={this.props.myUserId}
|
|
||||||
title={this.state.fullName}
|
|
||||||
onImageChanged={this.handleImageChanged}
|
|
||||||
onError={this.props.onError} />
|
|
||||||
</div>
|
|
||||||
<div className="hr" />
|
|
||||||
<FormattedMessage id="title_tag_manager" defaultMessage="Tags (user discovery)"
|
|
||||||
description="Section title for TagManager">{
|
|
||||||
(title_tag_manager) => <TagManager
|
|
||||||
title={title_tag_manager}
|
|
||||||
activated={false}
|
|
||||||
tags={this.state.tags}
|
|
||||||
tinode={this.props.tinode}
|
|
||||||
onSubmit={this.handleTagsUpdated} />
|
|
||||||
}</FormattedMessage>
|
|
||||||
<div className="hr" />
|
|
||||||
<div className="panel-form-column">
|
|
||||||
<label className="small">
|
|
||||||
<FormattedMessage id="label_user_contacts" defaultMessage="Contacts:"
|
|
||||||
description="Label for user contacts" />
|
|
||||||
</label>
|
|
||||||
<div className="quoted">
|
|
||||||
{credentials}
|
|
||||||
{this.state.addCredActive ?
|
|
||||||
<input type="text" value={this.state.value}
|
|
||||||
className={this.state.addCredInvalid ? 'invalid' : null}
|
|
||||||
placeholder="Phone number or email" required="required" autoFocus
|
|
||||||
onChange={this.handleCredChange} onKeyDown={this.handleCredKeyDown} onBlur={this.handleCredEntered} />
|
|
||||||
: null}
|
|
||||||
<div>
|
|
||||||
<a href="#" onClick={(e) => {e.preventDefault(); this.setState({addCredActive: true});}}>
|
|
||||||
<i className="material-icons">add</i>
|
|
||||||
<FormattedMessage id="button_add_another" defaultMessage="Add another" description="Call to action [+ add another]" />
|
|
||||||
</a></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -12,7 +12,7 @@ const messages = defineMessages({
|
|||||||
description: 'Title for delete account warning'
|
description: 'Title for delete account warning'
|
||||||
},
|
},
|
||||||
delete_account_warning: {
|
delete_account_warning: {
|
||||||
id: 'delete_account_arning',
|
id: 'delete_account_warning',
|
||||||
defaultMessage: 'Are you sure you want to delete your account? It cannot be undone.',
|
defaultMessage: 'Are you sure you want to delete your account? It cannot be undone.',
|
||||||
description: 'Warning message when deleting an account'
|
description: 'Warning message when deleting an account'
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ class AccSecurityView extends React.Component {
|
|||||||
|
|
||||||
handlePasswordUpdate(pwd) {
|
handlePasswordUpdate(pwd) {
|
||||||
this.setState({password: pwd});
|
this.setState({password: pwd});
|
||||||
this.props.onUpdateAccount(pwd);
|
this.props.onUpdatePassword(pwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleLaunchPermissionsEditor(which) {
|
handleLaunchPermissionsEditor(which) {
|
||||||
@@ -63,7 +63,7 @@ class AccSecurityView extends React.Component {
|
|||||||
handlePermissionsChanged(perm) {
|
handlePermissionsChanged(perm) {
|
||||||
let defacs = {};
|
let defacs = {};
|
||||||
defacs[this.state.showPermissionEditorFor] = perm;
|
defacs[this.state.showPermissionEditorFor] = perm;
|
||||||
this.props.onUpdateAccount(undefined, undefined, defacs);
|
this.props.onUpdateAccountDesc('me', undefined, undefined, defacs);
|
||||||
|
|
||||||
let newState = {showPermissionEditorFor: undefined};
|
let newState = {showPermissionEditorFor: undefined};
|
||||||
newState[this.state.showPermissionEditorFor] = perm;
|
newState[this.state.showPermissionEditorFor] = perm;
|
||||||
@@ -85,7 +85,7 @@ class AccSecurityView extends React.Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<React.Fragment>{this.state.showPermissionEditorFor ?
|
<>{this.state.showPermissionEditorFor ?
|
||||||
<PermissionsEditor
|
<PermissionsEditor
|
||||||
mode={this.state.editedPermissions}
|
mode={this.state.editedPermissions}
|
||||||
skip="O"
|
skip="O"
|
||||||
@@ -147,7 +147,7 @@ class AccSecurityView extends React.Component {
|
|||||||
</>
|
</>
|
||||||
: null }
|
: null }
|
||||||
</div>
|
</div>
|
||||||
}</React.Fragment>
|
}</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Edit account parameters.
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import AvatarUpload from '../widgets/avatar-upload.jsx';
|
||||||
|
import BadgeList from '../widgets/badge-list.jsx';
|
||||||
|
|
||||||
|
import { makeImageUrl } from '../lib/blob-helpers.js';
|
||||||
|
import { MAX_TITLE_LENGTH, MAX_TOPIC_DESCRIPTION_LENGTH } from '../config.js';
|
||||||
|
|
||||||
|
function _clip(str, length) {
|
||||||
|
return str && str.substring(0, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class AccountSettingsView extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
const me = this.props.tinode.getMeTopic();
|
||||||
|
this.state = {
|
||||||
|
fullName: _clip(me.public ? me.public.fn : undefined, MAX_TITLE_LENGTH),
|
||||||
|
description: _clip(me.public ? me.public.note : undefined, MAX_TOPIC_DESCRIPTION_LENGTH),
|
||||||
|
avatar: makeImageUrl(me.public ? me.public.photo : null)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className="scrollable-panel">
|
||||||
|
<div className="panel-form-column">
|
||||||
|
<a href="#" className="flat-button float-right" onClick={(e) => {e.preventDefault(); this.props.onNavigate('general');}}>
|
||||||
|
<i className="material-icons">edit</i>
|
||||||
|
<FormattedMessage id="button_edit" defaultMessage="Edit" description="Call to action [Edit]" />
|
||||||
|
</a>
|
||||||
|
<center>
|
||||||
|
<AvatarUpload
|
||||||
|
tinode={this.props.tinode}
|
||||||
|
avatar={this.state.avatar}
|
||||||
|
readOnly={true}
|
||||||
|
uid={this.props.myUserId}
|
||||||
|
title={this.state.fullName} />
|
||||||
|
</center>
|
||||||
|
<div className="group">
|
||||||
|
<label className="small">
|
||||||
|
<FormattedMessage id="label_your_name" defaultMessage="Your name"
|
||||||
|
description="Label for full name editing" />
|
||||||
|
</label>
|
||||||
|
<div className="large ellipsized">{this.state.fullName}</div>
|
||||||
|
</div>
|
||||||
|
<div className="group">
|
||||||
|
<label className="small"><FormattedMessage id="label_user_id" defaultMessage="ID:"
|
||||||
|
description="Label for user address (ID)" /></label>
|
||||||
|
<tt>{this.props.myUserId}</tt>
|
||||||
|
</div>
|
||||||
|
<div className="group">
|
||||||
|
<BadgeList trustedBadges={this.props.trustedBadges} />
|
||||||
|
</div>
|
||||||
|
{this.state.description ?
|
||||||
|
<div className="group">
|
||||||
|
<label className="small">
|
||||||
|
<FormattedMessage id="label_description" defaultMessage="Description"
|
||||||
|
description="Label for editing topic description" />
|
||||||
|
</label>
|
||||||
|
<div>{this.state.description}</div>
|
||||||
|
</div> : null}
|
||||||
|
</div>
|
||||||
|
<div className="hr" />
|
||||||
|
<div className="panel-form-column">
|
||||||
|
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onNavigate('notif');}}>
|
||||||
|
<i className="material-icons">notifications</i> <FormattedMessage id="sidepanel_title_acc_notifications"
|
||||||
|
defaultMessage="Notifications" description="Sidepanel title for AccNotificationsView." />
|
||||||
|
</a>
|
||||||
|
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onNavigate('security');}}>
|
||||||
|
<i className="material-icons">security</i> <FormattedMessage id="button_security"
|
||||||
|
defaultMessage="Security" description="Navigaton button for security panel." />
|
||||||
|
</a>
|
||||||
|
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onNavigate('support');}}>
|
||||||
|
<i className="material-icons">contact_support</i> <FormattedMessage id="sidepanel_title_acc_support"
|
||||||
|
defaultMessage="Support" description="Sidepanel title for AccSupportView." />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -89,6 +89,7 @@ export default class ContactsView extends React.Component {
|
|||||||
defaultMessage="You have no chats<br />¯∖_(ツ)_/¯"
|
defaultMessage="You have no chats<br />¯∖_(ツ)_/¯"
|
||||||
description="HTML message shown in ContactList when no contacts are found">{
|
description="HTML message shown in ContactList when no contacts are found">{
|
||||||
(no_contacts) => <ContactList
|
(no_contacts) => <ContactList
|
||||||
|
tinode={this.props.tinode}
|
||||||
connected={this.props.connected}
|
connected={this.props.connected}
|
||||||
contacts={this.state.contactList}
|
contacts={this.state.contactList}
|
||||||
emptyListMessage={no_contacts}
|
emptyListMessage={no_contacts}
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export default class CreateAccountView extends React.PureComponent {
|
|||||||
}</FormattedMessage>
|
}</FormattedMessage>
|
||||||
</div>
|
</div>
|
||||||
<AvatarUpload
|
<AvatarUpload
|
||||||
|
tinode={this.props.tinode}
|
||||||
onImageChanged={this.handleImageChanged}
|
onImageChanged={this.handleImageChanged}
|
||||||
onError={this.props.onError} />
|
onError={this.props.onError} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
// Edit account parameters.
|
|
||||||
import React from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import AvatarUpload from '../widgets/avatar-upload.jsx';
|
|
||||||
|
|
||||||
import { makeImageDataUrl } from '../lib/blob-helpers.js';
|
|
||||||
|
|
||||||
export default class EditAccountView extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
const me = this.props.tinode.getMeTopic();
|
|
||||||
this.state = {
|
|
||||||
fullName: me.public ? me.public.fn : undefined,
|
|
||||||
avatar: makeImageDataUrl(me.public ? me.public.photo : null)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div className="scrollable-panel">
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<div className="panel-form-column">
|
|
||||||
<label className="small">
|
|
||||||
<FormattedMessage id="label_your_name" defaultMessage="Your name"
|
|
||||||
description="Label for full name editing" />
|
|
||||||
</label>
|
|
||||||
<div className="large">{this.state.fullName}</div>
|
|
||||||
<div>
|
|
||||||
<label className="small"><FormattedMessage id="label_user_id" defaultMessage="ID:"
|
|
||||||
description="Label for user address (ID)" /></label>
|
|
||||||
<tt>{this.props.myUserId}</tt>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AvatarUpload
|
|
||||||
avatar={this.state.avatar}
|
|
||||||
readOnly={!this.state.owner}
|
|
||||||
uid={this.props.myUserId}
|
|
||||||
title={this.state.fullName} />
|
|
||||||
</div>
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onBasicNavigate('general');}}>
|
|
||||||
<i className="material-icons">edit</i>
|
|
||||||
<FormattedMessage id="button_edit" defaultMessage="Edit" description="Call to action [Edit]" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="hr" />
|
|
||||||
<div className="panel-form-column">
|
|
||||||
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onBasicNavigate('notif');}}>
|
|
||||||
<i className="material-icons">notifications</i> <FormattedMessage id="sidepanel_title_acc_notifications"
|
|
||||||
defaultMessage="Notifications" description="Sidepanel title for AccNotificationsView." />
|
|
||||||
</a>
|
|
||||||
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onBasicNavigate('security');}}>
|
|
||||||
<i className="material-icons">security</i> <FormattedMessage id="sidepanel_title_acc_security"
|
|
||||||
defaultMessage="Security" description="Sidepanel title for AccSecurityView." />
|
|
||||||
</a>
|
|
||||||
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onBasicNavigate('support');}}>
|
|
||||||
<i className="material-icons">contact_support</i> <FormattedMessage id="sidepanel_title_acc_support"
|
|
||||||
defaultMessage="Support" description="Sidepanel title for AccSupportView." />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -5,104 +5,83 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
|||||||
import Tinode from 'tinode-sdk';
|
import Tinode from 'tinode-sdk';
|
||||||
|
|
||||||
import AvatarUpload from '../widgets/avatar-upload.jsx';
|
import AvatarUpload from '../widgets/avatar-upload.jsx';
|
||||||
|
import BadgeList from '../widgets/badge-list.jsx';
|
||||||
import CheckBox from '../widgets/checkbox.jsx';
|
import CheckBox from '../widgets/checkbox.jsx';
|
||||||
import ContactList from '../widgets/contact-list.jsx';
|
import ContactList from '../widgets/contact-list.jsx';
|
||||||
import ErrorPanel from '../widgets/error-panel.jsx';
|
import ErrorPanel from '../widgets/error-panel.jsx';
|
||||||
import GroupManager from '../widgets/group-manager.jsx';
|
import GroupManager from '../widgets/group-manager.jsx';
|
||||||
import InPlaceEdit from '../widgets/in-place-edit.jsx';
|
|
||||||
import MenuCancel from '../widgets/menu-cancel.jsx';
|
import MenuCancel from '../widgets/menu-cancel.jsx';
|
||||||
import MoreButton from '../widgets/more-button.jsx';
|
|
||||||
import PermissionsEditor from '../widgets/permissions-editor.jsx';
|
import PermissionsEditor from '../widgets/permissions-editor.jsx';
|
||||||
import TagManager from '../widgets/tag-manager.jsx';
|
import TopicCommon from '../widgets/topic-common.jsx';
|
||||||
|
import TopicSecurity from '../widgets/topic-security.jsx';
|
||||||
|
|
||||||
import { MAX_TITLE_LENGTH, NO_ACCESS_MODE } from '../config.js';
|
import { MAX_TITLE_LENGTH, MAX_TOPIC_DESCRIPTION_LENGTH, NO_ACCESS_MODE } from '../config.js';
|
||||||
|
|
||||||
import { makeImageDataUrl } from '../lib/blob-helpers.js';
|
import { makeImageUrl } from '../lib/blob-helpers.js';
|
||||||
import { arrayEqual, theCard } from '../lib/utils.js';
|
import { arrayEqual, theCard } from '../lib/utils.js';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
requested: {
|
info: {
|
||||||
|
id: 'panel_title_info',
|
||||||
|
description: 'Title for InfoView',
|
||||||
|
defaultMessage: 'Info'
|
||||||
|
},
|
||||||
|
general: {
|
||||||
|
id: 'panel_title_general',
|
||||||
|
description: 'Title for TopicCommon.',
|
||||||
|
defaultMessage: 'General'
|
||||||
|
},
|
||||||
|
security: {
|
||||||
|
id: 'panel_title_security',
|
||||||
|
description: 'Title for TopicSecirity and AccSecurity.',
|
||||||
|
defaultMessage: 'Security'
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
id: 'panel_title_members',
|
||||||
|
description: 'Title for managing group members view.',
|
||||||
|
defaultMessage: 'Members'
|
||||||
|
},
|
||||||
|
crop: {
|
||||||
|
id: 'panel_title_crop',
|
||||||
|
description: 'Title for AvatarCropView.',
|
||||||
|
defaultMessage: 'Drag to Adjust'
|
||||||
|
},
|
||||||
|
perm_want: {
|
||||||
id: 'requested_permissions',
|
id: 'requested_permissions',
|
||||||
defaultMessage: 'Requested',
|
defaultMessage: 'Requested',
|
||||||
description: 'Title for permissions'
|
description: 'Title for permissions'
|
||||||
},
|
},
|
||||||
granted: {
|
perm_given: {
|
||||||
id: 'granted_permissions',
|
id: 'granted_permissions',
|
||||||
defaultMessage: 'Granted',
|
defaultMessage: 'Granted',
|
||||||
description: 'Title for permissions'
|
description: 'Title for permissions'
|
||||||
},
|
},
|
||||||
|
perm_auth: {
|
||||||
|
id: 'permissions_authenticated',
|
||||||
|
defaultMessage: 'Authenticated',
|
||||||
|
description: 'Title for editing default authenticated permissions'
|
||||||
|
},
|
||||||
|
perm_anon: {
|
||||||
|
id: 'permissions_anonymous',
|
||||||
|
defaultMessage: 'Anonymous',
|
||||||
|
description: 'Title for editing default anonymous permissions'
|
||||||
|
},
|
||||||
|
perm_user: {
|
||||||
|
id: 'permissions_user',
|
||||||
|
defaultMessage: 'User\'s Permissions',
|
||||||
|
description: 'Title for editing user\'s permissions'
|
||||||
|
},
|
||||||
edit_permissions: {
|
edit_permissions: {
|
||||||
id: 'menu_item_edit_permissions',
|
id: 'menu_item_edit_permissions',
|
||||||
defaultMessage: 'Edit permissions',
|
defaultMessage: 'Edit permissions',
|
||||||
description: 'Menu item [Edit permissions]'
|
description: 'Menu item [Edit permissions]'
|
||||||
},
|
},
|
||||||
other_user: {
|
|
||||||
id: 'label_other_user',
|
|
||||||
defaultMessage: 'Other',
|
|
||||||
description: 'Label for the other user when the user is unnamed'
|
|
||||||
},
|
|
||||||
clear_messages: {
|
|
||||||
id: 'action_clear_messages',
|
|
||||||
defaultMessage: 'Clear Messages',
|
|
||||||
description: 'Flat button [Clear Messages] (soft-delete messages)'
|
|
||||||
},
|
|
||||||
clear_messages_warning: {
|
|
||||||
id: 'clear_messages_warning',
|
|
||||||
defaultMessage: 'Are you sure you want to clear all messages? It cannot be undone.',
|
|
||||||
description: 'Alert dialog warning when deleting all messages.'
|
|
||||||
},
|
|
||||||
delete_messages: {
|
|
||||||
id: 'action_delete_messages',
|
|
||||||
defaultMessage: 'Clear Messages for All',
|
|
||||||
description: 'Flat button [Clear for All] (hard-delete all messages)'
|
|
||||||
},
|
|
||||||
delete_messages_warning: {
|
|
||||||
id: 'delete_messages_warning',
|
|
||||||
defaultMessage: 'Are you sure you want to delete all messages for everyone? It cannot be undone.',
|
|
||||||
description: 'Alert dialog warning when hard-deleting all messages.'
|
|
||||||
},
|
|
||||||
topic_delete: {
|
|
||||||
id: 'topic_delete',
|
|
||||||
defaultMessage: 'Delete Conversation',
|
|
||||||
description: 'Alert title when deleting the topic.'
|
|
||||||
},
|
|
||||||
topic_delete_warning: {
|
|
||||||
id: 'topic_delete_warning',
|
|
||||||
defaultMessage: 'Are you sure you want to delete this conversation? It cannot be undone.',
|
|
||||||
description: 'Alert warning when deleting entire topic'
|
|
||||||
},
|
|
||||||
leave_chat: {
|
|
||||||
id: 'action_leave_chat',
|
|
||||||
defaultMessage: 'Leave Conversation',
|
|
||||||
description: 'Flat button [Leave Conversation]'
|
|
||||||
},
|
|
||||||
leave_chat_warning: {
|
|
||||||
id: 'leave_chat_warning',
|
|
||||||
defaultMessage: 'Are you sure you want to leave this conversation?',
|
|
||||||
description: 'Alert dialog warning when unsubscribing from a chat.'
|
|
||||||
},
|
|
||||||
block_contact: {
|
|
||||||
id: 'action_block_contact',
|
|
||||||
defaultMessage: "Block Contact",
|
|
||||||
description: "Flat button [Block Contact]"
|
|
||||||
},
|
|
||||||
block_contact_warning: {
|
|
||||||
id: 'block_contact_warning',
|
|
||||||
defaultMessage: 'Are you sure you want to block this contact?',
|
|
||||||
description: 'Alert dialog warning when blocking a contact.'
|
|
||||||
},
|
|
||||||
report_chat: {
|
|
||||||
id: 'action_report_chat',
|
|
||||||
defaultMessage: 'Report Conversation',
|
|
||||||
description: 'Flat button [Report Group]'
|
|
||||||
},
|
|
||||||
report_chat_warning: {
|
|
||||||
id: 'report_chat_warning',
|
|
||||||
defaultMessage: 'Are you sure you want to block and report this conversation?',
|
|
||||||
description: 'Alert dialog warning when reporting a conversation for abuse'
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function _clip(str, length) {
|
||||||
|
return str && str.substring(0, length);
|
||||||
|
}
|
||||||
|
|
||||||
class InfoView extends React.Component {
|
class InfoView extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
@@ -118,6 +97,7 @@ class InfoView extends React.Component {
|
|||||||
groupTopic: undefined,
|
groupTopic: undefined,
|
||||||
channel: undefined,
|
channel: undefined,
|
||||||
fullName: undefined,
|
fullName: undefined,
|
||||||
|
description: undefined,
|
||||||
avatar: null,
|
avatar: null,
|
||||||
private: null,
|
private: null,
|
||||||
selectedContact: null,
|
selectedContact: null,
|
||||||
@@ -129,39 +109,26 @@ class InfoView extends React.Component {
|
|||||||
auth: null,
|
auth: null,
|
||||||
anon: null,
|
anon: null,
|
||||||
contactList: [],
|
contactList: [],
|
||||||
tags: [],
|
trustedBadges: [],
|
||||||
showMemberPanel: false,
|
|
||||||
showPermissionEditorFor: undefined,
|
|
||||||
moreInfoExpanded: false,
|
|
||||||
previousMetaDesc: undefined,
|
previousMetaDesc: undefined,
|
||||||
previousSubsUpdated: undefined,
|
previousSubsUpdated: undefined,
|
||||||
previousTagsUpdated: undefined
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.resetSubs = this.resetSubs.bind(this);
|
this.resetSubs = this.resetSubs.bind(this);
|
||||||
this.resetDesc = this.resetDesc.bind(this);
|
this.resetDesc = this.resetDesc.bind(this);
|
||||||
|
this.resetTags = this.resetTags.bind(this);
|
||||||
this.onMetaDesc = this.onMetaDesc.bind(this);
|
this.onMetaDesc = this.onMetaDesc.bind(this);
|
||||||
this.onSubsUpdated = this.onSubsUpdated.bind(this);
|
this.onSubsUpdated = this.onSubsUpdated.bind(this);
|
||||||
this.onTagsUpdated = this.onTagsUpdated.bind(this);
|
|
||||||
this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this);
|
|
||||||
this.handlePrivateUpdate = this.handlePrivateUpdate.bind(this);
|
|
||||||
this.handleImageChanged = this.handleImageChanged.bind(this);
|
this.handleImageChanged = this.handleImageChanged.bind(this);
|
||||||
this.handleMuted = this.handleMuted.bind(this);
|
this.handleMuted = this.handleMuted.bind(this);
|
||||||
|
this.handleUnarchive = this.handleUnarchive.bind(this);
|
||||||
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
|
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
|
||||||
this.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this);
|
this.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this);
|
||||||
this.handleHidePermissionsEditor = this.handleHidePermissionsEditor.bind(this);
|
|
||||||
this.handleShowAddMembers = this.handleShowAddMembers.bind(this);
|
this.handleShowAddMembers = this.handleShowAddMembers.bind(this);
|
||||||
this.handleHideAddMembers = this.handleHideAddMembers.bind(this);
|
|
||||||
this.handleMemberUpdateRequest = this.handleMemberUpdateRequest.bind(this);
|
this.handleMemberUpdateRequest = this.handleMemberUpdateRequest.bind(this);
|
||||||
this.handleDeleteTopic = this.handleDeleteTopic.bind(this);
|
|
||||||
this.handleDeleteMessages = this.handleDeleteMessages.bind(this);
|
|
||||||
this.handleLeave = this.handleLeave.bind(this);
|
|
||||||
this.handleBlock = this.handleBlock.bind(this);
|
|
||||||
this.handleReport = this.handleReport.bind(this);
|
|
||||||
this.handleMemberSelected = this.handleMemberSelected.bind(this);
|
this.handleMemberSelected = this.handleMemberSelected.bind(this);
|
||||||
this.handleMoreInfo = this.handleMoreInfo.bind(this);
|
|
||||||
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
|
|
||||||
this.handleContextMenu = this.handleContextMenu.bind(this);
|
this.handleContextMenu = this.handleContextMenu.bind(this);
|
||||||
|
this.handleBackNavigate = this.handleBackNavigate.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No need to separately handle component mount.
|
// No need to separately handle component mount.
|
||||||
@@ -177,19 +144,13 @@ class InfoView extends React.Component {
|
|||||||
|
|
||||||
this.previousSubsUpdated = topic.onSubsUpdated;
|
this.previousSubsUpdated = topic.onSubsUpdated;
|
||||||
topic.onSubsUpdated = this.onSubsUpdated;
|
topic.onSubsUpdated = this.onSubsUpdated;
|
||||||
|
|
||||||
if (topic.getType() == 'grp') {
|
|
||||||
this.previousTagsUpdated = topic.onTagsUpdated;
|
|
||||||
topic.onTagsUpdated = this.onTagsUpdated;
|
|
||||||
} else {
|
|
||||||
this.previousTagsUpdated = undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.state.topic != props.topic) {
|
if (this.state.topic != props.topic) {
|
||||||
this.setState({topic: props.topic});
|
this.setState({topic: props.topic});
|
||||||
this.resetDesc(topic, props);
|
this.resetDesc(topic, props);
|
||||||
this.resetSubs(topic, props);
|
this.resetSubs(topic, props);
|
||||||
|
this.resetTags(topic);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +162,6 @@ class InfoView extends React.Component {
|
|||||||
this.setState({topic: null});
|
this.setState({topic: null});
|
||||||
topic.onMetaDesc = this.previousMetaDesc;
|
topic.onMetaDesc = this.previousMetaDesc;
|
||||||
topic.onSubsUpdated = this.previousSubsUpdated;
|
topic.onSubsUpdated = this.previousSubsUpdated;
|
||||||
topic.onTagsUpdated = this.previousTagsUpdated;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resetSubs(topic, props) {
|
resetSubs(topic, props) {
|
||||||
@@ -230,6 +190,15 @@ class InfoView extends React.Component {
|
|||||||
const defacs = topic.getDefaultAccess() || {};
|
const defacs = topic.getDefaultAccess() || {};
|
||||||
const acs = topic.getAccessMode();
|
const acs = topic.getAccessMode();
|
||||||
|
|
||||||
|
const badges = [];
|
||||||
|
if (topic.trusted) {
|
||||||
|
for (const [key, val] of Object.entries(topic.trusted)) {
|
||||||
|
if (val) {
|
||||||
|
badges.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
owner: acs && acs.isOwner(),
|
owner: acs && acs.isOwner(),
|
||||||
admin: acs && acs.isAdmin(),
|
admin: acs && acs.isAdmin(),
|
||||||
@@ -237,21 +206,30 @@ class InfoView extends React.Component {
|
|||||||
deleter: acs && acs.isDeleter(),
|
deleter: acs && acs.isDeleter(),
|
||||||
muted: acs && acs.isMuted(),
|
muted: acs && acs.isMuted(),
|
||||||
|
|
||||||
fullName: topic.public ? topic.public.fn : undefined,
|
fullName: _clip(topic.public ? topic.public.fn : undefined, MAX_TITLE_LENGTH),
|
||||||
avatar: makeImageDataUrl(topic.public ? topic.public.photo : null),
|
description: _clip(topic.public ? topic.public.note : undefined, MAX_TOPIC_DESCRIPTION_LENGTH),
|
||||||
private: topic.private ? topic.private.comment : null,
|
avatar: makeImageUrl(topic.public ? topic.public.photo : null),
|
||||||
|
trustedBadges: badges,
|
||||||
|
private: _clip(topic.private ? topic.private.comment : null, MAX_TITLE_LENGTH),
|
||||||
|
archived: topic.isArchived(),
|
||||||
address: topic.name,
|
address: topic.name,
|
||||||
groupTopic: topic.isGroupType(),
|
groupTopic: topic.isGroupType(),
|
||||||
channel: topic.isChannelType(),
|
channel: topic.isChannelType() || topic.chan,
|
||||||
showMemberPanel: false,
|
|
||||||
access: acs ? acs.getMode() : undefined,
|
access: acs ? acs.getMode() : undefined,
|
||||||
modeGiven: acs ? acs.getGiven() : undefined,
|
modeGiven: acs ? acs.getGiven() : undefined,
|
||||||
modeWant: acs ? acs.getWant() : undefined,
|
modeWant: acs ? acs.getWant() : undefined,
|
||||||
auth: defacs.auth,
|
auth: defacs.auth,
|
||||||
anon: defacs.anon
|
anon: defacs.anon
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (topic.getType() == 'grp' && acs && acs.isOwner()) {
|
resetTags(topic) {
|
||||||
|
if (topic.getType() != 'grp') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const acs = topic.getAccessMode();
|
||||||
|
if (acs && acs.isOwner()) {
|
||||||
// Requesting tags: owner is editing the topic.
|
// Requesting tags: owner is editing the topic.
|
||||||
topic.getMeta(topic.startMetaQuery().withTags().build());
|
topic.getMeta(topic.startMetaQuery().withTags().build());
|
||||||
}
|
}
|
||||||
@@ -281,30 +259,6 @@ class InfoView extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onTagsUpdated(tags) {
|
|
||||||
this.setState({tags: tags});
|
|
||||||
|
|
||||||
if (this.previousTagsUpdated && this.previousTagsUpdated != this.onTagsUpdated) {
|
|
||||||
this.previousTagsUpdated();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFullNameUpdate(fn) {
|
|
||||||
fn = fn.trim().substring(0, MAX_TITLE_LENGTH);
|
|
||||||
if (this.state.fullName !== fn) {
|
|
||||||
this.setState({fullName: fn});
|
|
||||||
this.props.onTopicDescUpdate(this.props.topic, theCard(fn, null), null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePrivateUpdate(comment) {
|
|
||||||
comment = comment.trim().substring(0, MAX_TITLE_LENGTH);
|
|
||||||
if (this.state.private !== comment) {
|
|
||||||
this.setState({private: comment});
|
|
||||||
this.props.onTopicDescUpdate(this.props.topic, null, comment || Tinode.DEL_CHAR);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleImageChanged(img) {
|
handleImageChanged(img) {
|
||||||
this.setState({avatar: img});
|
this.setState({avatar: img});
|
||||||
this.props.onTopicDescUpdate(this.props.topic, theCard(null, img || Tinode.DEL_CHAR), null);
|
this.props.onTopicDescUpdate(this.props.topic, theCard(null, img || Tinode.DEL_CHAR), null);
|
||||||
@@ -315,13 +269,17 @@ class InfoView extends React.Component {
|
|||||||
this.props.onChangePermissions(this.props.topic, checked ? '-P' : '+P');
|
this.props.onChangePermissions(this.props.topic, checked ? '-P' : '+P');
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePermissionsChanged(perm) {
|
handleUnarchive(ignored, ignored2) {
|
||||||
switch (this.state.showPermissionEditorFor) {
|
this.props.onTopicUnArchive(this.props.topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePermissionsChanged(which, perm) {
|
||||||
|
switch (which) {
|
||||||
case 'auth':
|
case 'auth':
|
||||||
this.props.onTopicDescUpdate(this.props.topic, null, null, {auth: perm});
|
this.props.onTopicDescUpdateRequest(this.props.topic, null, null, {auth: perm});
|
||||||
break;
|
break;
|
||||||
case 'anon':
|
case 'anon':
|
||||||
this.props.onTopicDescUpdate(this.props.topic, null, null, {anon: perm});
|
this.props.onTopicDescUpdateRequest(this.props.topic, null, null, {anon: perm});
|
||||||
break;
|
break;
|
||||||
case 'mode':
|
case 'mode':
|
||||||
case 'want':
|
case 'want':
|
||||||
@@ -335,7 +293,7 @@ class InfoView extends React.Component {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({showPermissionEditorFor: undefined});
|
this.handleBackNavigate();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleLaunchPermissionsEditor(which, uid) {
|
handleLaunchPermissionsEditor(which, uid) {
|
||||||
@@ -359,15 +317,15 @@ class InfoView extends React.Component {
|
|||||||
toSkip += 'W';
|
toSkip += 'W';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
titleEdit = formatMessage(messages.requested);
|
titleEdit = formatMessage(messages.perm_want);
|
||||||
titleCompare = formatMessage(messages.granted);
|
titleCompare = formatMessage(messages.perm_given);
|
||||||
break;
|
break;
|
||||||
case 'given':
|
case 'given':
|
||||||
toEdit = this.state.modeGiven2;
|
toEdit = this.state.modeGiven2;
|
||||||
toCompare = this.state.modeWant2;
|
toCompare = this.state.modeWant2;
|
||||||
toSkip = this.state.groupTopic ? (this.state.owner ? '' : 'O') : 'ASDO';
|
toSkip = this.state.groupTopic ? (this.state.owner ? '' : 'O') : 'ASDO';
|
||||||
titleEdit = formatMessage(messages.granted);
|
titleEdit = formatMessage(messages.perm_given);
|
||||||
titleCompare = formatMessage(messages.requested);
|
titleCompare = formatMessage(messages.perm_want);
|
||||||
break;
|
break;
|
||||||
case 'auth':
|
case 'auth':
|
||||||
toEdit = this.state.auth;
|
toEdit = this.state.auth;
|
||||||
@@ -389,8 +347,8 @@ class InfoView extends React.Component {
|
|||||||
toEdit = user.acs.getGiven();
|
toEdit = user.acs.getGiven();
|
||||||
toCompare = user.acs.getWant();
|
toCompare = user.acs.getWant();
|
||||||
toSkip = this.state.owner ? '' : 'O';
|
toSkip = this.state.owner ? '' : 'O';
|
||||||
titleEdit = formatMessage(messages.granted);
|
titleEdit = formatMessage(messages.perm_given);
|
||||||
titleCompare = formatMessage(messages.requested);
|
titleCompare = formatMessage(messages.perm_want);
|
||||||
if (user.public) {
|
if (user.public) {
|
||||||
userTitle = user.public.fn;
|
userTitle = user.public.fn;
|
||||||
userAvatar = user.public.photo;
|
userAvatar = user.public.photo;
|
||||||
@@ -398,11 +356,10 @@ class InfoView extends React.Component {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
console.log("Unknown permission editing mode '" + which + "'");
|
console.error("Unknown permission editing mode '" + which + "'");
|
||||||
break;
|
return;
|
||||||
}
|
}
|
||||||
this.setState({
|
this.setState({
|
||||||
showPermissionEditorFor: which,
|
|
||||||
userPermissionsEdited: uid,
|
userPermissionsEdited: uid,
|
||||||
userPermissionsTitle: userTitle,
|
userPermissionsTitle: userTitle,
|
||||||
userPermissionsAvatar: userAvatar,
|
userPermissionsAvatar: userAvatar,
|
||||||
@@ -412,109 +369,41 @@ class InfoView extends React.Component {
|
|||||||
immutablePermissionsTitle: titleCompare,
|
immutablePermissionsTitle: titleCompare,
|
||||||
editedPermissionsSkipped: toSkip,
|
editedPermissionsSkipped: toSkip,
|
||||||
});
|
});
|
||||||
}
|
this.props.onNavigate(`perm/${which}`);
|
||||||
|
|
||||||
handleHidePermissionsEditor() {
|
|
||||||
this.setState({showPermissionEditorFor: undefined});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleShowAddMembers(e) {
|
handleShowAddMembers(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.props.onInitFind();
|
this.props.onInitFind();
|
||||||
this.setState({showMemberPanel: true});
|
this.props.onNavigate('members');
|
||||||
}
|
|
||||||
|
|
||||||
handleHideAddMembers() {
|
|
||||||
this.setState({showMemberPanel: false});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMemberUpdateRequest(members, added, removed) {
|
handleMemberUpdateRequest(members, added, removed) {
|
||||||
this.props.onMemberUpdateRequest(this.props.topic, added, removed);
|
this.props.onMemberUpdateRequest(this.props.topic, added, removed);
|
||||||
this.setState({showMemberPanel: false});
|
this.props.onNavigate('info');
|
||||||
}
|
|
||||||
|
|
||||||
handleDeleteTopic(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const {formatMessage} = this.props.intl;
|
|
||||||
this.props.onShowAlert(
|
|
||||||
formatMessage(messages.topic_delete), // title
|
|
||||||
formatMessage(messages.topic_delete_warning), // content
|
|
||||||
(() => { this.props.onDeleteTopic(this.props.topic); }), // onConfirm
|
|
||||||
null, // "OK"
|
|
||||||
true, // Show Reject button
|
|
||||||
null // "Cancel"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDeleteMessages(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const {formatMessage} = this.props.intl;
|
|
||||||
this.props.onShowAlert(
|
|
||||||
formatMessage(this.state.deleter ? messages.delete_messages : messages.clear_messages), // title
|
|
||||||
formatMessage(this.state.deleter ? messages.delete_messages_warning : messages.clear_messages_warning), // content
|
|
||||||
(() => { this.props.onDeleteMessages(this.props.topic); }), // onConfirm
|
|
||||||
null, // "OK"
|
|
||||||
true, // Show Reject button
|
|
||||||
null // "Cancel"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleLeave(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const {formatMessage} = this.props.intl;
|
|
||||||
this.props.onShowAlert(
|
|
||||||
formatMessage(messages.leave_chat), // title
|
|
||||||
formatMessage(messages.leave_chat_warning), // content
|
|
||||||
(() => { this.props.onLeaveTopic(this.props.topic); }), // onConfirm
|
|
||||||
null, // "OK"
|
|
||||||
true, // Show Reject button
|
|
||||||
null // "Cancel"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleBlock(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const {formatMessage} = this.props.intl;
|
|
||||||
this.props.onShowAlert(
|
|
||||||
formatMessage(messages.block_contact), // title
|
|
||||||
formatMessage(messages.block_contact_warning), // content
|
|
||||||
(() => { this.props.onBlockTopic(this.props.topic); }), // onConfirm
|
|
||||||
null, // "OK"
|
|
||||||
true, // Show Reject button
|
|
||||||
null // "Cancel"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleReport(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const {formatMessage} = this.props.intl;
|
|
||||||
this.props.onShowAlert(
|
|
||||||
formatMessage(messages.report_chat), // title
|
|
||||||
formatMessage(messages.report_chat_warning), // content
|
|
||||||
(() => { this.props.onReportTopic(this.props.topic); }), // onConfirm
|
|
||||||
null, // "OK"
|
|
||||||
true, // Show Reject button
|
|
||||||
null // "Cancel"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMemberSelected(uid) {
|
handleMemberSelected(uid) {
|
||||||
this.setState({selectedContact: uid});
|
this.setState({selectedContact: uid});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMoreInfo(open) {
|
handleBackNavigate() {
|
||||||
this.setState({moreInfoExpanded: open});
|
const args = (this.props.panel || 'info').split('/');
|
||||||
}
|
if (args[0] == 'info') {
|
||||||
|
this.props.onNavigate(null);
|
||||||
handleTagsUpdated(tags) {
|
} else if (args[0] == 'perm') {
|
||||||
if (!arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
|
if (args[1] == 'user') {
|
||||||
this.props.onTopicTagsUpdate(this.props.topic, tags);
|
this.props.onNavigate('info');
|
||||||
|
} else {
|
||||||
|
this.props.onNavigate('security');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.props.onNavigate('info');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleContextMenu(params) {
|
handleContextMenu(params) {
|
||||||
const {formatMessage} = this.props.intl;
|
const {formatMessage} = this.props.intl;
|
||||||
const instance = this;
|
|
||||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||||
if (!topic) {
|
if (!topic) {
|
||||||
return;
|
return;
|
||||||
@@ -524,14 +413,19 @@ class InfoView extends React.Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMe = this.props.tinode.isMe(params.topicName);
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{title: formatMessage(messages.edit_permissions), handler: function() {
|
{title: formatMessage(messages.edit_permissions), handler: () => {
|
||||||
instance.handleLaunchPermissionsEditor('user', params.topicName);
|
this.handleLaunchPermissionsEditor(isMe ? 'want' : 'user', params.topicName);
|
||||||
}},
|
}}
|
||||||
'member_delete',
|
|
||||||
user.acs.isMuted() ? 'member_unmute' : 'member_mute',
|
|
||||||
user.acs.isJoiner() ? 'member_block' : 'member_unblock'
|
|
||||||
];
|
];
|
||||||
|
if (!isMe) {
|
||||||
|
menuItems.push('member_delete');
|
||||||
|
}
|
||||||
|
menuItems.push(user.acs.isMuted() ? 'member_unmute' : 'member_mute');
|
||||||
|
if (!isMe) {
|
||||||
|
menuItems.push(user.acs.isJoiner() ? 'member_block' : 'member_unblock');
|
||||||
|
}
|
||||||
this.props.showContextMenu({
|
this.props.showContextMenu({
|
||||||
topicName: this.props.topic,
|
topicName: this.props.topic,
|
||||||
x: params.x,
|
x: params.x,
|
||||||
@@ -540,16 +434,20 @@ class InfoView extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const args = (this.props.panel || 'info').split('/');
|
||||||
|
const view = args[0];
|
||||||
|
args.shift();
|
||||||
|
|
||||||
const {formatMessage} = this.props.intl;
|
const {formatMessage} = this.props.intl;
|
||||||
|
const panelTitle = formatMessage((view == 'perm' ? messages['perm_' + args[0]] : messages[view])
|
||||||
|
|| messages['info']);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="info-view">
|
<div id="info-view">
|
||||||
<div className="caption-panel" id="info-caption-panel">
|
<div className="caption-panel" id="info-caption-panel">
|
||||||
<div className="panel-title" id="info-title">
|
<div className="panel-title" id="info-title">{panelTitle}</div>
|
||||||
<FormattedMessage id="title_info" defaultMessage="Info" description="Title for InfoView" />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<MenuCancel onCancel={this.props.onCancel} />
|
<MenuCancel onCancel={this.handleBackNavigate} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{this.props.displayMobile ?
|
{this.props.displayMobile ?
|
||||||
@@ -557,18 +455,20 @@ class InfoView extends React.Component {
|
|||||||
level={this.props.errorLevel}
|
level={this.props.errorLevel}
|
||||||
text={this.props.errorText}
|
text={this.props.errorText}
|
||||||
onClearError={this.props.onError} /> : null}
|
onClearError={this.props.onError} /> : null}
|
||||||
{this.state.showMemberPanel ?
|
{view == 'members' ?
|
||||||
<GroupManager
|
<GroupManager
|
||||||
|
tinode={this.props.tinode}
|
||||||
members={this.state.contactList}
|
members={this.state.contactList}
|
||||||
requiredMember={this.props.myUserId}
|
requiredMember={this.props.myUserId}
|
||||||
keepInitialMembers={!this.state.admin && !this.state.owner}
|
keepInitialMembers={!this.state.admin && !this.state.owner}
|
||||||
myUserId={this.props.myUserId}
|
myUserId={this.props.myUserId}
|
||||||
contacts={this.props.searchableContacts}
|
contacts={this.props.searchableContacts}
|
||||||
onCancel={this.handleHideAddMembers}
|
onCancel={this.handleBackNavigate}
|
||||||
onSubmit={this.handleMemberUpdateRequest} />
|
onSubmit={this.handleMemberUpdateRequest} />
|
||||||
:
|
:
|
||||||
this.state.showPermissionEditorFor ?
|
view == 'perm' && args.length > 0 ?
|
||||||
<PermissionsEditor
|
<PermissionsEditor
|
||||||
|
tinode={this.props.tinode}
|
||||||
mode={this.state.editedPermissions}
|
mode={this.state.editedPermissions}
|
||||||
compare={this.state.immutablePermissions}
|
compare={this.state.immutablePermissions}
|
||||||
skip={this.state.editedPermissionsSkipped}
|
skip={this.state.editedPermissionsSkipped}
|
||||||
@@ -577,226 +477,153 @@ class InfoView extends React.Component {
|
|||||||
userTitle={this.state.userPermissionsTitle}
|
userTitle={this.state.userPermissionsTitle}
|
||||||
item={this.state.userPermissionsEdited}
|
item={this.state.userPermissionsEdited}
|
||||||
userAvatar={this.state.userPermissionsAvatar}
|
userAvatar={this.state.userPermissionsAvatar}
|
||||||
onSubmit={this.handlePermissionsChanged}
|
onSubmit={(mode) => this.handlePermissionsChanged(args[0], mode)}
|
||||||
onCancel={this.handleHidePermissionsEditor}
|
onCancel={this.handleBackNavigate} />
|
||||||
/>
|
:
|
||||||
|
view == 'general' ?
|
||||||
|
<TopicCommon
|
||||||
|
tinode={this.props.tinode}
|
||||||
|
topic={this.props.topic}
|
||||||
|
onCredAdd={this.props.onCredAdd}
|
||||||
|
onTopicTagsUpdateRequest={this.props.onTopicTagsUpdateRequest}
|
||||||
|
onCredConfirm={this.props.onCredConfirm}
|
||||||
|
onCredDelete={this.props.onCredDelete}
|
||||||
|
onUpdateTopicDesc={this.props.onTopicDescUpdateRequest}
|
||||||
|
onError={this.props.onError} />
|
||||||
|
:
|
||||||
|
view == 'security' ?
|
||||||
|
<TopicSecurity
|
||||||
|
topic={this.props.topic}
|
||||||
|
owner={this.state.owner}
|
||||||
|
admin={this.state.admin}
|
||||||
|
sharer={this.state.sharer}
|
||||||
|
deleter={this.state.deleter}
|
||||||
|
muted={this.state.muted}
|
||||||
|
|
||||||
|
groupTopic={this.state.groupTopic}
|
||||||
|
channel={this.state.channel}
|
||||||
|
access={this.state.access}
|
||||||
|
modeGiven={this.state.modeGiven}
|
||||||
|
modeWant={this.state.modeWant}
|
||||||
|
modeGiven2={this.state.modeGiven2}
|
||||||
|
modeWant2={this.state.modeWant2}
|
||||||
|
auth={this.state.auth}
|
||||||
|
anon={this.state.anon}
|
||||||
|
|
||||||
|
onShowAlert={this.props.onShowAlert}
|
||||||
|
onDeleteMessages={this.props.onDeleteMessages}
|
||||||
|
onLeaveTopic={this.props.onLeaveTopic}
|
||||||
|
onBlockTopic={this.props.onBlockTopic}
|
||||||
|
onReportTopic={this.props.onReportTopic}
|
||||||
|
onLaunchPermissionsEditor={this.handleLaunchPermissionsEditor}
|
||||||
|
onNavigate={this.props.onNavigate} />
|
||||||
:
|
:
|
||||||
<div id="info-view-content" className="scrollable-panel">
|
<div id="info-view-content" className="scrollable-panel">
|
||||||
<div className="panel-form-row">
|
<div className="panel-form-column">
|
||||||
<div className="panel-form-column">
|
<a href="#" className="flat-button float-right" onClick={(e) => {e.preventDefault(); this.props.onNavigate('general');}}>
|
||||||
|
<i className="material-icons">edit</i>
|
||||||
|
<FormattedMessage id="button_edit" defaultMessage="Edit" description="Call to action [Edit]" />
|
||||||
|
</a>
|
||||||
|
<center>
|
||||||
|
<AvatarUpload
|
||||||
|
tinode={this.props.tinode}
|
||||||
|
avatar={this.state.avatar}
|
||||||
|
readOnly={true}
|
||||||
|
uid={this.props.topic}
|
||||||
|
title={this.state.fullName} />
|
||||||
|
</center>
|
||||||
|
<div className="group">
|
||||||
<div><label className="small">
|
<div><label className="small">
|
||||||
<FormattedMessage id="label_topic_name" defaultMessage="Name"
|
<FormattedMessage id="label_topic_name" defaultMessage="Name"
|
||||||
description="Label for editing topic name" />
|
description="Label for editing topic name" />
|
||||||
</label></div>
|
</label></div>
|
||||||
<div><InPlaceEdit
|
<div className="large ellipsized">{this.state.fullName}
|
||||||
placeholder={this.state.groupTopic ? "Group name" : <i>Unknown</i>}
|
{this.state.channel ? <img src="/img/channel.png" className="channel" alt="channel" /> : null}</div>
|
||||||
readOnly={!this.state.owner}
|
</div>
|
||||||
value={this.state.fullName}
|
{this.state.private ?
|
||||||
required={true}
|
<div className="group">
|
||||||
onFinished={this.handleFullNameUpdate} /></div>
|
<div><label className="small">
|
||||||
<div>
|
|
||||||
<label className="small">
|
|
||||||
<FormattedMessage id="label_private" defaultMessage="Private comment"
|
<FormattedMessage id="label_private" defaultMessage="Private comment"
|
||||||
description="Label for editing 'private'" />
|
description="Label for editing 'private'" />
|
||||||
</label>
|
</label></div>
|
||||||
</div>
|
<div className="large ellipsized">{this.state.private}</div>
|
||||||
<div>
|
|
||||||
<FormattedMessage id="private_editing_placeholder"
|
|
||||||
defaultMessage="Visible to you only"
|
|
||||||
description="Placeholder for editing 'private'">{
|
|
||||||
(private_placeholder) => <InPlaceEdit
|
|
||||||
placeholder={private_placeholder}
|
|
||||||
value={this.state.private}
|
|
||||||
onFinished={this.handlePrivateUpdate} />
|
|
||||||
}</FormattedMessage>
|
|
||||||
</div>
|
</div>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
<div className="group">
|
||||||
|
<label className="small"><FormattedMessage id="label_user_id" defaultMessage="ID:"
|
||||||
|
description="Label for user address (ID)" /></label>
|
||||||
|
<tt>{this.state.address}</tt>
|
||||||
</div>
|
</div>
|
||||||
<AvatarUpload
|
<div className="group">
|
||||||
avatar={this.state.avatar}
|
<BadgeList trustedBadges={this.state.trustedBadges} />
|
||||||
readOnly={!this.state.owner}
|
</div>
|
||||||
uid={this.props.topic}
|
{this.state.description ?
|
||||||
title={this.state.fullName}
|
<div className="group">
|
||||||
onImageChanged={this.handleImageChanged}
|
<label className="small">
|
||||||
onError={this.props.onError} />
|
<FormattedMessage id="label_description" defaultMessage="Description"
|
||||||
|
description="Label for editing topic description" />
|
||||||
|
</label>
|
||||||
|
<div>{this.state.description}</div>
|
||||||
|
</div> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="hr" />
|
<div className="hr" />
|
||||||
<div className="panel-form-column">
|
<div className="panel-form-row">
|
||||||
|
<label>
|
||||||
|
<FormattedMessage id="label_muting_topic" defaultMessage="Muted:"
|
||||||
|
description="Label for Muting/unmuting the topic" />
|
||||||
|
</label>
|
||||||
|
<CheckBox name="P" checked={this.state.muted} onChange={this.handleMuted} />
|
||||||
|
</div>
|
||||||
|
{this.state.archived ?
|
||||||
<div className="panel-form-row">
|
<div className="panel-form-row">
|
||||||
<label>
|
<label>
|
||||||
<FormattedMessage id="label_muting_topic" defaultMessage="Muted:"
|
<FormattedMessage id="label_unarchive_topic" defaultMessage="Archived:"
|
||||||
description="Label for Muting/unmuting the topic" />
|
description="Label for unarchiving the topic" />
|
||||||
</label>
|
</label>
|
||||||
<CheckBox name="P" checked={this.state.muted}
|
<CheckBox name="archived" checked={true} onChange={this.handleUnarchive} />
|
||||||
onChange={this.handleMuted} />
|
|
||||||
</div>
|
</div>
|
||||||
<FormattedMessage id="action_more" defaultMessage="More"
|
|
||||||
description="Action for showing more content">{
|
|
||||||
(more) => <MoreButton
|
|
||||||
title={more}
|
|
||||||
open={this.state.moreInfoExpanded}
|
|
||||||
onToggle={this.handleMoreInfo} />
|
|
||||||
}</FormattedMessage>
|
|
||||||
{this.state.moreInfoExpanded ?
|
|
||||||
<div className="panel-form-column">
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<label><FormattedMessage id="label_user_id" defaultMessage="ID:"
|
|
||||||
description="Label for user address (ID)" /></label>
|
|
||||||
<tt>{this.state.address}</tt>
|
|
||||||
</div>
|
|
||||||
{this.state.groupTopic ?
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<label>
|
|
||||||
<FormattedMessage id="label_your_permissions" defaultMessage="Your permissions:"
|
|
||||||
description="Label for current user permissions" />
|
|
||||||
</label>
|
|
||||||
<tt className="clickable"
|
|
||||||
onClick={this.handleLaunchPermissionsEditor.bind(this, 'want')}>
|
|
||||||
{this.state.access}
|
|
||||||
</tt>
|
|
||||||
</div>
|
|
||||||
:
|
|
||||||
<div>
|
|
||||||
<div>
|
|
||||||
<label className="small">
|
|
||||||
<FormattedMessage id="label_permissions" defaultMessage="Permissions:"
|
|
||||||
description="Section title" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="quoted">
|
|
||||||
<div>
|
|
||||||
<FormattedMessage id="label_you" defaultMessage="You:"
|
|
||||||
description="Label for the current user" /> <tt className="clickable"
|
|
||||||
onClick={this.handleLaunchPermissionsEditor.bind(this, 'want')}>
|
|
||||||
{this.state.access}
|
|
||||||
</tt></div>
|
|
||||||
<div>{this.state.fullName ? this.state.fullName : formatMessage(messages.other_user)}:
|
|
||||||
<tt className="clickable" onClick={this.handleLaunchPermissionsEditor.bind(this, 'given')}>
|
|
||||||
{this.state.modeGiven2}
|
|
||||||
</tt>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
{this.state.sharer && (this.state.auth || this.state.anon) ?
|
|
||||||
<div>
|
|
||||||
<div>
|
|
||||||
<label className="small">
|
|
||||||
<FormattedMessage id="label_default_access" defaultMessage="Default access mode:"
|
|
||||||
description="Section title" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="quoted">
|
|
||||||
<div>Auth: {this.state.admin ?
|
|
||||||
<tt className="clickable"
|
|
||||||
onClick={this.handleLaunchPermissionsEditor.bind(this, 'auth')}>
|
|
||||||
{this.state.auth}
|
|
||||||
</tt>
|
|
||||||
:
|
|
||||||
<tt>{this.state.auth}</tt>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div>Anon: {this.state.admin ?
|
|
||||||
<tt className="clickable"
|
|
||||||
onClick={this.handleLaunchPermissionsEditor.bind(this, 'anon')}>
|
|
||||||
{this.state.anon}
|
|
||||||
</tt>
|
|
||||||
:
|
|
||||||
<tt>{this.state.anon}</tt>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
:
|
|
||||||
null
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
:
|
|
||||||
null
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div className="hr" />
|
|
||||||
{this.state.owner ?
|
|
||||||
<>
|
|
||||||
<FormattedMessage id="title_tag_manager" defaultMessage="Tags (user discovery)"
|
|
||||||
description="Section title for TagManager">{
|
|
||||||
(tags) => <TagManager
|
|
||||||
title={tags}
|
|
||||||
tags={this.state.tags}
|
|
||||||
activated={false}
|
|
||||||
tinode={this.props.tinode}
|
|
||||||
onSubmit={this.handleTagsUpdated} />
|
|
||||||
}</FormattedMessage>
|
|
||||||
<div className="hr" />
|
|
||||||
</>
|
|
||||||
:
|
:
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
<div className="panel-form-column">
|
<div className="hr" />
|
||||||
{!this.state.channel ?
|
<div className="panel-form-row">
|
||||||
<a href="#" className="flat-button" onClick={this.handleDeleteMessages}>
|
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onNavigate('security');}}>
|
||||||
<i className="material-icons">delete_outline</i> {
|
<i className="material-icons">security</i> <FormattedMessage id="button_security"
|
||||||
formatMessage(this.state.deleter ? messages.delete_messages : messages.clear_messages)
|
defaultMessage="Security" description="Navigaton button for security panel." />
|
||||||
}
|
</a>
|
||||||
</a>
|
|
||||||
:
|
|
||||||
null
|
|
||||||
}
|
|
||||||
{this.state.owner ?
|
|
||||||
<a href="#" className="danger flat-button" onClick={this.handleDeleteTopic}>
|
|
||||||
<i className="material-icons">delete</i> {formatMessage(messages.topic_delete)}
|
|
||||||
</a>
|
|
||||||
:
|
|
||||||
<a href="#" className="danger flat-button" onClick={this.handleLeave}>
|
|
||||||
<i className="material-icons">exit_to_app</i> {formatMessage(messages.leave_chat)}
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
{!this.state.groupTopic ?
|
|
||||||
<a href="#" className="danger flat-button" onClick={this.handleBlock}>
|
|
||||||
<i className="material-icons">block</i> {formatMessage(messages.block_contact)}
|
|
||||||
</a>
|
|
||||||
:
|
|
||||||
null
|
|
||||||
}
|
|
||||||
{!this.state.owner ?
|
|
||||||
<a href="#" className="danger flat-button" onClick={this.handleReport}>
|
|
||||||
<i className="material-icons">report</i> {formatMessage(messages.report_chat)}
|
|
||||||
</a>
|
|
||||||
:
|
|
||||||
null
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
{this.state.groupTopic && this.state.sharer ?
|
{this.state.groupTopic && this.state.sharer ?
|
||||||
<>
|
<>
|
||||||
<div className="hr" />
|
<div className="hr" />
|
||||||
<div className="panel-form-column">
|
<div className="panel-form-row">
|
||||||
<div className="panel-form-row">
|
<label className="small">
|
||||||
<label className="small">
|
<FormattedMessage id="label_group_members" defaultMessage="Group members:"
|
||||||
<FormattedMessage id="label_group_members" defaultMessage="Group members:"
|
description="Section title or label" />
|
||||||
description="Section title or label" />
|
</label>
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<a href="#" className="flat-button" onClick={this.handleShowAddMembers}>
|
|
||||||
<i className="material-icons">person_add</i> <FormattedMessage id="button_add_members"
|
|
||||||
defaultMessage="Add members" description="Flat button [Add members] (to topic)" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<FormattedMessage id="group_has_no_members" defaultMessage="No members"
|
|
||||||
description="Shown in place of group members">{
|
|
||||||
(no_members) => <ContactList
|
|
||||||
contacts={this.state.contactList}
|
|
||||||
myUserId={this.props.myUserId}
|
|
||||||
emptyListMessage={no_members}
|
|
||||||
topicSelected={this.state.selectedContact}
|
|
||||||
showOnline={false}
|
|
||||||
showUnread={false}
|
|
||||||
showMode={true}
|
|
||||||
noScroll={true}
|
|
||||||
onTopicSelected={this.handleMemberSelected}
|
|
||||||
showContextMenu={this.state.admin ? this.handleContextMenu : false}
|
|
||||||
/>
|
|
||||||
}</FormattedMessage>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="panel-form-row">
|
||||||
|
<a href="#" className="flat-button" onClick={this.handleShowAddMembers}>
|
||||||
|
<i className="material-icons">person_add</i> <FormattedMessage id="button_add_members"
|
||||||
|
defaultMessage="Add members" description="Flat button [Add members] (to topic)" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<FormattedMessage id="group_has_no_members" defaultMessage="No members"
|
||||||
|
description="Shown in place of group members">{
|
||||||
|
(no_members) => <ContactList
|
||||||
|
tinode={this.props.tinode}
|
||||||
|
contacts={this.state.contactList}
|
||||||
|
myUserId={this.props.myUserId}
|
||||||
|
emptyListMessage={no_members}
|
||||||
|
topicSelected={this.state.selectedContact}
|
||||||
|
showOnline={false}
|
||||||
|
showUnread={false}
|
||||||
|
showMode={true}
|
||||||
|
noScroll={true}
|
||||||
|
onTopicSelected={this.handleMemberSelected}
|
||||||
|
showContextMenu={this.state.admin ? this.handleContextMenu : false}
|
||||||
|
/>
|
||||||
|
}</FormattedMessage>
|
||||||
</>
|
</>
|
||||||
:
|
:
|
||||||
null
|
null
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Tinode from 'tinode-sdk';
|
|||||||
const Drafty = Tinode.Drafty;
|
const Drafty = Tinode.Drafty;
|
||||||
|
|
||||||
import ChatMessage from '../widgets/chat-message.jsx';
|
import ChatMessage from '../widgets/chat-message.jsx';
|
||||||
|
import ContactBadges from '../widgets/contact-badges.jsx';
|
||||||
import DocPreview from '../widgets/doc-preview.jsx';
|
import DocPreview from '../widgets/doc-preview.jsx';
|
||||||
import ErrorPanel from '../widgets/error-panel.jsx';
|
import ErrorPanel from '../widgets/error-panel.jsx';
|
||||||
import GroupSubs from '../widgets/group-subs.jsx';
|
import GroupSubs from '../widgets/group-subs.jsx';
|
||||||
@@ -15,12 +16,14 @@ import LoadSpinner from '../widgets/load-spinner.jsx';
|
|||||||
import LogoView from './logo-view.jsx';
|
import LogoView from './logo-view.jsx';
|
||||||
import SendMessage from '../widgets/send-message.jsx';
|
import SendMessage from '../widgets/send-message.jsx';
|
||||||
|
|
||||||
import { DEFAULT_P2P_ACCESS_MODE, IMAGE_PREVIEW_DIM, KEYPRESS_DELAY, MESSAGES_PAGE,
|
import { DEFAULT_P2P_ACCESS_MODE, IMAGE_PREVIEW_DIM, IMAGE_THUMBNAIL_DIM, KEYPRESS_DELAY,
|
||||||
MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE, READ_DELAY } from '../config.js';
|
MESSAGES_PAGE, MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE,
|
||||||
import { SUPPORTED_IMAGE_FORMATS, blobToBase64, filePasted, fileToBase64,
|
READ_DELAY, QUOTED_REPLY_LENGTH } from '../config.js';
|
||||||
imageScaled, makeImageDataUrl } from '../lib/blob-helpers.js';
|
import { SUPPORTED_IMAGE_FORMATS, blobToBase64, base64ToBlob, filePasted, fileToBase64,
|
||||||
|
imageScaled, makeImageUrl } from '../lib/blob-helpers.js';
|
||||||
import HashNavigation from '../lib/navigation.js';
|
import HashNavigation from '../lib/navigation.js';
|
||||||
import { bytesToHumanSize, shortDateFormat } from '../lib/strformat.js';
|
import { bytesToHumanSize, shortDateFormat, letterTileColorId } from '../lib/strformat.js';
|
||||||
|
import { sanitizeImageUrl } from '../lib/utils.js';
|
||||||
|
|
||||||
// Run timer with this frequency (ms) for checking notification queue.
|
// Run timer with this frequency (ms) for checking notification queue.
|
||||||
const NOTIFICATION_EXEC_INTERVAL = 300;
|
const NOTIFICATION_EXEC_INTERVAL = 300;
|
||||||
@@ -50,7 +53,12 @@ const messages = defineMessages({
|
|||||||
id: 'file_attachment_too_large',
|
id: 'file_attachment_too_large',
|
||||||
defaultMessage: 'The file size {size} exceeds the {limit} limit.',
|
defaultMessage: 'The file size {size} exceeds the {limit} limit.',
|
||||||
description: 'Error message when attachment is too large'
|
description: 'Error message when attachment is too large'
|
||||||
}
|
},
|
||||||
|
invalid_content: {
|
||||||
|
id: 'invalid_content',
|
||||||
|
defaultMessage: 'invalid content',
|
||||||
|
description: 'Shown when the message is unreadable'
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Checks if the access permissions are granted but not yet accepted.
|
// Checks if the access permissions are granted but not yet accepted.
|
||||||
@@ -77,9 +85,12 @@ class MessagesView extends React.Component {
|
|||||||
this.state = MessagesView.getDerivedStateFromProps(props, {});
|
this.state = MessagesView.getDerivedStateFromProps(props, {});
|
||||||
|
|
||||||
this.leave = this.leave.bind(this);
|
this.leave = this.leave.bind(this);
|
||||||
|
this.sendMessage = this.sendMessage.bind(this);
|
||||||
|
this.retrySend = this.retrySend.bind(this);
|
||||||
this.sendImageAttachment = this.sendImageAttachment.bind(this);
|
this.sendImageAttachment = this.sendImageAttachment.bind(this);
|
||||||
this.sendFileAttachment = this.sendFileAttachment.bind(this);
|
this.sendFileAttachment = this.sendFileAttachment.bind(this);
|
||||||
this.sendKeyPress = this.sendKeyPress.bind(this);
|
this.sendKeyPress = this.sendKeyPress.bind(this);
|
||||||
|
this.subscribe = this.subscribe.bind(this);
|
||||||
this.handleScrollReference = this.handleScrollReference.bind(this);
|
this.handleScrollReference = this.handleScrollReference.bind(this);
|
||||||
this.handleScrollEvent = this.handleScrollEvent.bind(this);
|
this.handleScrollEvent = this.handleScrollEvent.bind(this);
|
||||||
this.handleDescChange = this.handleDescChange.bind(this);
|
this.handleDescChange = this.handleDescChange.bind(this);
|
||||||
@@ -91,7 +102,7 @@ class MessagesView extends React.Component {
|
|||||||
this.handleClosePreview = this.handleClosePreview.bind(this);
|
this.handleClosePreview = this.handleClosePreview.bind(this);
|
||||||
this.handleFormResponse = this.handleFormResponse.bind(this);
|
this.handleFormResponse = this.handleFormResponse.bind(this);
|
||||||
this.handleContextClick = this.handleContextClick.bind(this);
|
this.handleContextClick = this.handleContextClick.bind(this);
|
||||||
this.handleShowContextMenuMessage = this.handleShowContextMenuMessage.bind(this);
|
this.handleShowMessageContextMenu = this.handleShowMessageContextMenu.bind(this);
|
||||||
this.handleNewChatAcceptance = this.handleNewChatAcceptance.bind(this);
|
this.handleNewChatAcceptance = this.handleNewChatAcceptance.bind(this);
|
||||||
this.handleEnablePeer = this.handleEnablePeer.bind(this);
|
this.handleEnablePeer = this.handleEnablePeer.bind(this);
|
||||||
this.handleAttachFile = this.handleAttachFile.bind(this);
|
this.handleAttachFile = this.handleAttachFile.bind(this);
|
||||||
@@ -100,12 +111,27 @@ class MessagesView extends React.Component {
|
|||||||
this.postReadNotification = this.postReadNotification.bind(this);
|
this.postReadNotification = this.postReadNotification.bind(this);
|
||||||
this.clearNotificationQueue = this.clearNotificationQueue.bind(this);
|
this.clearNotificationQueue = this.clearNotificationQueue.bind(this);
|
||||||
|
|
||||||
|
this.handlePickReply = this.handlePickReply.bind(this);
|
||||||
|
this.handleCancelReply = this.handleCancelReply.bind(this);
|
||||||
|
this.handleQuoteClick = this.handleQuoteClick.bind(this);
|
||||||
|
|
||||||
|
this.chatMessageRefs = {};
|
||||||
|
this.getOrCreateMessageRef = this.getOrCreateMessageRef.bind(this);
|
||||||
|
|
||||||
this.readNotificationQueue = [];
|
this.readNotificationQueue = [];
|
||||||
this.readNotificationTimer = null;
|
this.readNotificationTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getOrCreateMessageRef(seqId) {
|
||||||
|
if (this.chatMessageRefs.hasOwnProperty(seqId)) {
|
||||||
|
return this.chatMessageRefs[seqId];
|
||||||
|
}
|
||||||
|
const ref = React.createRef();
|
||||||
|
this.chatMessageRefs[seqId] = ref;
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
// this.propsChange(this.props, this.state);
|
|
||||||
if (this.messagesScroller) {
|
if (this.messagesScroller) {
|
||||||
this.messagesScroller.addEventListener('scroll', this.handleScrollEvent);
|
this.messagesScroller.addEventListener('scroll', this.handleScrollEvent);
|
||||||
}
|
}
|
||||||
@@ -157,49 +183,8 @@ class MessagesView extends React.Component {
|
|||||||
this.postReadNotification(0);
|
this.postReadNotification(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (topic && !topic.isSubscribed() && this.props.ready &&
|
if ((this.state.topic != prevState.topic) || !prevProps.ready) {
|
||||||
((this.state.topic != prevState.topic) || !prevProps.ready)) {
|
this.subscribe(topic);
|
||||||
// Is this a new topic?
|
|
||||||
const newTopic = (this.props.newTopicParams && this.props.newTopicParams._topicName == this.props.topic);
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
if (this.state.isReader || newTopic) {
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
// And show "loading" spinner.
|
|
||||||
this.setState({ fetchingMessages: true });
|
|
||||||
}
|
|
||||||
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});
|
|
||||||
}
|
|
||||||
this.props.onNewTopicCreated(this.props.topic, ctrl.topic);
|
|
||||||
// If there are unsent messages, try sending them now.
|
|
||||||
topic.queuedMessages((pub) => {
|
|
||||||
if (!pub._sending && topic.isSubscribed()) {
|
|
||||||
topic.publishMessage(pub);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.log("Failed subscription to", this.state.topic);
|
|
||||||
this.props.onError(err.message, 'err');
|
|
||||||
const blankState = MessagesView.getDerivedStateFromProps({}, {});
|
|
||||||
blankState.title = this.props.intl.formatMessage(messages.not_found);
|
|
||||||
this.setState(blankState);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +199,9 @@ class MessagesView extends React.Component {
|
|||||||
topic: null,
|
topic: null,
|
||||||
title: '',
|
title: '',
|
||||||
avatar: null,
|
avatar: null,
|
||||||
|
isVerified: false,
|
||||||
|
isStaff: false,
|
||||||
|
isDangerous: false,
|
||||||
docPreview: null,
|
docPreview: null,
|
||||||
imagePreview: null,
|
imagePreview: null,
|
||||||
imagePostview: null,
|
imagePostview: null,
|
||||||
@@ -221,10 +209,23 @@ class MessagesView extends React.Component {
|
|||||||
scrollPosition: 0,
|
scrollPosition: 0,
|
||||||
fetchingMessages: false,
|
fetchingMessages: false,
|
||||||
peerMessagingDisabled: false,
|
peerMessagingDisabled: false,
|
||||||
channel: false
|
channel: false,
|
||||||
|
reply: null
|
||||||
};
|
};
|
||||||
} else if (nextProps.topic != prevState.topic) {
|
} else if (nextProps.topic != prevState.topic) {
|
||||||
const topic = nextProps.tinode.getTopic(nextProps.topic);
|
const topic = nextProps.tinode.getTopic(nextProps.topic);
|
||||||
|
|
||||||
|
let reply = null;
|
||||||
|
if (nextProps.forwardMessage) {
|
||||||
|
// We are forwarding a message. Show preview.
|
||||||
|
const preview = nextProps.forwardMessage.preview;
|
||||||
|
reply = {
|
||||||
|
content: preview,
|
||||||
|
forwarded: nextProps.forwardMessage.head.forwarded,
|
||||||
|
seq: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
nextState = {
|
nextState = {
|
||||||
topic: nextProps.topic,
|
topic: nextProps.topic,
|
||||||
docPreview: null,
|
docPreview: null,
|
||||||
@@ -232,7 +233,8 @@ class MessagesView extends React.Component {
|
|||||||
imagePostview: null,
|
imagePostview: null,
|
||||||
typingIndicator: false,
|
typingIndicator: false,
|
||||||
scrollPosition: 0,
|
scrollPosition: 0,
|
||||||
fetchingMessages: false
|
fetchingMessages: false,
|
||||||
|
reply: reply
|
||||||
};
|
};
|
||||||
|
|
||||||
if (topic) {
|
if (topic) {
|
||||||
@@ -254,7 +256,7 @@ class MessagesView extends React.Component {
|
|||||||
if (topic.public) {
|
if (topic.public) {
|
||||||
Object.assign(nextState, {
|
Object.assign(nextState, {
|
||||||
title: topic.public.fn,
|
title: topic.public.fn,
|
||||||
avatar: makeImageDataUrl(topic.public.photo)
|
avatar: makeImageUrl(topic.public.photo)
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
Object.assign(nextState, {
|
Object.assign(nextState, {
|
||||||
@@ -332,6 +334,55 @@ class MessagesView extends React.Component {
|
|||||||
return nextState;
|
return nextState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subscribe(topic) {
|
||||||
|
if (!topic || topic.isSubscribed() || !this.props.ready) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is this a new topic?
|
||||||
|
const newTopic = (this.props.newTopicParams && this.props.newTopicParams._topicName == this.props.topic);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
if (this.state.isReader || newTopic) {
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
// And show "loading" spinner.
|
||||||
|
this.setState({ fetchingMessages: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
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});
|
||||||
|
}
|
||||||
|
this.props.onNewTopicCreated(this.props.topic, ctrl.topic);
|
||||||
|
// If there are unsent messages, try sending them now.
|
||||||
|
topic.queuedMessages((pub) => {
|
||||||
|
if (!pub._sending && topic.isSubscribed()) {
|
||||||
|
this.retrySend(pub);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Failed subscription to", this.state.topic);
|
||||||
|
this.props.onError(err.message, 'err');
|
||||||
|
const blankState = MessagesView.getDerivedStateFromProps({}, {});
|
||||||
|
blankState.title = this.props.intl.formatMessage(messages.not_found);
|
||||||
|
this.setState(blankState);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
leave(oldTopicName) {
|
leave(oldTopicName) {
|
||||||
if (!oldTopicName || !this.props.tinode.isTopicCached(oldTopicName)) {
|
if (!oldTopicName || !this.props.tinode.isTopicCached(oldTopicName)) {
|
||||||
return;
|
return;
|
||||||
@@ -385,7 +436,7 @@ class MessagesView extends React.Component {
|
|||||||
if (desc.public) {
|
if (desc.public) {
|
||||||
this.setState({
|
this.setState({
|
||||||
title: desc.public.fn,
|
title: desc.public.fn,
|
||||||
avatar: makeImageDataUrl(desc.public.photo)
|
avatar: makeImageUrl(desc.public.photo)
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.setState({
|
this.setState({
|
||||||
@@ -500,7 +551,8 @@ class MessagesView extends React.Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({messageCount: topic.messageCount()});
|
clearTimeout(this.keyPressTimer)
|
||||||
|
this.setState({messageCount: topic.messageCount(), typingIndicator: false});
|
||||||
|
|
||||||
// Scroll to the bottom if the message is added to the end of the message list.
|
// Scroll to the bottom if the message is added to the end of the message list.
|
||||||
// TODO: This should be replaced by showing a "scroll to bottom" button.
|
// TODO: This should be replaced by showing a "scroll to bottom" button.
|
||||||
@@ -530,9 +582,8 @@ class MessagesView extends React.Component {
|
|||||||
switch (info.what) {
|
switch (info.what) {
|
||||||
case 'kp': {
|
case 'kp': {
|
||||||
clearTimeout(this.keyPressTimer);
|
clearTimeout(this.keyPressTimer);
|
||||||
var instance = this;
|
this.keyPressTimer = setTimeout(() => {
|
||||||
this.keyPressTimer = setTimeout(function() {
|
this.setState({typingIndicator: false});
|
||||||
instance.setState({typingIndicator: false});
|
|
||||||
}, KEYPRESS_DELAY + 1000);
|
}, KEYPRESS_DELAY + 1000);
|
||||||
if (!this.state.typingIndicator) {
|
if (!this.state.typingIndicator) {
|
||||||
this.setState({typingIndicator: true});
|
this.setState({typingIndicator: true});
|
||||||
@@ -545,7 +596,7 @@ class MessagesView extends React.Component {
|
|||||||
this.forceUpdate();
|
this.forceUpdate();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
console.log("Other change in topic: ", info.what);
|
console.info("Other change in topic: ", info.what);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,7 +613,7 @@ class MessagesView extends React.Component {
|
|||||||
|
|
||||||
handleFormResponse(action, text, data) {
|
handleFormResponse(action, text, data) {
|
||||||
if (action == 'pub') {
|
if (action == 'pub') {
|
||||||
this.props.sendMessage(Drafty.attachJSON(Drafty.parse(text), data));
|
this.sendMessage(Drafty.attachJSON(Drafty.parse(text), data));
|
||||||
} else if (action == 'url') {
|
} else if (action == 'url') {
|
||||||
const url = new URL(data.ref);
|
const url = new URL(data.ref);
|
||||||
const params = url.searchParams;
|
const params = url.searchParams;
|
||||||
@@ -581,7 +632,7 @@ class MessagesView extends React.Component {
|
|||||||
url.search = params;
|
url.search = params;
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
} else {
|
} else {
|
||||||
console.log("Unknown action in form", action);
|
console.info("Unknown action in form", action);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,7 +642,11 @@ class MessagesView extends React.Component {
|
|||||||
this.props.showContextMenu({ topicName: this.state.topic, y: e.pageY, x: e.pageX });
|
this.props.showContextMenu({ topicName: this.state.topic, y: e.pageY, x: e.pageX });
|
||||||
}
|
}
|
||||||
|
|
||||||
handleShowContextMenuMessage(params, messageSpecificMenuItems) {
|
handleShowMessageContextMenu(params, messageSpecificMenuItems) {
|
||||||
|
if (params.userFrom == 'chan') {
|
||||||
|
params.userFrom = this.state.topic;
|
||||||
|
params.userName = this.state.title;
|
||||||
|
}
|
||||||
params.topicName = this.state.topic;
|
params.topicName = this.state.topic;
|
||||||
const menuItems = messageSpecificMenuItems || [];
|
const menuItems = messageSpecificMenuItems || [];
|
||||||
const topic = this.props.tinode.getTopic(params.topicName);
|
const topic = this.props.tinode.getTopic(params.topicName);
|
||||||
@@ -623,6 +678,32 @@ class MessagesView extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendMessage sends the message with an optional subscription to topic first.
|
||||||
|
sendMessage(msg, uploadCompletionPromise, uploader) {
|
||||||
|
let head;
|
||||||
|
if (this.props.forwardMessage) {
|
||||||
|
// We are forwarding a message.
|
||||||
|
msg = this.props.forwardMessage.msg;
|
||||||
|
head = this.props.forwardMessage.head;
|
||||||
|
this.handleCancelReply();
|
||||||
|
} else if (this.state.reply && this.state.reply.content) {
|
||||||
|
// We are replying to a message in this topic.
|
||||||
|
head = {reply: '' + this.state.reply.seq};
|
||||||
|
// Turn it into Drafty so we can make a quoted Drafty object later.
|
||||||
|
if (typeof msg == 'string') {
|
||||||
|
msg = Drafty.parse(msg);
|
||||||
|
}
|
||||||
|
msg = Drafty.append(this.state.reply.content, msg);
|
||||||
|
this.handleCancelReply();
|
||||||
|
}
|
||||||
|
this.props.sendMessage(msg, uploadCompletionPromise, uploader, head);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry sending a message.
|
||||||
|
retrySend(pub) {
|
||||||
|
this.props.sendMessage(pub.content, undefined, undefined, pub.head);
|
||||||
|
}
|
||||||
|
|
||||||
// Send attachment as Drafty message:
|
// Send attachment as Drafty message:
|
||||||
// - if file is too large, upload it and send a s link.
|
// - if file is too large, upload it and send a s link.
|
||||||
// - if file is small enough, just send it in-band.
|
// - if file is small enough, just send it in-band.
|
||||||
@@ -634,10 +715,6 @@ class MessagesView extends React.Component {
|
|||||||
if (file.size > maxInbandAttachmentSize) {
|
if (file.size > maxInbandAttachmentSize) {
|
||||||
// Too large to send inband - uploading out of band and sending as a link.
|
// Too large to send inband - uploading out of band and sending as a link.
|
||||||
const uploader = this.props.tinode.getLargeFileHelper();
|
const uploader = this.props.tinode.getLargeFileHelper();
|
||||||
if (!uploader) {
|
|
||||||
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const uploadCompletionPromise = uploader.upload(file);
|
const uploadCompletionPromise = uploader.upload(file);
|
||||||
const msg = Drafty.attachFile(null, {
|
const msg = Drafty.attachFile(null, {
|
||||||
mime: file.type,
|
mime: file.type,
|
||||||
@@ -646,15 +723,12 @@ class MessagesView extends React.Component {
|
|||||||
urlPromise: uploadCompletionPromise
|
urlPromise: uploadCompletionPromise
|
||||||
});
|
});
|
||||||
// Pass data and the uploader to the TinodeWeb.
|
// Pass data and the uploader to the TinodeWeb.
|
||||||
this.props.sendMessage(msg, uploadCompletionPromise, uploader);
|
this.sendMessage(msg, uploadCompletionPromise, uploader);
|
||||||
} else {
|
} else {
|
||||||
// Small enough to send inband.
|
// Small enough to send inband.
|
||||||
fileToBase64(file,
|
fileToBase64(file)
|
||||||
(mime, bits, fname) => {
|
.then(b64 => this.sendMessage(Drafty.attachFile(null, {mime: b64.mime, data: b64.bits, filename: b64.name})))
|
||||||
this.props.sendMessage(Drafty.attachFile(null, {mime: mime, data: bits, filename: fname}));
|
.catch(err => this.props.onError(err));
|
||||||
},
|
|
||||||
this.props.onError
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -667,21 +741,23 @@ class MessagesView extends React.Component {
|
|||||||
this.props.onError(this.props.intl.formatMessage(messages.file_attachment_too_large,
|
this.props.onError(this.props.intl.formatMessage(messages.file_attachment_too_large,
|
||||||
{size: bytesToHumanSize(file.size), limit: bytesToHumanSize(maxExternAttachmentSize)}), 'err');
|
{size: bytesToHumanSize(file.size), limit: bytesToHumanSize(maxExternAttachmentSize)}), 'err');
|
||||||
} else {
|
} else {
|
||||||
this.setState({ docPreview: {
|
this.setState({
|
||||||
file: file,
|
docPreview: {
|
||||||
filename: file.name,
|
file: file,
|
||||||
size: file.size,
|
name: file.name,
|
||||||
type: file.type
|
size: file.size,
|
||||||
}});
|
type: file.type
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendImageAttachment sends the image bits inband as Drafty message.
|
// sendImageAttachment sends the image bits inband as Drafty message.
|
||||||
sendImageAttachment(caption, blob) {
|
sendImageAttachment(caption, blob) {
|
||||||
const mime = this.state.imagePreview.type;
|
const mime = this.state.imagePreview.mime;
|
||||||
const width = this.state.imagePreview.width;
|
const width = this.state.imagePreview.width;
|
||||||
const height = this.state.imagePreview.height;
|
const height = this.state.imagePreview.height;
|
||||||
const fname = this.state.imagePreview.filename;
|
const fname = this.state.imagePreview.name;
|
||||||
|
|
||||||
// Server-provided limit reduced for base64 encoding and overhead.
|
// Server-provided limit reduced for base64 encoding and overhead.
|
||||||
const maxInbandAttachmentSize = (this.props.tinode.getServerLimit('maxMessageSize',
|
const maxInbandAttachmentSize = (this.props.tinode.getServerLimit('maxMessageSize',
|
||||||
@@ -697,13 +773,13 @@ class MessagesView extends React.Component {
|
|||||||
const uploadCompletionPromise = uploader.upload(blob);
|
const uploadCompletionPromise = uploader.upload(blob);
|
||||||
|
|
||||||
// Make small preview to show while uploading.
|
// Make small preview to show while uploading.
|
||||||
imageScaled(blob, IMAGE_PREVIEW_DIM, IMAGE_PREVIEW_DIM, -1, false,
|
imageScaled(blob, IMAGE_PREVIEW_DIM, IMAGE_PREVIEW_DIM, -1, false)
|
||||||
(tinyMine, tinyBlob) => {
|
// Convert tiny image into base64 for serialization and previewing.
|
||||||
// Convert tiny image into base64 for serialization and previewing.
|
.then(scaled => blobToBase64(scaled.blob))
|
||||||
blobToBase64(tinyBlob, (blobMime, tinyBits64) => {
|
.then(b64 => {
|
||||||
let msg = Drafty.insertImage(null, 0, {
|
let msg = Drafty.insertImage(null, 0, {
|
||||||
mime: mime,
|
mime: mime,
|
||||||
_tempPreview: tinyBits64, // This preview will not be serialized.
|
_tempPreview: b64.bits, // This preview will not be serialized.
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
filename: fname,
|
filename: fname,
|
||||||
@@ -712,36 +788,33 @@ class MessagesView extends React.Component {
|
|||||||
});
|
});
|
||||||
if (caption) {
|
if (caption) {
|
||||||
msg = Drafty.appendLineBreak(msg);
|
msg = Drafty.appendLineBreak(msg);
|
||||||
msg = Drafty.append(msg, Drafty.init(caption));
|
msg = Drafty.append(msg, Drafty.parse(caption));
|
||||||
}
|
}
|
||||||
// Pass data and the uploader to the TinodeWeb.
|
// Pass data and the uploader to the TinodeWeb.
|
||||||
this.props.sendMessage(msg, uploadCompletionPromise, uploader);
|
this.sendMessage(msg, uploadCompletionPromise, uploader);
|
||||||
}
|
}).catch((err) => {
|
||||||
)},
|
|
||||||
// Failure
|
|
||||||
(err) => {
|
|
||||||
this.props.onError(err, 'err');
|
this.props.onError(err, 'err');
|
||||||
}
|
});
|
||||||
);
|
return;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload the image if it's too big to be send inband.
|
// Upload the image if it's too big to be send inband.
|
||||||
blobToBase64(blob, (blobMime, bits64) => {
|
blobToBase64(blob)
|
||||||
let msg = Drafty.insertImage(null, 0, {
|
.then(b64 => {
|
||||||
mime: blobMime,
|
let msg = Drafty.insertImage(null, 0, {
|
||||||
preview: bits64, // Serializable preview
|
mime: b64.mime,
|
||||||
width: width,
|
preview: b64.bits, // Serializable preview
|
||||||
height: height,
|
width: width,
|
||||||
filename: fname,
|
height: height,
|
||||||
size: blob.size
|
filename: fname,
|
||||||
|
size: blob.size
|
||||||
|
});
|
||||||
|
if (caption) {
|
||||||
|
msg = Drafty.appendLineBreak(msg);
|
||||||
|
msg = Drafty.append(msg, Drafty.parse(caption));
|
||||||
|
}
|
||||||
|
this.sendMessage(msg);
|
||||||
});
|
});
|
||||||
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 method is called when [Attach image] button is clicked.
|
||||||
@@ -749,24 +822,20 @@ class MessagesView extends React.Component {
|
|||||||
const maxExternAttachmentSize = this.props.tinode.getServerLimit('maxFileUploadSize', MAX_EXTERN_ATTACHMENT_SIZE);
|
const maxExternAttachmentSize = this.props.tinode.getServerLimit('maxFileUploadSize', MAX_EXTERN_ATTACHMENT_SIZE);
|
||||||
|
|
||||||
// Get image dimensions and size, optionally scale it down.
|
// Get image dimensions and size, optionally scale it down.
|
||||||
imageScaled(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, maxExternAttachmentSize, false,
|
imageScaled(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, maxExternAttachmentSize, false)
|
||||||
// Success
|
.then(scaled => {
|
||||||
(mime, blob, width, height, fname) => {
|
|
||||||
this.setState({imagePreview: {
|
this.setState({imagePreview: {
|
||||||
url: URL.createObjectURL(blob),
|
url: URL.createObjectURL(scaled.blob),
|
||||||
blob: blob,
|
blob: scaled.blob,
|
||||||
filename: fname,
|
name: scaled.name,
|
||||||
width: width,
|
width: scaled.width,
|
||||||
height: height,
|
height: scaled.height,
|
||||||
size: blob.size,
|
size: scaled.blob.size,
|
||||||
type: mime
|
mime: scaled.mime
|
||||||
}});
|
}});
|
||||||
},
|
}).catch(err => {
|
||||||
// Failure
|
|
||||||
(err) => {
|
|
||||||
this.props.onError(err, 'err');
|
this.props.onError(err, 'err');
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCancelUpload(seq, uploader) {
|
handleCancelUpload(seq, uploader) {
|
||||||
@@ -778,6 +847,49 @@ class MessagesView extends React.Component {
|
|||||||
uploader.cancel();
|
uploader.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handlePickReply(seq, content, forwarded, senderId, senderName) {
|
||||||
|
this.setState({reply: null});
|
||||||
|
|
||||||
|
if (!seq || !content) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
content = forwarded ?
|
||||||
|
Drafty.forwardedContent(content) :
|
||||||
|
typeof content == 'string' ? Drafty.init(content) : content;
|
||||||
|
if (Drafty.isValid(content)) {
|
||||||
|
content = Drafty.preview(content, QUOTED_REPLY_LENGTH, undefined, !forwarded);
|
||||||
|
} else {
|
||||||
|
// /!\ invalid content.
|
||||||
|
content = Drafty.append(Drafty.init('\u26A0 '),
|
||||||
|
Drafty.wrapInto(this.props.intl.formatMessage(messages.invalid_content), 'EM'));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
reply: {
|
||||||
|
content: Drafty.quote(senderName, senderId, content),
|
||||||
|
seq: seq
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.props.onCancelForwardMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCancelReply() {
|
||||||
|
this.setState({reply: null});
|
||||||
|
this.props.onCancelForwardMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleQuoteClick(replyToSeq) {
|
||||||
|
const ref = this.getOrCreateMessageRef(replyToSeq);
|
||||||
|
if (ref && ref.current) {
|
||||||
|
ref.current.scrollIntoView({block: "center", behavior: "smooth"});
|
||||||
|
ref.current.classList.add('flash');
|
||||||
|
setTimeout(() => { ref.current.classList.remove('flash') } , 1000);
|
||||||
|
} else {
|
||||||
|
console.error("Unresolved message ref", replyToSeq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {formatMessage} = this.props.intl;
|
const {formatMessage} = this.props.intl;
|
||||||
|
|
||||||
@@ -797,6 +909,9 @@ class MessagesView extends React.Component {
|
|||||||
component2 = (
|
component2 = (
|
||||||
<ImagePreview
|
<ImagePreview
|
||||||
content={this.state.imagePreview}
|
content={this.state.imagePreview}
|
||||||
|
tinode={this.props.tinode}
|
||||||
|
reply={this.state.reply}
|
||||||
|
onCancelReply={this.handleCancelReply}
|
||||||
onClose={this.handleClosePreview}
|
onClose={this.handleClosePreview}
|
||||||
onSendMessage={this.sendImageAttachment} />
|
onSendMessage={this.sendImageAttachment} />
|
||||||
);
|
);
|
||||||
@@ -812,6 +927,9 @@ class MessagesView extends React.Component {
|
|||||||
component2 = (
|
component2 = (
|
||||||
<DocPreview
|
<DocPreview
|
||||||
content={this.state.docPreview}
|
content={this.state.docPreview}
|
||||||
|
tinode={this.props.tinode}
|
||||||
|
reply={this.state.reply}
|
||||||
|
onCancelReply={this.handleCancelReply}
|
||||||
onClose={this.handleClosePreview}
|
onClose={this.handleClosePreview}
|
||||||
onSendMessage={this.sendFileAttachment} />
|
onSendMessage={this.sendFileAttachment} />
|
||||||
);
|
);
|
||||||
@@ -819,11 +937,23 @@ class MessagesView extends React.Component {
|
|||||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||||
const isChannel = topic.isChannelType();
|
const isChannel = topic.isChannelType();
|
||||||
const groupTopic = topic.isGroupType() && !isChannel;
|
const groupTopic = topic.isGroupType() && !isChannel;
|
||||||
|
const icon_badges = [];
|
||||||
|
if (topic.trusted) {
|
||||||
|
if (topic.trusted.verified) {
|
||||||
|
icon_badges.push({icon: 'verified', color: 'badge-inv'});
|
||||||
|
}
|
||||||
|
if (topic.trusted.staff) {
|
||||||
|
icon_badges.push({icon: 'staff', color: 'badge-inv'});
|
||||||
|
}
|
||||||
|
if (topic.trusted.danger) {
|
||||||
|
icon_badges.push({icon: 'dangerous', color: 'badge-inv'});
|
||||||
|
}
|
||||||
|
}
|
||||||
let messageNodes = [];
|
let messageNodes = [];
|
||||||
let previousFrom = null;
|
let previousFrom = null;
|
||||||
let chatBoxClass = null;
|
let chatBoxClass = null;
|
||||||
topic.messages((msg, prev, next, i) => {
|
topic.messages((msg, prev, next, i) => {
|
||||||
let nextFrom = next ? (next.from || null) : 'chan';
|
let nextFrom = next ? (next.from || 'chan') : null;
|
||||||
|
|
||||||
let sequence = 'single';
|
let sequence = 'single';
|
||||||
let thisFrom = msg.from || 'chan';
|
let thisFrom = msg.from || 'chan';
|
||||||
@@ -841,40 +971,50 @@ class MessagesView extends React.Component {
|
|||||||
const isReply = !(thisFrom == this.props.myUserId);
|
const isReply = !(thisFrom == this.props.myUserId);
|
||||||
const deliveryStatus = topic.msgStatus(msg, true);
|
const deliveryStatus = topic.msgStatus(msg, true);
|
||||||
|
|
||||||
let userName, userAvatar, userFrom;
|
let userFrom = thisFrom, userName, userAvatar;
|
||||||
if (groupTopic) {
|
const user = topic.userDesc(thisFrom);
|
||||||
const user = topic.userDesc(thisFrom);
|
if (user && user.public) {
|
||||||
if (user && user.public) {
|
userName = user.public.fn;
|
||||||
userName = user.public.fn;
|
userAvatar = makeImageUrl(user.public.photo);
|
||||||
userAvatar = makeImageDataUrl(user.public.photo);
|
}
|
||||||
}
|
chatBoxClass = groupTopic ? 'chat-box group' : 'chat-box';
|
||||||
userFrom = thisFrom;
|
|
||||||
chatBoxClass='chat-box group';
|
// Ref for this chat message.
|
||||||
} else {
|
const ref = this.getOrCreateMessageRef(msg.seq);
|
||||||
chatBoxClass='chat-box';
|
let replyToSeq = msg.head ? parseInt(msg.head.reply) : null;
|
||||||
|
if (!replyToSeq || isNaN(replyToSeq)) {
|
||||||
|
replyToSeq = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
messageNodes.push(
|
messageNodes.push(
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
tinode={this.props.tinode}
|
tinode={this.props.tinode}
|
||||||
content={msg.content}
|
content={msg.content}
|
||||||
|
forwarded={msg.head ? msg.head.forwarded : null}
|
||||||
deleted={msg.hi}
|
deleted={msg.hi}
|
||||||
mimeType={msg.head ? msg.head.mime : null}
|
mimeType={msg.head ? msg.head.mime : null}
|
||||||
timestamp={msg.ts}
|
timestamp={msg.ts}
|
||||||
response={isReply}
|
response={isReply}
|
||||||
seq={msg.seq}
|
seq={msg.seq}
|
||||||
|
isGroup={groupTopic}
|
||||||
|
isChan={this.state.channel}
|
||||||
userFrom={userFrom}
|
userFrom={userFrom}
|
||||||
userName={userName}
|
userName={userName}
|
||||||
userAvatar={userAvatar}
|
userAvatar={userAvatar}
|
||||||
sequence={sequence}
|
sequence={sequence}
|
||||||
received={deliveryStatus}
|
received={deliveryStatus}
|
||||||
uploader={msg._uploader}
|
uploader={msg._uploader}
|
||||||
viewportWidth={this.props.viewportWidth}
|
viewportWidth={this.props.viewportWidth} // Used by `formatter`.
|
||||||
showContextMenu={this.state.channel? false : this.handleShowContextMenuMessage}
|
showContextMenu={this.handleShowMessageContextMenu}
|
||||||
onImagePreview={this.handleImagePostview}
|
onImagePreview={this.handleImagePostview}
|
||||||
onFormResponse={this.handleFormResponse}
|
onFormResponse={this.handleFormResponse}
|
||||||
onError={this.props.onError}
|
onError={this.props.onError}
|
||||||
onCancelUpload={this.handleCancelUpload}
|
onCancelUpload={this.handleCancelUpload}
|
||||||
|
pickReply={this.handlePickReply}
|
||||||
|
replyToSeq={replyToSeq}
|
||||||
|
onQuoteClick={this.handleQuoteClick}
|
||||||
|
ref={ref}
|
||||||
|
userIsWriter={this.state.isWriter}
|
||||||
key={msg.seq} />
|
key={msg.seq} />
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -908,6 +1048,7 @@ class MessagesView extends React.Component {
|
|||||||
null}
|
null}
|
||||||
<div className="avatar-box">
|
<div className="avatar-box">
|
||||||
<LetterTile
|
<LetterTile
|
||||||
|
tinode={this.props.tinode}
|
||||||
avatar={avatar}
|
avatar={avatar}
|
||||||
topic={this.state.topic}
|
topic={this.state.topic}
|
||||||
title={this.state.title} />
|
title={this.state.title} />
|
||||||
@@ -918,11 +1059,12 @@ class MessagesView extends React.Component {
|
|||||||
this.state.title ||
|
this.state.title ||
|
||||||
<i><FormattedMessage id="unnamed_topic" defaultMessage="Unnamed"
|
<i><FormattedMessage id="unnamed_topic" defaultMessage="Unnamed"
|
||||||
description="Title shown when the topic has no name" /></i>
|
description="Title shown when the topic has no name" /></i>
|
||||||
}</div>
|
}<ContactBadges badges={icon_badges} /></div>
|
||||||
<div id="topic-last-seen">{lastSeen}</div>
|
<div id="topic-last-seen">{lastSeen}</div>
|
||||||
</div>
|
</div>
|
||||||
{groupTopic ?
|
{groupTopic ?
|
||||||
<GroupSubs
|
<GroupSubs
|
||||||
|
tinode={this.props.tinode}
|
||||||
subscribers={this.state.onlineSubs} /> :
|
subscribers={this.state.onlineSubs} /> :
|
||||||
<div id="topic-users" />
|
<div id="topic-users" />
|
||||||
}
|
}
|
||||||
@@ -968,12 +1110,17 @@ class MessagesView extends React.Component {
|
|||||||
<Invitation onAction={this.handleNewChatAcceptance} />
|
<Invitation onAction={this.handleNewChatAcceptance} />
|
||||||
:
|
:
|
||||||
<SendMessage
|
<SendMessage
|
||||||
|
tinode={this.props.tinode}
|
||||||
|
noInput={!!this.props.forwardMessage}
|
||||||
disabled={!this.state.isWriter}
|
disabled={!this.state.isWriter}
|
||||||
onSendMessage={this.props.sendMessage}
|
|
||||||
onKeyPress={this.sendKeyPress}
|
onKeyPress={this.sendKeyPress}
|
||||||
onAttachFile={this.handleAttachFile}
|
onSendMessage={this.sendMessage}
|
||||||
onAttachImage={this.handleAttachImage}
|
onAttachFile={this.props.forwardMessage ? null : this.handleAttachFile}
|
||||||
onError={this.props.onError} />}
|
onAttachImage={this.props.forwardMessage ? null : this.handleAttachImage}
|
||||||
|
onError={this.props.onError}
|
||||||
|
reply={this.state.reply}
|
||||||
|
onQuoteClick={this.handleQuoteClick}
|
||||||
|
onCancelReply={this.handleCancelReply} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ const messages = defineMessages({
|
|||||||
id: "search_no_results",
|
id: "search_no_results",
|
||||||
defaultMessage: "Search returned no results",
|
defaultMessage: "Search returned no results",
|
||||||
description: "Text shown in contacts view when query returned no results."
|
description: "Text shown in contacts view when query returned no results."
|
||||||
}
|
},
|
||||||
|
search_placeholder: {
|
||||||
|
id: "search_placeholder",
|
||||||
|
defaultMessage: "List like email:alice@example.com, tel:17025550003...",
|
||||||
|
description: "Placeholder in contacts search field"
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
class NewTopicView extends React.Component {
|
class NewTopicView extends React.Component {
|
||||||
@@ -56,16 +61,17 @@ class NewTopicView extends React.Component {
|
|||||||
this.setState({searchQuery: Tinode.isNullValue(query) ? null : query});
|
this.setState({searchQuery: Tinode.isNullValue(query) ? null : query});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleContactSelected(sel) {
|
handleContactSelected(uid) {
|
||||||
if (this.state.tabSelected === 'p2p') {
|
if (this.state.tabSelected == 'p2p') {
|
||||||
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
|
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
|
||||||
this.props.onCreateTopic(sel);
|
this.props.onCreateTopic(uid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleNewGroupSubmit(name, dataUrl, priv, tags, isChannel) {
|
handleNewGroupSubmit(name, description, dataUrl, priv, tags, isChannel) {
|
||||||
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
|
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
|
||||||
this.props.onCreateTopic(undefined, theCard(name, dataUrl), priv, tags, isChannel);
|
this.props.onCreateTopic(undefined,
|
||||||
|
{public: theCard(name, dataUrl, null, description), private: priv, tags: tags}, isChannel);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleGroupByID(topicName) {
|
handleGroupByID(topicName) {
|
||||||
@@ -77,6 +83,7 @@ class NewTopicView extends React.Component {
|
|||||||
const {formatMessage} = this.props.intl;
|
const {formatMessage} = this.props.intl;
|
||||||
const no_contacts_placeholder = formatMessage(this.state.searchQuery ?
|
const no_contacts_placeholder = formatMessage(this.state.searchQuery ?
|
||||||
messages.search_no_results : messages.search_for_contacts);
|
messages.search_no_results : messages.search_for_contacts);
|
||||||
|
const search_placeholder = formatMessage(messages.search_placeholder);
|
||||||
return (
|
return (
|
||||||
<div className="flex-column">
|
<div className="flex-column">
|
||||||
<ul className="tabbar">
|
<ul className="tabbar">
|
||||||
@@ -110,8 +117,10 @@ class NewTopicView extends React.Component {
|
|||||||
<div className="flex-column">
|
<div className="flex-column">
|
||||||
<SearchContacts
|
<SearchContacts
|
||||||
type="p2p"
|
type="p2p"
|
||||||
|
placeholder={search_placeholder}
|
||||||
onSearchContacts={this.handleSearchContacts} />
|
onSearchContacts={this.handleSearchContacts} />
|
||||||
<ContactList
|
<ContactList
|
||||||
|
tinode={this.props.tinode}
|
||||||
contacts={this.props.searchResults}
|
contacts={this.props.searchResults}
|
||||||
myUserId={this.props.myUserId}
|
myUserId={this.props.myUserId}
|
||||||
emptyListMessage={no_contacts_placeholder}
|
emptyListMessage={no_contacts_placeholder}
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { defineMessages, injectIntl } from 'react-intl';
|
|||||||
import ErrorPanel from '../widgets/error-panel.jsx';
|
import ErrorPanel from '../widgets/error-panel.jsx';
|
||||||
import LoadSpinner from '../widgets/load-spinner.jsx';
|
import LoadSpinner from '../widgets/load-spinner.jsx';
|
||||||
import SideNavbar from '../widgets/side-navbar.jsx';
|
import SideNavbar from '../widgets/side-navbar.jsx';
|
||||||
|
import TopicCommon from '../widgets/topic-common.jsx';
|
||||||
|
|
||||||
|
import AccountSettingsView from './account-settings-view.jsx';
|
||||||
import ContactsView from './contacts-view.jsx';
|
import ContactsView from './contacts-view.jsx';
|
||||||
import CreateAccountView from './create-account-view.jsx';
|
import CreateAccountView from './create-account-view.jsx';
|
||||||
import EditAccountView from './edit-account-view.jsx';
|
|
||||||
import AccGeneralView from './acc-general-view.jsx';
|
|
||||||
import AccNotificationsView from './acc-notifications-view.jsx';
|
import AccNotificationsView from './acc-notifications-view.jsx';
|
||||||
import AccSecurityView from './acc-security-view.jsx';
|
import AccSecurityView from './acc-security-view.jsx';
|
||||||
import AccSupportView from './acc-support-view.jsx';
|
import AccSupportView from './acc-support-view.jsx';
|
||||||
@@ -37,19 +37,24 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
edit: {
|
edit: {
|
||||||
id: 'sidepanel_title_account_settings',
|
id: 'sidepanel_title_account_settings',
|
||||||
description: 'Sidepanel title for EditAccountView.',
|
description: 'Sidepanel title for AccountSettingsView.',
|
||||||
defaultMessage: 'Account Settings'
|
defaultMessage: 'Account Settings'
|
||||||
},
|
},
|
||||||
general: {
|
general: {
|
||||||
id: 'sidepanel_title_acc_general',
|
id: 'panel_title_general',
|
||||||
description: 'Sidepanel title for AccGeneralView.',
|
description: 'Title for TopicCommon.',
|
||||||
defaultMessage: 'General'
|
defaultMessage: 'General'
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
id: 'sidepanel_title_acc_security',
|
id: 'panel_title_security',
|
||||||
description: 'Sidepanel title for AccSecurityView.',
|
description: 'Title for TopicSecirity and AccSecurity.',
|
||||||
defaultMessage: 'Security'
|
defaultMessage: 'Security'
|
||||||
},
|
},
|
||||||
|
crop: {
|
||||||
|
id: 'panel_title_crop',
|
||||||
|
description: 'Title for AvatarCropView.',
|
||||||
|
defaultMessage: 'Drag to Adjust'
|
||||||
|
},
|
||||||
notif: {
|
notif: {
|
||||||
id: 'sidepanel_title_acc_notifications',
|
id: 'sidepanel_title_acc_notifications',
|
||||||
description: 'Sidepanel title for AccNotificationsView.',
|
description: 'Sidepanel title for AccNotificationsView.',
|
||||||
@@ -95,20 +100,22 @@ class SidepanelView extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleNewTopic() {
|
handleNewTopic() {
|
||||||
this.props.onBasicNavigate('newtpk');
|
this.props.onNavigate('newtpk');
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {formatMessage} = this.props.intl;
|
const {formatMessage} = this.props.intl;
|
||||||
const view = this.props.state || (this.props.myUserId ? 'contacts' : 'login');
|
const view = this.props.state || (this.props.myUserId ? 'contacts' : 'login');
|
||||||
|
|
||||||
let title, avatar;
|
let title, avatar, badges;
|
||||||
if (view == 'contacts') {
|
if (view == 'contacts') {
|
||||||
title = this.props.title;
|
title = this.props.title;
|
||||||
avatar = this.props.avatar ? this.props.avatar : true;
|
avatar = this.props.avatar ? this.props.avatar : true;
|
||||||
|
badges = this.props.trustedBadges;
|
||||||
} else {
|
} else {
|
||||||
title = formatMessage(messages[view]);
|
title = formatMessage(messages[view]);
|
||||||
avatar = false;
|
avatar = false;
|
||||||
|
badges = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let onCancel;
|
let onCancel;
|
||||||
@@ -122,6 +129,8 @@ class SidepanelView extends React.Component {
|
|||||||
state={view}
|
state={view}
|
||||||
title={title}
|
title={title}
|
||||||
avatar={avatar}
|
avatar={avatar}
|
||||||
|
tinode={this.props.tinode}
|
||||||
|
trustedBadges={badges}
|
||||||
myUserId={this.props.myUserId}
|
myUserId={this.props.myUserId}
|
||||||
onSignUp={this.props.onSignUp}
|
onSignUp={this.props.onSignUp}
|
||||||
onSettings={this.props.onSettings}
|
onSettings={this.props.onSettings}
|
||||||
@@ -147,6 +156,7 @@ class SidepanelView extends React.Component {
|
|||||||
|
|
||||||
view === 'register' ?
|
view === 'register' ?
|
||||||
<CreateAccountView
|
<CreateAccountView
|
||||||
|
tinode={this.props.tinode}
|
||||||
onCreateAccount={this.props.onCreateAccount}
|
onCreateAccount={this.props.onCreateAccount}
|
||||||
onCancel={this.props.onCancel}
|
onCancel={this.props.onCancel}
|
||||||
onError={this.props.onError} /> :
|
onError={this.props.onError} /> :
|
||||||
@@ -159,21 +169,22 @@ class SidepanelView extends React.Component {
|
|||||||
onUpdate={this.props.onGlobalSettings} /> :
|
onUpdate={this.props.onGlobalSettings} /> :
|
||||||
|
|
||||||
view === 'edit' ?
|
view === 'edit' ?
|
||||||
<EditAccountView
|
<AccountSettingsView
|
||||||
tinode={this.props.tinode}
|
tinode={this.props.tinode}
|
||||||
myUserId={this.props.myUserId}
|
myUserId={this.props.myUserId}
|
||||||
onBasicNavigate={this.props.onBasicNavigate} /> :
|
trustedBadges={this.props.trustedBadges}
|
||||||
|
onNavigate={this.props.onNavigate} /> :
|
||||||
|
|
||||||
view === 'general' ?
|
(view === 'general' || view === 'crop') ?
|
||||||
<AccGeneralView
|
<TopicCommon
|
||||||
|
topic="me"
|
||||||
tinode={this.props.tinode}
|
tinode={this.props.tinode}
|
||||||
myUserId={this.props.myUserId}
|
myUserId={this.props.myUserId}
|
||||||
onUpdateAccount={this.props.onUpdateAccount}
|
onUpdateTopicDesc={this.props.onUpdateAccountDesc}
|
||||||
onUpdateTags={this.props.onUpdateAccountTags}
|
onUpdateTags={this.props.onUpdateAccountTags}
|
||||||
onCredAdd={this.props.onCredAdd}
|
onCredAdd={this.props.onCredAdd}
|
||||||
onCredDelete={this.props.onCredDelete}
|
onCredDelete={this.props.onCredDelete}
|
||||||
onCredConfirm={this.props.onCredConfirm}
|
onCredConfirm={this.props.onCredConfirm}
|
||||||
onBasicNavigate={this.props.onBasicNavigate}
|
|
||||||
onError={this.props.onError} /> :
|
onError={this.props.onError} /> :
|
||||||
|
|
||||||
view === 'notif' ?
|
view === 'notif' ?
|
||||||
@@ -189,7 +200,8 @@ class SidepanelView extends React.Component {
|
|||||||
view === 'security' ?
|
view === 'security' ?
|
||||||
<AccSecurityView
|
<AccSecurityView
|
||||||
tinode={this.props.tinode}
|
tinode={this.props.tinode}
|
||||||
onUpdateAccount={this.props.onUpdateAccount}
|
onUpdateAccountDesc={this.props.onUpdateAccountDesc}
|
||||||
|
onUpdatePassword={this.props.onUpdatePassword}
|
||||||
onLogout={this.props.onLogout}
|
onLogout={this.props.onLogout}
|
||||||
onDeleteAccount={this.props.onDeleteAccount}
|
onDeleteAccount={this.props.onDeleteAccount}
|
||||||
onShowAlert={this.props.onShowAlert}
|
onShowAlert={this.props.onShowAlert}
|
||||||
@@ -233,7 +245,8 @@ class SidepanelView extends React.Component {
|
|||||||
view === 'reset' ?
|
view === 'reset' ?
|
||||||
<PasswordResetView
|
<PasswordResetView
|
||||||
onRequest={this.props.onPasswordResetRequest}
|
onRequest={this.props.onPasswordResetRequest}
|
||||||
onReset={this.props.onResetPassword} /> :
|
onReset={this.props.onResetPassword}
|
||||||
|
onCancel={this.props.onCancel} /> :
|
||||||
null}
|
null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// The top-level class to hold all functionality together.
|
// The top-level class to hold all functionality together.
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
|
||||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||||
|
|
||||||
import * as firebase from 'firebase/app';
|
import * as firebase from 'firebase/app';
|
||||||
@@ -10,15 +9,16 @@ import Tinode from 'tinode-sdk';
|
|||||||
|
|
||||||
import Alert from '../widgets/alert.jsx';
|
import Alert from '../widgets/alert.jsx';
|
||||||
import ContextMenu from '../widgets/context-menu.jsx';
|
import ContextMenu from '../widgets/context-menu.jsx';
|
||||||
|
import ForwardDialog from '../widgets/forward-dialog.jsx';
|
||||||
|
|
||||||
import InfoView from './info-view.jsx';
|
import InfoView from './info-view.jsx';
|
||||||
import MessagesView from './messages-view.jsx';
|
import MessagesView from './messages-view.jsx';
|
||||||
import SidepanelView from './sidepanel-view.jsx';
|
import SidepanelView from './sidepanel-view.jsx';
|
||||||
|
|
||||||
import { API_KEY, APP_NAME, DEFAULT_P2P_ACCESS_MODE, LOGGING_ENABLED,
|
import { API_KEY, APP_NAME, DEFAULT_P2P_ACCESS_MODE, FORWARDED_PREVIEW_LENGTH, LOGGING_ENABLED,
|
||||||
MEDIA_BREAKPOINT, RECEIVED_DELAY } from '../config.js';
|
MEDIA_BREAKPOINT, QUOTED_REPLY_LENGTH, RECEIVED_DELAY } from '../config.js';
|
||||||
import { PACKAGE_VERSION } from '../version.js';
|
import { PACKAGE_VERSION } from '../version.js';
|
||||||
import { base64ReEncode, makeImageDataUrl } from '../lib/blob-helpers.js';
|
import { base64ReEncode, makeImageUrl } from '../lib/blob-helpers.js';
|
||||||
import { detectServerAddress, isLocalHost, isSecureConnection } from '../lib/host-name.js';
|
import { detectServerAddress, isLocalHost, isSecureConnection } from '../lib/host-name.js';
|
||||||
import LocalStorageUtil from '../lib/local-storage.js';
|
import LocalStorageUtil from '../lib/local-storage.js';
|
||||||
import HashNavigation from '../lib/navigation.js';
|
import HashNavigation from '../lib/navigation.js';
|
||||||
@@ -70,6 +70,8 @@ class TinodeWeb extends React.Component {
|
|||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
|
this.selfRef = React.createRef();
|
||||||
|
|
||||||
this.state = this.getBlankState();
|
this.state = this.getBlankState();
|
||||||
|
|
||||||
this.handleResize = this.handleResize.bind(this);
|
this.handleResize = this.handleResize.bind(this);
|
||||||
@@ -100,7 +102,7 @@ class TinodeWeb extends React.Component {
|
|||||||
this.handleNewChatInvitation = this.handleNewChatInvitation.bind(this);
|
this.handleNewChatInvitation = this.handleNewChatInvitation.bind(this);
|
||||||
this.handleNewAccount = this.handleNewAccount.bind(this);
|
this.handleNewAccount = this.handleNewAccount.bind(this);
|
||||||
this.handleNewAccountRequest = this.handleNewAccountRequest.bind(this);
|
this.handleNewAccountRequest = this.handleNewAccountRequest.bind(this);
|
||||||
this.handleUpdateAccountRequest = this.handleUpdateAccountRequest.bind(this);
|
this.handleUpdatePasswordRequest = this.handleUpdatePasswordRequest.bind(this);
|
||||||
this.handleUpdateAccountTagsRequest = this.handleUpdateAccountTagsRequest.bind(this);
|
this.handleUpdateAccountTagsRequest = this.handleUpdateAccountTagsRequest.bind(this);
|
||||||
this.handleToggleIncognitoMode = this.handleToggleIncognitoMode.bind(this);
|
this.handleToggleIncognitoMode = this.handleToggleIncognitoMode.bind(this);
|
||||||
this.handleSettings = this.handleSettings.bind(this);
|
this.handleSettings = this.handleSettings.bind(this);
|
||||||
@@ -119,8 +121,9 @@ class TinodeWeb extends React.Component {
|
|||||||
this.handleStartTopicRequest = this.handleStartTopicRequest.bind(this);
|
this.handleStartTopicRequest = this.handleStartTopicRequest.bind(this);
|
||||||
this.handleNewTopicCreated = this.handleNewTopicCreated.bind(this);
|
this.handleNewTopicCreated = this.handleNewTopicCreated.bind(this);
|
||||||
this.handleTopicUpdateRequest = this.handleTopicUpdateRequest.bind(this);
|
this.handleTopicUpdateRequest = this.handleTopicUpdateRequest.bind(this);
|
||||||
|
this.handleUnarchive = this.handleUnarchive.bind(this);
|
||||||
this.handleChangePermissions = this.handleChangePermissions.bind(this);
|
this.handleChangePermissions = this.handleChangePermissions.bind(this);
|
||||||
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
|
this.handleTagsUpdateRequest = this.handleTagsUpdateRequest.bind(this);
|
||||||
this.handleLogout = this.handleLogout.bind(this);
|
this.handleLogout = this.handleLogout.bind(this);
|
||||||
this.handleDeleteAccount = this.handleDeleteAccount.bind(this);
|
this.handleDeleteAccount = this.handleDeleteAccount.bind(this);
|
||||||
this.handleDeleteTopicRequest = this.handleDeleteTopicRequest.bind(this);
|
this.handleDeleteTopicRequest = this.handleDeleteTopicRequest.bind(this);
|
||||||
@@ -133,12 +136,16 @@ class TinodeWeb extends React.Component {
|
|||||||
this.handleHideContextMenu = this.handleHideContextMenu.bind(this);
|
this.handleHideContextMenu = this.handleHideContextMenu.bind(this);
|
||||||
this.handleShowAlert = this.handleShowAlert.bind(this);
|
this.handleShowAlert = this.handleShowAlert.bind(this);
|
||||||
this.handleShowInfoView = this.handleShowInfoView.bind(this);
|
this.handleShowInfoView = this.handleShowInfoView.bind(this);
|
||||||
this.handleHideInfoView = this.handleHideInfoView.bind(this);
|
|
||||||
this.handleMemberUpdateRequest = this.handleMemberUpdateRequest.bind(this);
|
this.handleMemberUpdateRequest = this.handleMemberUpdateRequest.bind(this);
|
||||||
this.handleValidateCredentialsRequest = this.handleValidateCredentialsRequest.bind(this);
|
this.handleValidateCredentialsRequest = this.handleValidateCredentialsRequest.bind(this);
|
||||||
this.handlePasswordResetRequest = this.handlePasswordResetRequest.bind(this);
|
this.handlePasswordResetRequest = this.handlePasswordResetRequest.bind(this);
|
||||||
this.handleResetPassword = this.handleResetPassword.bind(this);
|
this.handleResetPassword = this.handleResetPassword.bind(this);
|
||||||
this.handleContextMenuAction = this.handleContextMenuAction.bind(this);
|
this.handleContextMenuAction = this.handleContextMenuAction.bind(this);
|
||||||
|
|
||||||
|
this.handleShowForwardDialog = this.handleShowForwardDialog.bind(this);
|
||||||
|
this.handleHideForwardDialog = this.handleHideForwardDialog.bind(this);
|
||||||
|
|
||||||
|
this.sendMessageToTopic = this.sendMessageToTopic.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
getBlankState() {
|
getBlankState() {
|
||||||
@@ -157,7 +164,7 @@ class TinodeWeb extends React.Component {
|
|||||||
// "On" is the default, so saving the "off" state.
|
// "On" is the default, so saving the "off" state.
|
||||||
messageSounds: !settings.messageSoundsOff,
|
messageSounds: !settings.messageSoundsOff,
|
||||||
incognitoMode: false,
|
incognitoMode: false,
|
||||||
desktopAlerts: persist && settings.desktopAlerts,
|
desktopAlerts: persist && !!settings.desktopAlerts,
|
||||||
desktopAlertsEnabled: (isSecureConnection() || isLocalHost()) &&
|
desktopAlertsEnabled: (isSecureConnection() || isLocalHost()) &&
|
||||||
(typeof firebase != 'undefined') && (typeof navigator != 'undefined') &&
|
(typeof firebase != 'undefined') && (typeof navigator != 'undefined') &&
|
||||||
(typeof FIREBASE_INIT != 'undefined'),
|
(typeof FIREBASE_INIT != 'undefined'),
|
||||||
@@ -173,6 +180,7 @@ class TinodeWeb extends React.Component {
|
|||||||
sidePanelSelected: 'login',
|
sidePanelSelected: 'login',
|
||||||
sidePanelTitle: null,
|
sidePanelTitle: null,
|
||||||
sidePanelAvatar: null,
|
sidePanelAvatar: null,
|
||||||
|
myTrustedBadges: [],
|
||||||
loadSpinnerVisible: false,
|
loadSpinnerVisible: false,
|
||||||
|
|
||||||
login: '',
|
login: '',
|
||||||
@@ -186,7 +194,7 @@ class TinodeWeb extends React.Component {
|
|||||||
newTopicParams: null,
|
newTopicParams: null,
|
||||||
loginDisabled: false,
|
loginDisabled: false,
|
||||||
displayMobile: (window.innerWidth <= MEDIA_BREAKPOINT),
|
displayMobile: (window.innerWidth <= MEDIA_BREAKPOINT),
|
||||||
showInfoPanel: false,
|
infoPanel: undefined,
|
||||||
mobilePanel: 'sidepanel',
|
mobilePanel: 'sidepanel',
|
||||||
|
|
||||||
contextMenuVisible: false,
|
contextMenuVisible: false,
|
||||||
@@ -195,6 +203,9 @@ class TinodeWeb extends React.Component {
|
|||||||
contextMenuParams: null,
|
contextMenuParams: null,
|
||||||
contextMenuItems: [],
|
contextMenuItems: [],
|
||||||
|
|
||||||
|
forwardDialogVisible: false,
|
||||||
|
forwardMessage: null,
|
||||||
|
|
||||||
// Modal alert dialog.
|
// Modal alert dialog.
|
||||||
alertVisible: false,
|
alertVisible: false,
|
||||||
alertParams: {},
|
alertParams: {},
|
||||||
@@ -224,7 +235,7 @@ class TinodeWeb extends React.Component {
|
|||||||
serviceWorkerChannel.addEventListener('message', this.handlePushMessage);
|
serviceWorkerChannel.addEventListener('message', this.handlePushMessage);
|
||||||
} else {
|
} else {
|
||||||
// Safari is broken by design.
|
// Safari is broken by design.
|
||||||
console.warn('Your browser does not support BroadcastChannel. Some features will not be available');
|
console.warn("Your browser does not support BroadcastChannel. Some features will not be available");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Window/tab visible or invisible for pausing timers.
|
// Window/tab visible or invisible for pausing timers.
|
||||||
@@ -386,7 +397,7 @@ class TinodeWeb extends React.Component {
|
|||||||
const hash = HashNavigation.parseUrlHash(window.location.hash);
|
const hash = HashNavigation.parseUrlHash(window.location.hash);
|
||||||
if (hash.path && hash.path.length > 0) {
|
if (hash.path && hash.path.length > 0) {
|
||||||
// Left-side panel selector.
|
// Left-side panel selector.
|
||||||
if (['register','settings','edit','notif','security','support','general',
|
if (['register','settings','edit','notif','security','support','general','crop',
|
||||||
'cred','reset','newtpk','archive','blocked','contacts',''].includes(hash.path[0])) {
|
'cred','reset','newtpk','archive','blocked','contacts',''].includes(hash.path[0])) {
|
||||||
this.setState({sidePanelSelected: hash.path[0]});
|
this.setState({sidePanelSelected: hash.path[0]});
|
||||||
} else {
|
} else {
|
||||||
@@ -424,7 +435,7 @@ class TinodeWeb extends React.Component {
|
|||||||
|
|
||||||
// Additional parameters of panels.
|
// Additional parameters of panels.
|
||||||
this.setState({
|
this.setState({
|
||||||
showInfoPanel: hash.params.info,
|
infoPanel: hash.params.info,
|
||||||
newTopicTabSelected: hash.params.tab
|
newTopicTabSelected: hash.params.tab
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -550,6 +561,7 @@ class TinodeWeb extends React.Component {
|
|||||||
errorLevel: err && err.message ? 'err' : 'warn',
|
errorLevel: err && err.message ? 'err' : 'warn',
|
||||||
loginDisabled: false,
|
loginDisabled: false,
|
||||||
contextMenuVisible: false,
|
contextMenuVisible: false,
|
||||||
|
forwardDialogVisible: false,
|
||||||
serverVersion: "no connection"
|
serverVersion: "no connection"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -564,8 +576,9 @@ class TinodeWeb extends React.Component {
|
|||||||
cred = Tinode.credential(cred);
|
cred = Tinode.credential(cred);
|
||||||
// Try to login with login/password. If they are not available, try token. If no token, ask for login/password.
|
// Try to login with login/password. If they are not available, try token. If no token, ask for login/password.
|
||||||
let promise = null;
|
let promise = null;
|
||||||
const token = this.tinode.getAuthToken();
|
let token = this.tinode.getAuthToken();
|
||||||
if (login && password) {
|
if (login && password) {
|
||||||
|
token = null;
|
||||||
this.setState({password: null});
|
this.setState({password: null});
|
||||||
promise = this.tinode.loginBasic(login, password, cred);
|
promise = this.tinode.loginBasic(login, password, cred);
|
||||||
} else if (token) {
|
} else if (token) {
|
||||||
@@ -593,7 +606,9 @@ class TinodeWeb extends React.Component {
|
|||||||
autoLogin: false
|
autoLogin: false
|
||||||
});
|
});
|
||||||
this.handleError(err.message, 'err');
|
this.handleError(err.message, 'err');
|
||||||
localStorage.removeItem('auth-token');
|
if (token) {
|
||||||
|
this.handleLogout();
|
||||||
|
}
|
||||||
HashNavigation.navigateTo('');
|
HashNavigation.navigateTo('');
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -661,7 +676,18 @@ class TinodeWeb extends React.Component {
|
|||||||
if (desc.public) {
|
if (desc.public) {
|
||||||
this.setState({
|
this.setState({
|
||||||
sidePanelTitle: desc.public.fn,
|
sidePanelTitle: desc.public.fn,
|
||||||
sidePanelAvatar: makeImageDataUrl(desc.public.photo)
|
sidePanelAvatar: makeImageUrl(desc.public.photo)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (desc.trusted) {
|
||||||
|
const badges = [];
|
||||||
|
for (const [key, val] of Object.entries(desc.trusted)) {
|
||||||
|
if (val) {
|
||||||
|
badges.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.setState({
|
||||||
|
myTrustedBadges: badges,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (desc.acs) {
|
if (desc.acs) {
|
||||||
@@ -769,6 +795,7 @@ class TinodeWeb extends React.Component {
|
|||||||
// Contacts expect c.topic to be set.
|
// Contacts expect c.topic to be set.
|
||||||
c.topic = c.name;
|
c.topic = c.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
newState.chatList.push(c);
|
newState.chatList.push(c);
|
||||||
if (this.state.topicSelected == c.topic) {
|
if (this.state.topicSelected == c.topic) {
|
||||||
newState.topicSelectedOnline = c.online;
|
newState.topicSelectedOnline = c.online;
|
||||||
@@ -849,13 +876,14 @@ class TinodeWeb extends React.Component {
|
|||||||
errorText: '',
|
errorText: '',
|
||||||
errorLevel: null,
|
errorLevel: null,
|
||||||
mobilePanel: 'topic-view',
|
mobilePanel: 'topic-view',
|
||||||
showInfoPanel: false
|
infoPanel: undefined
|
||||||
});
|
});
|
||||||
// Different contact selected.
|
// Different contact selected.
|
||||||
if (this.state.topicSelected != topicName) {
|
if (this.state.topicSelected != topicName) {
|
||||||
this.setState({
|
this.setState({
|
||||||
topicSelectedOnline: this.tinode.isTopicOnline(topicName),
|
topicSelectedOnline: this.tinode.isTopicOnline(topicName),
|
||||||
topicSelectedAcs: this.tinode.getTopicAccessMode(topicName)
|
topicSelectedAcs: this.tinode.getTopicAccessMode(topicName),
|
||||||
|
forwardMessage: null
|
||||||
});
|
});
|
||||||
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', topicName));
|
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', topicName));
|
||||||
}
|
}
|
||||||
@@ -867,7 +895,8 @@ class TinodeWeb extends React.Component {
|
|||||||
mobilePanel: 'sidepanel',
|
mobilePanel: 'sidepanel',
|
||||||
topicSelectedOnline: false,
|
topicSelectedOnline: false,
|
||||||
topicSelectedAcs: null,
|
topicSelectedAcs: null,
|
||||||
showInfoPanel: false
|
infoPanel: undefined,
|
||||||
|
forwardMessage: null
|
||||||
});
|
});
|
||||||
|
|
||||||
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', null));
|
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', null));
|
||||||
@@ -887,18 +916,36 @@ class TinodeWeb extends React.Component {
|
|||||||
// - msg - Drafty message with content
|
// - msg - Drafty message with content
|
||||||
// - promise - Promise to be resolved when the upload is completed
|
// - promise - Promise to be resolved when the upload is completed
|
||||||
// - uploader - for tracking progress
|
// - uploader - for tracking progress
|
||||||
handleSendMessage(msg, promise, uploader) {
|
// - head - head dictionary to be attached to the message
|
||||||
|
handleSendMessage(msg, promise, uploader, head) {
|
||||||
const topic = this.tinode.getTopic(this.state.topicSelected);
|
const topic = this.tinode.getTopic(this.state.topicSelected);
|
||||||
|
this.sendMessageToTopic(topic, msg, promise, uploader, head);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMessageToTopic(topic, msg, promise, uploader, head) {
|
||||||
msg = topic.createMessage(msg, false);
|
msg = topic.createMessage(msg, false);
|
||||||
// The uploader is used to show progress.
|
// The uploader is used to show progress.
|
||||||
msg._uploader = uploader;
|
msg._uploader = uploader;
|
||||||
|
|
||||||
|
if (head) {
|
||||||
|
msg.head = Object.assign(msg.head || {}, head);
|
||||||
|
}
|
||||||
|
|
||||||
if (!topic.isSubscribed()) {
|
if (!topic.isSubscribed()) {
|
||||||
|
// Topic is not subscribed yet. Subscribe.
|
||||||
if (!promise) {
|
if (!promise) {
|
||||||
promise = Promise.resolve();
|
promise = Promise.resolve();
|
||||||
}
|
}
|
||||||
promise = promise.then(() => { return topic.subscribe(); });
|
promise = promise
|
||||||
|
.then(() => topic.subscribe())
|
||||||
|
.then(() => {
|
||||||
|
// If there are unsent messages, try sending them now.
|
||||||
|
topic.queuedMessages((pub) => {
|
||||||
|
if (!pub._sending && topic.isSubscribed()) {
|
||||||
|
this.sendMessage(pub);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (promise) {
|
if (promise) {
|
||||||
@@ -984,32 +1031,15 @@ class TinodeWeb extends React.Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleUpdateAccountRequest(password, pub, defacs) {
|
|
||||||
this.handleError();
|
|
||||||
|
|
||||||
if (pub || defacs) {
|
|
||||||
const params = {};
|
|
||||||
if (pub) {
|
|
||||||
params.public = pub;
|
|
||||||
}
|
|
||||||
if (defacs) {
|
|
||||||
params.defacs = defacs;
|
|
||||||
}
|
|
||||||
this.tinode.getMeTopic().setMeta({desc: params}).catch((err) => {
|
|
||||||
this.handleError(err.message, 'err');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (password) {
|
|
||||||
this.tinode.updateAccountBasic(null, this.tinode.getCurrentLogin(), password).catch((err) => {
|
|
||||||
this.handleError(err.message, 'err');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleToggleIncognitoMode(on) {
|
handleToggleIncognitoMode(on) {
|
||||||
|
// Make state undefined.
|
||||||
|
this.setState({incognitoMode: null});
|
||||||
|
|
||||||
const me = this.tinode.getMeTopic();
|
const me = this.tinode.getMeTopic();
|
||||||
const am = me.getAccessMode().updateWant(on ? '-P' : '+P').getWant();
|
const am = me.getAccessMode().updateWant(on ? '-P' : '+P').getWant();
|
||||||
me.setMeta({sub: {mode: am}}).catch((err) => {
|
me.setMeta({sub: {mode: am}}).catch((err) => {
|
||||||
|
// Request failed, keep existing state.
|
||||||
|
this.setState({incognitoMode: !on});
|
||||||
this.handleError(err.message, 'err');
|
this.handleError(err.message, 'err');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1070,6 +1100,7 @@ class TinodeWeb extends React.Component {
|
|||||||
|
|
||||||
togglePushToken(enabled) {
|
togglePushToken(enabled) {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
|
this.setState({desktopAlerts: null});
|
||||||
if (!this.state.firebaseToken) {
|
if (!this.state.firebaseToken) {
|
||||||
const fcm = this.fbPush ?
|
const fcm = this.fbPush ?
|
||||||
Promise.resolve() :
|
Promise.resolve() :
|
||||||
@@ -1079,7 +1110,7 @@ class TinodeWeb extends React.Component {
|
|||||||
}).then(() => {
|
}).then(() => {
|
||||||
this.requestPushToken();
|
this.requestPushToken();
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
console.warn("Failed to get notification permission.", err);
|
console.error("Failed to get notification permission.", err);
|
||||||
this.handleError(err.message, 'err');
|
this.handleError(err.message, 'err');
|
||||||
this.setState({desktopAlerts: false, firebaseToken: null});
|
this.setState({desktopAlerts: false, firebaseToken: null});
|
||||||
LocalStorageUtil.updateObject('settings', {desktopAlerts: false});
|
LocalStorageUtil.updateObject('settings', {desktopAlerts: false});
|
||||||
@@ -1092,7 +1123,7 @@ class TinodeWeb extends React.Component {
|
|||||||
}
|
}
|
||||||
} else if (this.state.firebaseToken && this.fbPush) {
|
} else if (this.state.firebaseToken && this.fbPush) {
|
||||||
this.fbPush.deleteToken(this.state.firebaseToken).catch((err) => {
|
this.fbPush.deleteToken(this.state.firebaseToken).catch((err) => {
|
||||||
console.warn("Unable to delete token.", err);
|
console.error("Unable to delete token.", err);
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
LocalStorageUtil.updateObject('settings', {desktopAlerts: false});
|
LocalStorageUtil.updateObject('settings', {desktopAlerts: false});
|
||||||
localStorage.removeItem('firebase-token');
|
localStorage.removeItem('firebase-token');
|
||||||
@@ -1121,7 +1152,7 @@ class TinodeWeb extends React.Component {
|
|||||||
}
|
}
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
this.handleError(err.message, 'err');
|
this.handleError(err.message, 'err');
|
||||||
console.warn("Failed to retrieve firebase token", err);
|
console.error("Failed to retrieve firebase token", err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1156,6 +1187,8 @@ class TinodeWeb extends React.Component {
|
|||||||
let path = '';
|
let path = '';
|
||||||
if (['security','support','general','notif'].includes(parsed.path[0])) {
|
if (['security','support','general','notif'].includes(parsed.path[0])) {
|
||||||
path = 'edit';
|
path = 'edit';
|
||||||
|
} else if ('crop' == parsed.path[0]) {
|
||||||
|
path = 'general';
|
||||||
} else if ('blocked' == parsed.path[0]) {
|
} else if ('blocked' == parsed.path[0]) {
|
||||||
path = 'security';
|
path = 'security';
|
||||||
} else if (this.state.myUserId) {
|
} else if (this.state.myUserId) {
|
||||||
@@ -1171,13 +1204,18 @@ class TinodeWeb extends React.Component {
|
|||||||
this.setState({errorText: '', errorLevel: null});
|
this.setState({errorText: '', errorLevel: null});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic nagigator by hash value. No need to bind to this.
|
// Sidepanel navigator. No need to bind to 'this'.
|
||||||
basicNavigator(hash) {
|
basicNavigator(hash) {
|
||||||
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, hash));
|
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, hash));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Topic info navigator. No need to bind to 'this'.
|
||||||
|
infoNavigator(hash) {
|
||||||
|
HashNavigation.navigateTo(HashNavigation.setUrlInfoPanel(window.location.hash, hash));
|
||||||
|
}
|
||||||
|
|
||||||
// Request to start a topic, new or selected from search results, or "by ID".
|
// Request to start a topic, new or selected from search results, or "by ID".
|
||||||
handleStartTopicRequest(topicName, pub, priv, tags, isChannel) {
|
handleStartTopicRequest(topicName, newTopicParams, isChannel) {
|
||||||
// Check if topic is indeed new. If not, launch it.
|
// Check if topic is indeed new. If not, launch it.
|
||||||
if (topicName && this.tinode.isTopicCached(topicName)) {
|
if (topicName && this.tinode.isTopicCached(topicName)) {
|
||||||
this.handleTopicSelected(topicName);
|
this.handleTopicSelected(topicName);
|
||||||
@@ -1192,14 +1230,14 @@ class TinodeWeb extends React.Component {
|
|||||||
params.desc = {defacs: {auth: DEFAULT_P2P_ACCESS_MODE}};
|
params.desc = {defacs: {auth: DEFAULT_P2P_ACCESS_MODE}};
|
||||||
} else {
|
} else {
|
||||||
topicName = topicName || this.tinode.newGroupTopicName(isChannel);
|
topicName = topicName || this.tinode.newGroupTopicName(isChannel);
|
||||||
params.desc = {public: pub, private: {comment: priv}};
|
params.desc = {public: newTopicParams.public, private: {comment: newTopicParams.private}};
|
||||||
params.tags = tags;
|
params.tags = newTopicParams.tags;
|
||||||
}
|
}
|
||||||
params._topicName = topicName;
|
params._topicName = topicName;
|
||||||
this.setState({newTopicParams: params}, () => {this.handleTopicSelected(topicName)});
|
this.setState({newTopicParams: params}, () => {this.handleTopicSelected(topicName)});
|
||||||
}
|
}
|
||||||
|
|
||||||
// New topic was creted, here is the new topic name.
|
// New topic was created, here is the new topic name.
|
||||||
handleNewTopicCreated(oldName, newName) {
|
handleNewTopicCreated(oldName, newName) {
|
||||||
if (this.state.topicSelected == oldName && oldName != newName) {
|
if (this.state.topicSelected == oldName && oldName != newName) {
|
||||||
// If the current URl contains the old topic name, replace it with new.
|
// If the current URl contains the old topic name, replace it with new.
|
||||||
@@ -1211,21 +1249,49 @@ class TinodeWeb extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleTopicUpdateRequest(topicName, pub, priv, permissions) {
|
handleTopicUpdateRequest(topicName, pub, priv, defacs) {
|
||||||
|
this.handleError();
|
||||||
|
|
||||||
const topic = this.tinode.getTopic(topicName);
|
const topic = this.tinode.getTopic(topicName);
|
||||||
if (topic) {
|
if (topic) {
|
||||||
const params = {};
|
const params = {};
|
||||||
|
let attachments;
|
||||||
if (pub) {
|
if (pub) {
|
||||||
|
if (pub.photo) {
|
||||||
|
if (pub.photo.ref && pub.photo.ref != Tinode.DEL_CHAR) {
|
||||||
|
attachments = [pub.photo.ref];
|
||||||
|
} else if (!pub.photo.data || pub.photo.data == Tinode.DEL_CHAR) {
|
||||||
|
pub.photo = Tinode.DEL_CHAR;
|
||||||
|
}
|
||||||
|
}
|
||||||
params.public = pub;
|
params.public = pub;
|
||||||
}
|
}
|
||||||
if (priv) {
|
|
||||||
|
if (typeof priv == 'string') {
|
||||||
params.private = (priv === Tinode.DEL_CHAR) ?
|
params.private = (priv === Tinode.DEL_CHAR) ?
|
||||||
Tinode.DEL_CHAR : {comment: priv};
|
Tinode.DEL_CHAR : {comment: priv};
|
||||||
}
|
}
|
||||||
if (permissions) {
|
if (defacs) {
|
||||||
params.defacs = permissions;
|
params.defacs = defacs;
|
||||||
}
|
}
|
||||||
topic.setMeta({desc: params}).catch((err) => {
|
topic.setMeta({desc: params, attachments: attachments}).catch((err) => {
|
||||||
|
this.handleError(err.message, 'err');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUnarchive(topicName) {
|
||||||
|
const topic = this.tinode.getTopic(topicName);
|
||||||
|
if (topic) {
|
||||||
|
topic.archive(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUpdatePasswordRequest(password) {
|
||||||
|
this.handleError();
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
this.tinode.updateAccountBasic(null, this.tinode.getCurrentLogin(), password).catch((err) => {
|
||||||
this.handleError(err.message, 'err');
|
this.handleError(err.message, 'err');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1248,7 +1314,7 @@ class TinodeWeb extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleTagsUpdated(topicName, tags) {
|
handleTagsUpdateRequest(topicName, tags) {
|
||||||
const topic = this.tinode.getTopic(topicName);
|
const topic = this.tinode.getTopic(topicName);
|
||||||
if (topic) {
|
if (topic) {
|
||||||
topic.setMeta({tags: tags}).catch((err) => {
|
topic.setMeta({tags: tags}).catch((err) => {
|
||||||
@@ -1373,7 +1439,33 @@ class TinodeWeb extends React.Component {
|
|||||||
contextMenuClickAt: {x: params.x, y: params.y},
|
contextMenuClickAt: {x: params.x, y: params.y},
|
||||||
contextMenuParams: params,
|
contextMenuParams: params,
|
||||||
contextMenuItems: menuItems || this.defaultTopicContextMenu(params.topicName),
|
contextMenuItems: menuItems || this.defaultTopicContextMenu(params.topicName),
|
||||||
contextMenuBounds: ReactDOM.findDOMNode(this).getBoundingClientRect()
|
contextMenuBounds: this.selfRef.current.getBoundingClientRect()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleShowForwardDialog(params) {
|
||||||
|
if (this.state.sidePanelSelected == 'newtpk') {
|
||||||
|
// If the Find panel is active, close it.
|
||||||
|
this.handleSidepanelCancel();
|
||||||
|
}
|
||||||
|
const header = '➦ ' + params.userName;
|
||||||
|
const content = params.forwarded ?
|
||||||
|
Tinode.Drafty.forwardedContent(params.content) :
|
||||||
|
typeof params.content == 'string' ?
|
||||||
|
Tinode.Drafty.init(params.content) : params.content;
|
||||||
|
const preview = Tinode.Drafty.preview(content, FORWARDED_PREVIEW_LENGTH,
|
||||||
|
undefined, params.forwarded != null);
|
||||||
|
const msg = Tinode.Drafty.append(
|
||||||
|
Tinode.Drafty.appendLineBreak(
|
||||||
|
Tinode.Drafty.mention(header, params.userFrom)),
|
||||||
|
content);
|
||||||
|
const msgPreview = Tinode.Drafty.quote(header, params.userFrom, preview);
|
||||||
|
const head = {
|
||||||
|
forwarded: params.topicName + ':' + params.seq
|
||||||
|
};
|
||||||
|
this.setState({
|
||||||
|
forwardDialogVisible: true,
|
||||||
|
forwardMessage: { head: head, msg: msg, preview: msgPreview }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1417,6 +1509,13 @@ class TinodeWeb extends React.Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleHideForwardDialog(keepForwardedMessage) {
|
||||||
|
this.setState({
|
||||||
|
forwardDialogVisible: false,
|
||||||
|
forwardMessage: keepForwardedMessage ? this.state.forwardMessage : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
handleContextMenuAction(action, promise, params) {
|
handleContextMenuAction(action, promise, params) {
|
||||||
if (action == 'topic_archive') {
|
if (action == 'topic_archive') {
|
||||||
if (promise && params.topicName && params.topicName == this.state.topicSelected) {
|
if (promise && params.topicName && params.topicName == this.state.topicSelected) {
|
||||||
@@ -1424,6 +1523,8 @@ class TinodeWeb extends React.Component {
|
|||||||
this.handleTopicSelected(null);
|
this.handleTopicSelected(null);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (action == 'menu_item_forward') {
|
||||||
|
this.handleShowForwardDialog(params);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1442,13 +1543,8 @@ class TinodeWeb extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleShowInfoView() {
|
handleShowInfoView() {
|
||||||
HashNavigation.navigateTo(HashNavigation.addUrlParam(window.location.hash, 'info', true));
|
HashNavigation.navigateTo(HashNavigation.addUrlParam(window.location.hash, 'info', 'info'));
|
||||||
this.setState({showInfoPanel: true});
|
this.setState({infoPanel: 'info'});
|
||||||
}
|
|
||||||
|
|
||||||
handleHideInfoView() {
|
|
||||||
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'info'));
|
|
||||||
this.setState({showInfoPanel: false});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMemberUpdateRequest(topicName, added, removed) {
|
handleMemberUpdateRequest(topicName, added, removed) {
|
||||||
@@ -1524,7 +1620,7 @@ class TinodeWeb extends React.Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div id="app-container">
|
<div id="app-container" ref={this.selfRef}>
|
||||||
{this.state.contextMenuVisible ?
|
{this.state.contextMenuVisible ?
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
tinode={this.tinode}
|
tinode={this.tinode}
|
||||||
@@ -1544,6 +1640,22 @@ class TinodeWeb extends React.Component {
|
|||||||
:
|
:
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
{this.state.forwardDialogVisible ?
|
||||||
|
<ForwardDialog
|
||||||
|
tinode={this.tinode}
|
||||||
|
contacts={this.state.chatList}
|
||||||
|
topicSelected={this.state.topicSelected}
|
||||||
|
myUserId={this.state.myUserId}
|
||||||
|
|
||||||
|
hide={this.handleHideForwardDialog}
|
||||||
|
onInitFind={this.tnInitFind}
|
||||||
|
searchResults={this.state.searchResults}
|
||||||
|
onSearchContacts={this.handleSearchContacts}
|
||||||
|
onTopicSelected={this.handleStartTopicRequest}
|
||||||
|
/>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
<Alert
|
<Alert
|
||||||
visible={this.state.alertVisible}
|
visible={this.state.alertVisible}
|
||||||
title={this.state.alertParams.title}
|
title={this.state.alertParams.title}
|
||||||
@@ -1561,6 +1673,7 @@ class TinodeWeb extends React.Component {
|
|||||||
state={this.state.sidePanelSelected}
|
state={this.state.sidePanelSelected}
|
||||||
title={this.state.sidePanelTitle}
|
title={this.state.sidePanelTitle}
|
||||||
avatar={this.state.sidePanelAvatar}
|
avatar={this.state.sidePanelAvatar}
|
||||||
|
trustedBadges={this.state.myTrustedBadges}
|
||||||
login={this.state.login}
|
login={this.state.login}
|
||||||
persist={this.state.persist}
|
persist={this.state.persist}
|
||||||
myUserId={this.state.myUserId}
|
myUserId={this.state.myUserId}
|
||||||
@@ -1588,11 +1701,12 @@ class TinodeWeb extends React.Component {
|
|||||||
onGlobalSettings={this.handleGlobalSettings}
|
onGlobalSettings={this.handleGlobalSettings}
|
||||||
onSignUp={this.handleNewAccount}
|
onSignUp={this.handleNewAccount}
|
||||||
onSettings={this.handleSettings}
|
onSettings={this.handleSettings}
|
||||||
onBasicNavigate={this.basicNavigator}
|
onNavigate={this.basicNavigator}
|
||||||
onLoginRequest={this.handleLoginRequest}
|
onLoginRequest={this.handleLoginRequest}
|
||||||
onPersistenceChange={this.handlePersistenceChange}
|
onPersistenceChange={this.handlePersistenceChange}
|
||||||
onCreateAccount={this.handleNewAccountRequest}
|
onCreateAccount={this.handleNewAccountRequest}
|
||||||
onUpdateAccount={this.handleUpdateAccountRequest}
|
onUpdateAccountDesc={this.handleTopicUpdateRequest}
|
||||||
|
onUpdatePassword={this.handleUpdatePasswordRequest}
|
||||||
onUpdateAccountTags={this.handleUpdateAccountTagsRequest}
|
onUpdateAccountTags={this.handleUpdateAccountTagsRequest}
|
||||||
onTogglePushNotifications={this.togglePushToken}
|
onTogglePushNotifications={this.togglePushToken}
|
||||||
onToggleMessageSounds={this.handleToggleMessageSounds}
|
onToggleMessageSounds={this.handleToggleMessageSounds}
|
||||||
@@ -1629,13 +1743,18 @@ class TinodeWeb extends React.Component {
|
|||||||
viewportWidth={this.state.viewportWidth}
|
viewportWidth={this.state.viewportWidth}
|
||||||
viewportHeight={this.state.viewportHeight}
|
viewportHeight={this.state.viewportHeight}
|
||||||
hideSelf={this.state.displayMobile &&
|
hideSelf={this.state.displayMobile &&
|
||||||
(this.state.mobilePanel !== 'topic-view' || this.state.showInfoPanel)}
|
(this.state.mobilePanel !== 'topic-view' || this.state.infoPanel)}
|
||||||
topic={this.state.topicSelected}
|
topic={this.state.topicSelected}
|
||||||
myUserId={this.state.myUserId}
|
myUserId={this.state.myUserId}
|
||||||
|
// User public.fn.
|
||||||
|
myUserName={this.state.sidePanelTitle}
|
||||||
serverVersion={this.state.serverVersion}
|
serverVersion={this.state.serverVersion}
|
||||||
serverAddress={this.state.serverAddress}
|
serverAddress={this.state.serverAddress}
|
||||||
applicationVisible={this.state.applicationVisible}
|
applicationVisible={this.state.applicationVisible}
|
||||||
|
|
||||||
|
forwardMessage={this.state.forwardMessage}
|
||||||
|
onCancelForwardMessage={this.handleHideForwardDialog}
|
||||||
|
|
||||||
errorText={this.state.errorText}
|
errorText={this.state.errorText}
|
||||||
errorLevel={this.state.errorLevel}
|
errorLevel={this.state.errorLevel}
|
||||||
errorAction={this.state.errorAction}
|
errorAction={this.state.errorAction}
|
||||||
@@ -1652,7 +1771,7 @@ class TinodeWeb extends React.Component {
|
|||||||
onNewChat={this.handleNewChatInvitation}
|
onNewChat={this.handleNewChatInvitation}
|
||||||
sendMessage={this.handleSendMessage} />
|
sendMessage={this.handleSendMessage} />
|
||||||
|
|
||||||
{this.state.showInfoPanel ?
|
{this.state.infoPanel ?
|
||||||
<InfoView
|
<InfoView
|
||||||
tinode={this.tinode}
|
tinode={this.tinode}
|
||||||
connected={this.state.connected}
|
connected={this.state.connected}
|
||||||
@@ -1660,14 +1779,15 @@ class TinodeWeb extends React.Component {
|
|||||||
topic={this.state.topicSelected}
|
topic={this.state.topicSelected}
|
||||||
searchableContacts={this.state.searchableContacts}
|
searchableContacts={this.state.searchableContacts}
|
||||||
myUserId={this.state.myUserId}
|
myUserId={this.state.myUserId}
|
||||||
|
panel={this.state.infoPanel}
|
||||||
|
|
||||||
errorText={this.state.errorText}
|
errorText={this.state.errorText}
|
||||||
errorLevel={this.state.errorLevel}
|
errorLevel={this.state.errorLevel}
|
||||||
errorAction={this.state.errorAction}
|
errorAction={this.state.errorAction}
|
||||||
errorActionText={this.state.errorActionText}
|
errorActionText={this.state.errorActionText}
|
||||||
|
|
||||||
onTopicDescUpdate={this.handleTopicUpdateRequest}
|
onNavigate={this.infoNavigator}
|
||||||
onCancel={this.handleHideInfoView}
|
onTopicDescUpdateRequest={this.handleTopicUpdateRequest}
|
||||||
onShowAlert={this.handleShowAlert}
|
onShowAlert={this.handleShowAlert}
|
||||||
onChangePermissions={this.handleChangePermissions}
|
onChangePermissions={this.handleChangePermissions}
|
||||||
onMemberUpdateRequest={this.handleMemberUpdateRequest}
|
onMemberUpdateRequest={this.handleMemberUpdateRequest}
|
||||||
@@ -1677,7 +1797,8 @@ class TinodeWeb extends React.Component {
|
|||||||
onBlockTopic={this.handleBlockTopicRequest}
|
onBlockTopic={this.handleBlockTopicRequest}
|
||||||
onReportTopic={this.handleReportTopic}
|
onReportTopic={this.handleReportTopic}
|
||||||
onAddMember={this.handleManageGroupMembers}
|
onAddMember={this.handleManageGroupMembers}
|
||||||
onTopicTagsUpdate={this.handleTagsUpdated}
|
onTopicTagsUpdateRequest={this.handleTagsUpdateRequest}
|
||||||
|
onTopicUnArchive={this.handleUnarchive}
|
||||||
onInitFind={this.tnInitFind}
|
onInitFind={this.tnInitFind}
|
||||||
onError={this.handleError}
|
onError={this.handleError}
|
||||||
|
|
||||||
|
|||||||
@@ -78,12 +78,12 @@ class ValidationView extends React.PureComponent {
|
|||||||
}</FormattedMessage>
|
}</FormattedMessage>
|
||||||
</div>
|
</div>
|
||||||
<div className="dialog-buttons">
|
<div className="dialog-buttons">
|
||||||
<button className="primary" onClick={this.handleSubmit}>
|
|
||||||
<FormattedMessage id="button_confirm" defaultMessage="Confirm" description="Button [Confirm]" />
|
|
||||||
</button>
|
|
||||||
<button className="secondary" onClick={this.handleCancel}>
|
<button className="secondary" onClick={this.handleCancel}>
|
||||||
<FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" />
|
<FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" />
|
||||||
</button>
|
</button>
|
||||||
|
<button className="primary" onClick={this.handleSubmit}>
|
||||||
|
<FormattedMessage id="button_confirm" defaultMessage="Confirm" description="Button [Confirm]" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Edit account parameters.
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import Cropper from './cropper.jsx';
|
||||||
|
import { imageCrop } from '../lib/blob-helpers.js';
|
||||||
|
|
||||||
|
export default class AvatarCrop extends React.PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
scale: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
|
this.handleChange = this.handleChange.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cut out in image coordinates and desired scale factor.
|
||||||
|
handleChange(left, top, width, height, scale) {
|
||||||
|
this.setState({
|
||||||
|
left: left,
|
||||||
|
top: top,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
scale: scale
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSubmit() {
|
||||||
|
imageCrop(this.props.mime, this.props.avatar,
|
||||||
|
this.state.left, this.state.top, this.state.width, this.state.height,
|
||||||
|
this.state.scale)
|
||||||
|
.then(img => {
|
||||||
|
this.props.onSubmit(img.mime, img.blob, img.width, img.height);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
this.props.onError(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className="panel-form">
|
||||||
|
<div className="panel-form-row">
|
||||||
|
<Cropper
|
||||||
|
source={this.props.avatar}
|
||||||
|
onChange={this.handleChange} />
|
||||||
|
</div>
|
||||||
|
<div className="dialog-buttons">
|
||||||
|
{this.props.onCancel ?
|
||||||
|
<button className="secondary" onClick={this.props.onCancel}>
|
||||||
|
<FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" />
|
||||||
|
</button>
|
||||||
|
: null}
|
||||||
|
<button className="primary" onClick={this.handleSubmit}>
|
||||||
|
<FormattedMessage id="button_ok" defaultMessage="OK" description="Button [OK]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,42 +1,32 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import LetterTile from './letter-tile.jsx';
|
import LetterTile from './letter-tile.jsx';
|
||||||
|
import LoadSpinner from './load-spinner.jsx';
|
||||||
|
|
||||||
import { AVATAR_SIZE, MAX_EXTERN_ATTACHMENT_SIZE } from '../config.js';
|
import { sanitizeImageUrl } from '../lib/utils.js';
|
||||||
import { imageScaled, blobToBase64, makeImageDataUrl } from '../lib/blob-helpers.js';
|
|
||||||
|
import { AVATAR_SIZE } from '../config.js';
|
||||||
|
|
||||||
export default class AvatarUpload extends React.Component {
|
export default class AvatarUpload extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
dataUrl: props.avatar
|
source: props.avatar
|
||||||
};
|
};
|
||||||
|
|
||||||
this.handleFileUpload = this.handleFileUpload.bind(this);
|
this.handleFileReceived = this.handleFileReceived.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
if (this.props.avatar != prevProps.avatar) {
|
if (this.props.avatar != prevProps.avatar) {
|
||||||
this.setState({dataUrl: this.props.avatar})
|
this.setState({source: this.props.avatar});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleFileUpload(e) {
|
handleFileReceived(e) {
|
||||||
imageScaled(e.target.files[0], AVATAR_SIZE, AVATAR_SIZE, MAX_EXTERN_ATTACHMENT_SIZE, true,
|
const image = e.target.files[0];
|
||||||
// Image successfully scaled and converted.
|
this.props.onImageUpdated(image.type, URL.createObjectURL(image), image.name);
|
||||||
(mime, blob) => {
|
|
||||||
// Convert blob to base64-encoded bits.
|
|
||||||
blobToBase64(blob, (unused, base64bits) => {
|
|
||||||
const du = makeImageDataUrl({data: base64bits, type: mime});
|
|
||||||
this.setState({dataUrl: du});
|
|
||||||
this.props.onImageChanged(du);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// Failure
|
|
||||||
(err) => {
|
|
||||||
this.props.onError(err, 'err');
|
|
||||||
});
|
|
||||||
// Clear the value so the same file can be uploaded again.
|
// Clear the value so the same file can be uploaded again.
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
}
|
}
|
||||||
@@ -48,16 +38,17 @@ export default class AvatarUpload extends React.Component {
|
|||||||
const className = 'avatar-upload' + (this.props.readOnly ? ' read-only' : '');
|
const className = 'avatar-upload' + (this.props.readOnly ? ' read-only' : '');
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{this.props.readOnly || !this.state.dataUrl ?
|
{this.props.readOnly || !this.state.source ?
|
||||||
null :
|
null :
|
||||||
<a href="#" className="clear-avatar" onClick={(e) => {e.preventDefault(); this.props.onImageChanged(null);}}>
|
<a href="#" className="clear-avatar" onClick={(e) => {e.preventDefault(); this.props.onImageUpdated();}}>
|
||||||
<i className="material-icons">clear</i>
|
<i className="material-icons">clear</i>
|
||||||
</a>}
|
</a>}
|
||||||
{this.state.dataUrl ?
|
{this.state.source ?
|
||||||
<img src={this.state.dataUrl} className="preview" /> :
|
<img src={this.props.tinode.authorizeURL(sanitizeImageUrl(this.state.source))} className="preview" /> :
|
||||||
this.props.readOnly && this.props.uid ?
|
this.props.readOnly && this.props.uid ?
|
||||||
<div className="avatar-box">
|
<div className="avatar-box">
|
||||||
<LetterTile
|
<LetterTile
|
||||||
|
tinode={this.props.tinode}
|
||||||
avatar={true}
|
avatar={true}
|
||||||
topic={this.props.uid}
|
topic={this.props.uid}
|
||||||
title={this.props.title} />
|
title={this.props.title} />
|
||||||
@@ -66,11 +57,12 @@ export default class AvatarUpload extends React.Component {
|
|||||||
<div className="blank">{AVATAR_SIZE}×{AVATAR_SIZE}</div>}
|
<div className="blank">{AVATAR_SIZE}×{AVATAR_SIZE}</div>}
|
||||||
{this.props.readOnly ? null :
|
{this.props.readOnly ? null :
|
||||||
<input type="file" id={randId} className="inputfile hidden"
|
<input type="file" id={randId} className="inputfile hidden"
|
||||||
accept="image/*" onChange={this.handleFileUpload} />}
|
accept="image/*" onChange={this.handleFileReceived} />}
|
||||||
{this.props.readOnly ? null :
|
{this.props.readOnly ? null :
|
||||||
<label htmlFor={randId} className="round">
|
<label htmlFor={randId} className="round">
|
||||||
<i className="material-icons">file_upload</i>
|
<i className="material-icons">file_upload</i>
|
||||||
</label>}
|
</label>}
|
||||||
|
<LoadSpinner show={this.props.uploading} large={true} clear={true} centered={true} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
|
|
||||||
|
const icon_mapping = {'staff': 'verified_user'};
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
badge_verified: {
|
||||||
|
id: 'badge_verified',
|
||||||
|
defaultMessage: 'Verified/official',
|
||||||
|
description: 'Explanation of a verified account or topic badge'
|
||||||
|
},
|
||||||
|
badge_staff: {
|
||||||
|
id: 'badge_staff',
|
||||||
|
defaultMessage: 'Staff-managed',
|
||||||
|
description: 'Explanation of a staff-managed account or topic badge'
|
||||||
|
},
|
||||||
|
badge_danger: {
|
||||||
|
id: 'badge_danger',
|
||||||
|
defaultMessage: 'Untrustworthy',
|
||||||
|
description: 'Suspicious or untrustworthy account or topic badge'
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/* Trusted badges with labels: verified, staff, danger */
|
||||||
|
class BadgeList extends React.PureComponent {
|
||||||
|
render() {
|
||||||
|
const {formatMessage} = this.props.intl;
|
||||||
|
let badges = null;
|
||||||
|
if (this.props.trustedBadges && this.props.trustedBadges.length > 0) {
|
||||||
|
badges = [];
|
||||||
|
this.props.trustedBadges.map((b) => {
|
||||||
|
const comment = formatMessage(messages['badge_' + b]);
|
||||||
|
const style = 'material-icons ' + b + '-color';
|
||||||
|
badges.push(<div className="trusted-badge" key={b}><i className={style}>{icon_mapping[b] || b}</i> {comment}</div>);
|
||||||
|
});
|
||||||
|
return <>{badges}</>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(BadgeList);
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
// Single message, sent or received.
|
// Single message, sent or received.
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage, injectIntl } from 'react-intl';
|
||||||
import { Drafty } from 'tinode-sdk';
|
import { Drafty } from 'tinode-sdk';
|
||||||
|
|
||||||
import Attachment from './attachment.jsx';
|
import Attachment from './attachment.jsx';
|
||||||
import LetterTile from './letter-tile.jsx';
|
import LetterTile from './letter-tile.jsx';
|
||||||
import ReceivedMarker from './received-marker.jsx'
|
import ReceivedMarker from './received-marker.jsx'
|
||||||
import UploadingImage from './uploading-image.jsx'
|
|
||||||
|
import { fullFormatter, quoteFormatter } from '../lib/formatters.js';
|
||||||
import { sanitizeImageUrl, sanitizeUrl } from '../lib/utils.js';
|
import { sanitizeImageUrl, sanitizeUrl } from '../lib/utils.js';
|
||||||
|
|
||||||
export default class ChatMessage extends React.Component {
|
class BaseChatMessage extends React.PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
@@ -25,6 +26,17 @@ export default class ChatMessage extends React.Component {
|
|||||||
this.handleFormButtonClick = this.handleFormButtonClick.bind(this);
|
this.handleFormButtonClick = this.handleFormButtonClick.bind(this);
|
||||||
this.handleContextClick = this.handleContextClick.bind(this);
|
this.handleContextClick = this.handleContextClick.bind(this);
|
||||||
this.handleCancelUpload = this.handleCancelUpload.bind(this);
|
this.handleCancelUpload = this.handleCancelUpload.bind(this);
|
||||||
|
this.handleQuoteClick = this.handleQuoteClick.bind(this);
|
||||||
|
|
||||||
|
this.formatterContext = {
|
||||||
|
getFormatter: (tp) => { return tp == 'QQ' ? quoteFormatter : null; },
|
||||||
|
formatMessage: props.intl.formatMessage.bind(props.intl),
|
||||||
|
viewportWidth: props.viewportWidth,
|
||||||
|
authorizeURL: props.tinode.authorizeURL.bind(props.tinode),
|
||||||
|
onImagePreview: this.handleImagePreview,
|
||||||
|
onFormButtonClick: this.handleFormButtonClick,
|
||||||
|
onQuoteClick: this.handleQuoteClick
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
handleImagePreview(e) {
|
handleImagePreview(e) {
|
||||||
@@ -59,9 +71,27 @@ export default class ChatMessage extends React.Component {
|
|||||||
handleContextClick(e) {
|
handleContextClick(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const menuItems = this.props.received == Tinode.MESSAGE_STATUS_FAILED ? ['menu_item_send_retry'] : [];
|
const menuItems = [];
|
||||||
this.props.showContextMenu({ seq: this.props.seq, content: this.props.content,
|
if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
|
||||||
y: e.pageY, x: e.pageX }, menuItems);
|
menuItems.push('menu_item_send_retry');
|
||||||
|
}
|
||||||
|
if (this.props.userIsWriter &&
|
||||||
|
this.props.received > Tinode.MESSAGE_STATUS_FAILED &&
|
||||||
|
this.props.received < Tinode.MESSAGE_STATUS_DEL_RANGE) {
|
||||||
|
menuItems.push('menu_item_reply');
|
||||||
|
}
|
||||||
|
menuItems.push('menu_item_forward');
|
||||||
|
|
||||||
|
this.props.showContextMenu({
|
||||||
|
seq: this.props.seq,
|
||||||
|
content: this.props.content,
|
||||||
|
userFrom: this.props.userFrom,
|
||||||
|
userName: this.props.userName,
|
||||||
|
forwarded: this.props.forwarded,
|
||||||
|
y: e.pageY,
|
||||||
|
x: e.pageX,
|
||||||
|
pickReply: this.props.pickReply
|
||||||
|
}, menuItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleProgress(ratio) {
|
handleProgress(ratio) {
|
||||||
@@ -72,18 +102,27 @@ export default class ChatMessage extends React.Component {
|
|||||||
this.props.onCancelUpload(this.props.seq, this.props.uploader);
|
this.props.onCancelUpload(this.props.seq, this.props.uploader);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleQuoteClick(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const replyToSeq = this.props.replyToSeq;
|
||||||
|
if (replyToSeq) {
|
||||||
|
this.props.onQuoteClick(replyToSeq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const sideClass = this.props.deleted ? 'center' :
|
const sideClass = this.props.deleted ? 'center' :
|
||||||
(this.props.sequence + ' ' + (this.props.response ? 'left' : 'right'));
|
(this.props.sequence + ' ' + (this.props.response ? 'left' : 'right'));
|
||||||
const bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ? 'bubble tip' : 'bubble';
|
const bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ? 'bubble tip' : 'bubble';
|
||||||
const avatar = this.props.deleted ? null : (this.props.userAvatar || true);
|
const avatar = this.props.deleted ? null : (this.props.userAvatar || true);
|
||||||
const fullDisplay = (this.props.userFrom && this.props.response && !this.props.deleted &&
|
const fullDisplay = (this.props.isGroup && this.props.response && !this.props.deleted &&
|
||||||
(this.props.sequence == 'single' || this.props.sequence == 'last'));
|
(this.props.sequence == 'single' || this.props.sequence == 'last'));
|
||||||
|
|
||||||
let content = this.props.content;
|
let content = this.props.content;
|
||||||
const attachments = [];
|
const attachments = [];
|
||||||
if (this.props.mimeType == Drafty.getContentType() && Drafty.isValid(content)) {
|
if (this.props.mimeType == Drafty.getContentType() && Drafty.isValid(content)) {
|
||||||
Drafty.attachments(content, function(att, i) {
|
Drafty.attachments(content, (att, i) => {
|
||||||
if (att.mime == 'application/json') {
|
if (att.mime == 'application/json') {
|
||||||
// Don't show json objects as attachments.
|
// Don't show json objects as attachments.
|
||||||
// They are not meant for users.
|
// They are not meant for users.
|
||||||
@@ -101,7 +140,8 @@ export default class ChatMessage extends React.Component {
|
|||||||
onError={this.props.onError}
|
onError={this.props.onError}
|
||||||
key={i} />);
|
key={i} />);
|
||||||
}, this);
|
}, this);
|
||||||
content = React.createElement(React.Fragment, null, Drafty.format(content, draftyFormatter, this));
|
const tree = Drafty.format(content, fullFormatter, this.formatterContext);
|
||||||
|
content = React.createElement(React.Fragment, null, tree);
|
||||||
} else if (this.props.deleted) {
|
} else if (this.props.deleted) {
|
||||||
// Message represents a range of deleted messages.
|
// Message represents a range of deleted messages.
|
||||||
content = <><i className="material-icons gray">block</i> <i className="gray">
|
content = <><i className="material-icons gray">block</i> <i className="gray">
|
||||||
@@ -109,19 +149,18 @@ export default class ChatMessage extends React.Component {
|
|||||||
defaultMessage="content deleted" description="Shown when messages are deleted" />
|
defaultMessage="content deleted" description="Shown when messages are deleted" />
|
||||||
</i></>
|
</i></>
|
||||||
} else if (typeof content != 'string') {
|
} else if (typeof content != 'string') {
|
||||||
content = <>
|
content = <><i className="material-icons gray">warning_amber</i> <i className="gray">
|
||||||
<i className="material-icons gray">error_outline</i> <i className="gray">
|
<FormattedMessage id="invalid_content"
|
||||||
<FormattedMessage id="invalid_content"
|
defaultMessage="invalid content" description="Shown when the message is unreadable" /></i></>
|
||||||
defaultMessage="invalid content" description="Shown when message is unreadable" /></i>
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={sideClass}>
|
<li ref={this.props.innerRef} className={sideClass}>
|
||||||
{this.props.userFrom && this.props.response ?
|
{this.props.isGroup && this.props.response ?
|
||||||
<div className="avatar-box">
|
<div className="avatar-box">
|
||||||
{fullDisplay ?
|
{fullDisplay ?
|
||||||
<LetterTile
|
<LetterTile
|
||||||
|
tinode={this.props.tinode}
|
||||||
topic={this.props.userFrom}
|
topic={this.props.userFrom}
|
||||||
title={this.props.userName}
|
title={this.props.userName}
|
||||||
avatar={avatar} /> :
|
avatar={avatar} /> :
|
||||||
@@ -152,10 +191,10 @@ export default class ChatMessage extends React.Component {
|
|||||||
</div>
|
</div>
|
||||||
{fullDisplay ?
|
{fullDisplay ?
|
||||||
<div className="author">
|
<div className="author">
|
||||||
<FormattedMessage id="user_not_found" defaultMessage="Not found"
|
{this.props.userName ||
|
||||||
description="In place of a user's full name when the user is not found.">{
|
<i><FormattedMessage id="user_not_found" defaultMessage="Not found"
|
||||||
(notFound) => {return this.props.userName || <i>{notFound}</i>}
|
description="In place of a user's full name when the user is not found." /></i>
|
||||||
}</FormattedMessage>
|
}
|
||||||
</div>
|
</div>
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
@@ -165,86 +204,7 @@ export default class ChatMessage extends React.Component {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert Drafty object to a tree of React elements.
|
const IntlChatMessage = injectIntl(BaseChatMessage);
|
||||||
import { BROKEN_IMAGE_SIZE, REM_SIZE } from '../config.js';
|
const ChatMessage = React.forwardRef((props, ref) => <IntlChatMessage innerRef = {ref} {...props} />);
|
||||||
import { fitImageSize } from '../lib/blob-helpers.js';
|
|
||||||
|
|
||||||
// Converts Drafty elements into React classes.
|
export default ChatMessage;
|
||||||
// 'this' is set by the caller.
|
|
||||||
function draftyFormatter(style, data, values, key) {
|
|
||||||
if (style == 'EX') {
|
|
||||||
// attachments are handled elsewhere.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let el = Drafty.tagName(style);
|
|
||||||
if (el) {
|
|
||||||
const attr = Drafty.attrValue(style, data) || {};
|
|
||||||
attr.key = key;
|
|
||||||
switch (style) {
|
|
||||||
case 'HL':
|
|
||||||
// Highlighted text. Assign class name.
|
|
||||||
attr.className = 'highlight';
|
|
||||||
break;
|
|
||||||
case 'IM':
|
|
||||||
// Additional processing for images
|
|
||||||
if (data) {
|
|
||||||
attr.className = 'inline-image';
|
|
||||||
const dim = fitImageSize(data.width, data.height,
|
|
||||||
Math.min(this.props.viewportWidth - REM_SIZE * 6.5, REM_SIZE * 34.5), REM_SIZE * 24, false) ||
|
|
||||||
{dstWidth: BROKEN_IMAGE_SIZE, dstHeight: BROKEN_IMAGE_SIZE};
|
|
||||||
attr.style = {
|
|
||||||
width: dim.dstWidth + 'px',
|
|
||||||
height: dim.dstHeight + 'px',
|
|
||||||
// Looks like a Chrome bug: broken image does not respect 'width' and 'height'.
|
|
||||||
minWidth: dim.dstWidth + 'px',
|
|
||||||
minHeight: dim.dstHeight + 'px'
|
|
||||||
};
|
|
||||||
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';
|
|
||||||
attr.loading = 'lazy';
|
|
||||||
} else {
|
|
||||||
attr.src = 'img/broken_image.png';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Use custom element instead of <img>.
|
|
||||||
el = UploadingImage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'BN':
|
|
||||||
// Button
|
|
||||||
attr.onClick = this.handleFormButtonClick;
|
|
||||||
let inner = React.Children.map(values, (child) => {
|
|
||||||
return typeof child == 'string' ? child : undefined;
|
|
||||||
});
|
|
||||||
if (!inner || inner.length == 0) {
|
|
||||||
inner = [attr.name]
|
|
||||||
}
|
|
||||||
// Get text which will be sent back when the button is clicked.
|
|
||||||
attr['data-title'] = inner.join('');
|
|
||||||
break;
|
|
||||||
case 'FM':
|
|
||||||
// Form
|
|
||||||
attr.className = 'bot-form';
|
|
||||||
break;
|
|
||||||
case 'RW':
|
|
||||||
// Form element formatting is dependent on element content.
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
if (el == '_UNKN') {
|
|
||||||
// Unknown element.
|
|
||||||
// TODO: make it prettier.
|
|
||||||
el = <><span className="material-icons">extension</span></>;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return React.createElement(el, attr, values);
|
|
||||||
} else {
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ export default class CheckBox extends React.PureComponent {
|
|||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
this.props.onChange ? (
|
this.props.onChange ? (
|
||||||
this.props.checked ?
|
this.props.checked === true ?
|
||||||
<i className="material-icons blue clickable" onClick={this.handleChange}>check_box</i> :
|
<i className="material-icons blue clickable" onClick={this.handleChange}>check_box</i> :
|
||||||
<i className="material-icons blue clickable" onClick={this.handleChange}>check_box_outline_blank</i>
|
this.props.checked === false ?
|
||||||
|
<i className="material-icons blue clickable" onClick={this.handleChange}>check_box_outline_blank</i> :
|
||||||
|
<i className="material-icons lt-blue">indeterminate_check_box</i>
|
||||||
) : (
|
) : (
|
||||||
this.props.checked ?
|
this.props.checked ?
|
||||||
<i className="material-icons">check_box</i> :
|
<i className="material-icons">check_box</i> :
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
|
|
||||||
import Chip from './chip.jsx';
|
import Chip from './chip.jsx';
|
||||||
|
|
||||||
import { makeImageDataUrl } from '../lib/blob-helpers.js';
|
import { makeImageUrl } from '../lib/blob-helpers.js';
|
||||||
|
|
||||||
/* BEGIN ChipInput: group membership widget */
|
/* BEGIN ChipInput: group membership widget */
|
||||||
export default class ChipInput extends React.Component {
|
export default class ChipInput extends React.Component {
|
||||||
@@ -118,8 +118,9 @@ export default class ChipInput extends React.Component {
|
|||||||
this.state.sortedChips.map((item) => {
|
this.state.sortedChips.map((item) => {
|
||||||
chips.push(
|
chips.push(
|
||||||
<Chip
|
<Chip
|
||||||
|
tinode={this.props.tinode}
|
||||||
onCancel={this.handleChipCancel}
|
onCancel={this.handleChipCancel}
|
||||||
avatar={makeImageDataUrl(item.public ? item.public.photo : null)}
|
avatar={makeImageUrl(item.public ? item.public.photo : null)}
|
||||||
title={item.public ? item.public.fn : undefined}
|
title={item.public ? item.public.fn : undefined}
|
||||||
noAvatar={this.props.avatarDisabled}
|
noAvatar={this.props.avatarDisabled}
|
||||||
topic={item.user}
|
topic={item.user}
|
||||||
@@ -131,6 +132,7 @@ export default class ChipInput extends React.Component {
|
|||||||
count++;
|
count++;
|
||||||
});
|
});
|
||||||
const className = "chip-input" + (this.state.focused ? ' focused' : '');
|
const className = "chip-input" + (this.state.focused ? ' focused' : '');
|
||||||
|
const autoFocus = !(this.props.tabIndex > 0);
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{chips}
|
{chips}
|
||||||
@@ -141,7 +143,8 @@ export default class ChipInput extends React.Component {
|
|||||||
onBlur={this.handleFocusLost}
|
onBlur={this.handleFocusLost}
|
||||||
onKeyDown={this.handleKeyDown}
|
onKeyDown={this.handleKeyDown}
|
||||||
value={this.state.input}
|
value={this.state.input}
|
||||||
autoFocus />
|
tabIndex={this.props.tabIndex}
|
||||||
|
autoFocus={autoFocus} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export default class Chip extends React.PureComponent {
|
|||||||
<span className="spacer" /> :
|
<span className="spacer" /> :
|
||||||
<div className="avatar-box">
|
<div className="avatar-box">
|
||||||
<LetterTile
|
<LetterTile
|
||||||
|
tinode={this.props.tinode}
|
||||||
avatar={this.props.avatar || true}
|
avatar={this.props.avatar || true}
|
||||||
topic={this.props.topic}
|
topic={this.props.topic}
|
||||||
title={this.props.title} />
|
title={this.props.title} />
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
const icon_mapping = {'muted': 'notifications_off', 'banned': 'block'};
|
const icon_mapping = {'muted': 'notifications_off', 'banned': 'block', 'staff': 'verified_user'};
|
||||||
|
|
||||||
/* Contact's labels: [you], or icons [muted], [blocked] */
|
/* Contact's labels: [you], or icons [muted], [blocked] */
|
||||||
export default class ContactBadges extends React.PureComponent {
|
export default class ContactBadges extends React.PureComponent {
|
||||||
render() {
|
render() {
|
||||||
let badges = null;
|
let badges = null;
|
||||||
if (this.props.badges && this.props.badges.length > 0) {
|
if (this.props.badges && this.props.badges.length > 0) {
|
||||||
badges = [];
|
badges = [];
|
||||||
this.props.badges.map(function(b) {
|
this.props.badges.map((b) => {
|
||||||
if (b.icon) {
|
const color = (b.color ? ' ' + b.color : '');
|
||||||
badges.push(<i className="material-icons as-badge" key={b.key || b.icon}>{icon_mapping[b.icon]}</i>);
|
if (b.icon) {
|
||||||
} else {
|
badges.push(<i className={'material-icons as-badge' + color} key={b.key || b.icon}>{icon_mapping[b.icon] || b.icon}</i>);
|
||||||
const style = 'badge' + (b.color ? ' ' + b.color : '');
|
} else {
|
||||||
badges.push(<span className={style} key={b.key || b.name}>{b.name}</span>);
|
badges.push(<span className={'badge' + color} key={b.key || b.name}>{b.name}</span>);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return <>{badges}</>;
|
return <>{badges}</>;
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import Tinode from 'tinode-sdk';
|
|||||||
import Contact from './contact.jsx';
|
import Contact from './contact.jsx';
|
||||||
import ContactAction from './contact-action.jsx';
|
import ContactAction from './contact-action.jsx';
|
||||||
|
|
||||||
import { makeImageDataUrl } from '../lib/blob-helpers.js';
|
import { makeImageUrl } from '../lib/blob-helpers.js';
|
||||||
|
|
||||||
import { MESSAGE_PREVIEW_LENGTH } from '../config.js';
|
import { MESSAGE_PREVIEW_LENGTH } from '../config.js';
|
||||||
|
|
||||||
@@ -77,22 +77,26 @@ class ContactList extends React.Component {
|
|||||||
const comment = Array.isArray(c.private) ?
|
const comment = Array.isArray(c.private) ?
|
||||||
c.private.join(',') : (c.private ? c.private.comment : null);
|
c.private.join(',') : (c.private ? c.private.comment : null);
|
||||||
let preview;
|
let preview;
|
||||||
|
let forwarded;
|
||||||
let deliveryStatus;
|
let deliveryStatus;
|
||||||
if (!this.props.showMode && c.latestMessage) {
|
if (!this.props.showMode && c.latestMessage) {
|
||||||
const msg = c.latestMessage(true);
|
const msg = c.latestMessage(true);
|
||||||
if (msg) {
|
if (msg) {
|
||||||
|
forwarded = msg.head ? msg.head.forwarded : null;
|
||||||
deliveryStatus = msg._status || c.msgStatus(msg, true);
|
deliveryStatus = msg._status || c.msgStatus(msg, true);
|
||||||
preview = typeof msg.content == 'string' ?
|
preview = typeof msg.content == 'string' ?
|
||||||
msg.content.substr(0, MESSAGE_PREVIEW_LENGTH) :
|
msg.content.substr(0, MESSAGE_PREVIEW_LENGTH) :
|
||||||
Drafty.preview(msg.content, MESSAGE_PREVIEW_LENGTH);
|
Drafty.preview(msg.content, MESSAGE_PREVIEW_LENGTH, undefined, forwarded != null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
contactNodes.push(
|
contactNodes.push(
|
||||||
<Contact
|
<Contact
|
||||||
|
tinode={this.props.tinode}
|
||||||
title={c.public ? c.public.fn : null}
|
title={c.public ? c.public.fn : null}
|
||||||
avatar={makeImageDataUrl(c.public ? c.public.photo : null)}
|
avatar={makeImageUrl(c.public ? c.public.photo : null)}
|
||||||
comment={comment}
|
comment={comment}
|
||||||
preview={preview}
|
preview={preview}
|
||||||
|
forwarded={forwarded}
|
||||||
received={deliveryStatus}
|
received={deliveryStatus}
|
||||||
unread={this.props.showUnread ? c.unread : 0}
|
unread={this.props.showUnread ? c.unread : 0}
|
||||||
now={c.online && this.props.connected}
|
now={c.online && this.props.connected}
|
||||||
@@ -105,6 +109,9 @@ class ContactList extends React.Component {
|
|||||||
isChannel={isChannel}
|
isChannel={isChannel}
|
||||||
onSelected={this.props.onTopicSelected}
|
onSelected={this.props.onTopicSelected}
|
||||||
showContextMenu={this.props.showContextMenu}
|
showContextMenu={this.props.showContextMenu}
|
||||||
|
isVerified={c.trusted && c.trusted.verified}
|
||||||
|
isStaff={c.trusted && c.trusted.staff}
|
||||||
|
isDangerous={c.trusted && c.trusted.danger}
|
||||||
item={key}
|
item={key}
|
||||||
index={contactNodes.length}
|
index={contactNodes.length}
|
||||||
key={key} />
|
key={key} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// A single topic or user.
|
// A single topic or user.
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
import { FormattedMessage, injectIntl } from 'react-intl';
|
||||||
|
|
||||||
import ContactBadges from './contact-badges.jsx';
|
import ContactBadges from './contact-badges.jsx';
|
||||||
import LetterTile from './letter-tile.jsx';
|
import LetterTile from './letter-tile.jsx';
|
||||||
@@ -9,26 +9,9 @@ import UnreadBadge from './unread-badge.jsx';
|
|||||||
|
|
||||||
import { Drafty } from 'tinode-sdk';
|
import { Drafty } from 'tinode-sdk';
|
||||||
|
|
||||||
|
import { previewFormatter } from '../lib/formatters.js';
|
||||||
import { deliveryMarker } from '../lib/utils.js';
|
import { deliveryMarker } from '../lib/utils.js';
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
drafty_image: {
|
|
||||||
id: 'drafty_image',
|
|
||||||
defaultMessage: 'Picture',
|
|
||||||
description: 'Comment for embedded images in drafty preview'
|
|
||||||
},
|
|
||||||
drafty_form: {
|
|
||||||
id: 'drafty_form',
|
|
||||||
defaultMessage: 'Form: ',
|
|
||||||
description: 'Comment for form in drafty preview'
|
|
||||||
},
|
|
||||||
drafty_attachment: {
|
|
||||||
id: 'drafty_attachment',
|
|
||||||
defaultMessage: 'Attachment',
|
|
||||||
description: 'Comment for attachment in drafty preview'
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
class Contact extends React.Component {
|
class Contact extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
@@ -65,6 +48,15 @@ class Contact extends React.Component {
|
|||||||
const avatar = this.props.avatar ? this.props.avatar : true;
|
const avatar = this.props.avatar ? this.props.avatar : true;
|
||||||
const badges = this.props.badges ? this.props.badges.slice() : [];
|
const badges = this.props.badges ? this.props.badges.slice() : [];
|
||||||
const icon_badges = [];
|
const icon_badges = [];
|
||||||
|
if (this.props.isVerified) {
|
||||||
|
icon_badges.push({icon: 'verified', color: 'verified-color'});
|
||||||
|
}
|
||||||
|
if (this.props.isStaff) {
|
||||||
|
icon_badges.push({icon: 'staff', color: 'staff-color'});
|
||||||
|
}
|
||||||
|
if (this.props.isDangerous) {
|
||||||
|
icon_badges.push({icon: 'dangerous', color: 'danger-color'});
|
||||||
|
}
|
||||||
if (this.props.acs) {
|
if (this.props.acs) {
|
||||||
if (this.props.showMode) {
|
if (this.props.showMode) {
|
||||||
badges.push({name: this.props.acs.getMode(), key: 'mode'});
|
badges.push({name: this.props.acs.getMode(), key: 'mode'});
|
||||||
@@ -79,7 +71,14 @@ class Contact extends React.Component {
|
|||||||
|
|
||||||
const subtitle = this.props.preview ?
|
const subtitle = this.props.preview ?
|
||||||
(typeof this.props.preview == 'string' ? this.props.preview :
|
(typeof this.props.preview == 'string' ? this.props.preview :
|
||||||
React.createElement(React.Fragment, null, Drafty.format(this.props.preview, draftyFormatter, this))) :
|
Drafty.isValid(this.props.preview) ?
|
||||||
|
React.createElement(React.Fragment, null, Drafty.format(this.props.preview, previewFormatter,
|
||||||
|
{formatMessage: this.props.intl.formatMessage})) :
|
||||||
|
<><i className="material-icons gray">warning_amber</i> <i className="gray">
|
||||||
|
<FormattedMessage id="invalid_content"
|
||||||
|
defaultMessage="invalid content" description="Shown when the message is unreadable" /></i>
|
||||||
|
</>
|
||||||
|
) :
|
||||||
this.props.comment;
|
this.props.comment;
|
||||||
|
|
||||||
const icon = deliveryMarker(this.props.received);
|
const icon = deliveryMarker(this.props.received);
|
||||||
@@ -90,6 +89,7 @@ class Contact extends React.Component {
|
|||||||
<li className={!this.props.showCheckmark && this.props.selected ? 'selected' : null} onClick={this.handleClick}>
|
<li className={!this.props.showCheckmark && this.props.selected ? 'selected' : null} onClick={this.handleClick}>
|
||||||
<div className="avatar-box">
|
<div className="avatar-box">
|
||||||
<LetterTile
|
<LetterTile
|
||||||
|
tinode={this.props.tinode}
|
||||||
avatar={avatar}
|
avatar={avatar}
|
||||||
title={this.props.title}
|
title={this.props.title}
|
||||||
topic={this.props.item} />
|
topic={this.props.item} />
|
||||||
@@ -101,11 +101,11 @@ class Contact extends React.Component {
|
|||||||
<div className="text-box">
|
<div className="text-box">
|
||||||
<div><span className="contact-title">{title}</span>
|
<div><span className="contact-title">{title}</span>
|
||||||
{this.props.isChannel ? <img src="/img/channel.png" className="channel" alt="channel" /> : null}
|
{this.props.isChannel ? <img src="/img/channel.png" className="channel" alt="channel" /> : null}
|
||||||
<UnreadBadge count={this.props.unread} /><ContactBadges badges={icon_badges} />
|
<ContactBadges badges={icon_badges} /><UnreadBadge count={this.props.unread} />
|
||||||
</div>
|
</div>
|
||||||
{this.props.showMode ?
|
{this.props.showMode ?
|
||||||
<span><ContactBadges badges={badges} /></span> :
|
<span><ContactBadges badges={badges} /></span> :
|
||||||
<div className="contact-comment">{marker}{subtitle || '\u00A0'}</div>}
|
this.props.small ? null : <div className="contact-comment">{marker}<span>{subtitle || '\u00A0'}</span></div>}
|
||||||
</div>
|
</div>
|
||||||
{this.props.showContextMenu ?
|
{this.props.showContextMenu ?
|
||||||
<span className="menuTrigger">
|
<span className="menuTrigger">
|
||||||
@@ -118,63 +118,4 @@ class Contact extends React.Component {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Converts Drafty object into a one-line preview.
|
|
||||||
function draftyFormatter(style, data, values, key) {
|
|
||||||
let el = Drafty.tagName(style);
|
|
||||||
const attr = { key: key };
|
|
||||||
if (el) {
|
|
||||||
const { formatMessage } = this.props.intl;
|
|
||||||
switch (style) {
|
|
||||||
case 'BR':
|
|
||||||
// Replace new line with a space.
|
|
||||||
el = React.Fragment;
|
|
||||||
values = [' '];
|
|
||||||
break;
|
|
||||||
case 'HL':
|
|
||||||
// Make highlight less prominent in preview.
|
|
||||||
attr.className = 'highlight preview';
|
|
||||||
break;
|
|
||||||
case 'LN':
|
|
||||||
// Disable links in previews.
|
|
||||||
el = 'span';
|
|
||||||
break;
|
|
||||||
case 'IM':
|
|
||||||
// Replace image with '[icon] Image'.
|
|
||||||
el = React.Fragment;
|
|
||||||
values = [<i key="im" className="material-icons">photo</i>, formatMessage(messages.drafty_image)];
|
|
||||||
break;
|
|
||||||
case 'BN':
|
|
||||||
el = 'span';
|
|
||||||
attr.className = 'flat-button faux';
|
|
||||||
break;
|
|
||||||
case 'FM':
|
|
||||||
el = React.Fragment;
|
|
||||||
values = [<i key="fm" className="material-icons">dashboard</i>,
|
|
||||||
formatMessage(messages.drafty_form)].concat(values || []);
|
|
||||||
break;
|
|
||||||
case 'RW':
|
|
||||||
el = React.Fragment;
|
|
||||||
break;
|
|
||||||
case 'EX':
|
|
||||||
if (data && data.mime == 'application/json') {
|
|
||||||
// Ignore JSON attachments: they are form response payloads.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
el = React.Fragment;
|
|
||||||
values = [<i key="ex" className="material-icons">attachment</i>, formatMessage(messages.drafty_attachment)];
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
if (el == '_UNKN') {
|
|
||||||
el = React.Fragment;
|
|
||||||
values = [<i key="unkn" className="material-icons">extension</i>];
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return React.createElement(el, attr, values);
|
|
||||||
} else {
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(Contact);
|
export default injectIntl(Contact);
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
// Context Menu: popup/dropdown menu.
|
// Context Menu: popup/dropdown menu.
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
|
||||||
import { injectIntl, defineMessages } from 'react-intl';
|
import { injectIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
import { REM_SIZE } from '../config.js';
|
import { REM_SIZE } from '../config.js';
|
||||||
import HashNavigation from '../lib/navigation.js';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
info: {
|
info: {
|
||||||
@@ -47,6 +45,16 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Unmute',
|
defaultMessage: 'Unmute',
|
||||||
description: 'Turn notifications on'
|
description: 'Turn notifications on'
|
||||||
},
|
},
|
||||||
|
reply: {
|
||||||
|
id: 'menu_item_reply',
|
||||||
|
defaultMessage: 'Reply',
|
||||||
|
description: 'Reply to message'
|
||||||
|
},
|
||||||
|
forward: {
|
||||||
|
id: 'menu_item_forward',
|
||||||
|
defaultMessage: 'Forward',
|
||||||
|
description: 'Forward message'
|
||||||
|
},
|
||||||
topic_delete: {
|
topic_delete: {
|
||||||
id: 'menu_item_delete_topic',
|
id: 'menu_item_delete_topic',
|
||||||
defaultMessage: 'Delete',
|
defaultMessage: 'Delete',
|
||||||
@@ -109,6 +117,8 @@ class ContextMenu extends React.Component {
|
|||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
|
this.selfRef = React.createRef();
|
||||||
|
|
||||||
const {formatMessage} = props.intl;
|
const {formatMessage} = props.intl;
|
||||||
|
|
||||||
this.handlePageClick = this.handlePageClick.bind(this);
|
this.handlePageClick = this.handlePageClick.bind(this);
|
||||||
@@ -171,6 +181,18 @@ class ContextMenu extends React.Component {
|
|||||||
return this.retryMessage(params, errorHandler);
|
return this.retryMessage(params, errorHandler);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
'menu_item_reply': {
|
||||||
|
id: 'menu_item_reply',
|
||||||
|
title: formatMessage(messages.reply),
|
||||||
|
handler: (params, errorHandler) => {
|
||||||
|
return this.replyToMessage(params, errorHandler);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'menu_item_forward': {
|
||||||
|
id: 'menu_item_forward',
|
||||||
|
title: formatMessage(messages.forward),
|
||||||
|
handler: () => {} /* the action is taken directly in tinode-web */
|
||||||
|
},
|
||||||
'topic_unmute': {
|
'topic_unmute': {
|
||||||
id: 'topic_unmute',
|
id: 'topic_unmute',
|
||||||
title: formatMessage(messages.unmute),
|
title: formatMessage(messages.unmute),
|
||||||
@@ -318,7 +340,7 @@ class ContextMenu extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handlePageClick(e) {
|
handlePageClick(e) {
|
||||||
if (ReactDOM.findDOMNode(this).contains(e.target)) {
|
if (this.selfRef.current.contains(e.target)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -342,7 +364,7 @@ class ContextMenu extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
console.log("Invalid menu item ID", e.currentTarget.dataset.id);
|
console.error("Invalid menu item ID", e.currentTarget.dataset.id);
|
||||||
} else {
|
} else {
|
||||||
this.props.onAction(
|
this.props.onAction(
|
||||||
item.id,
|
item.id,
|
||||||
@@ -411,9 +433,13 @@ class ContextMenu extends React.Component {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
replyToMessage(params, errorHandler) {
|
||||||
|
params.pickReply(params.seq, params.content, params.forwarded, params.userFrom, params.userName, errorHandler);
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const menu = [];
|
||||||
let count = 0;
|
let count = 0;
|
||||||
let menu = [];
|
|
||||||
this.props.items.map((item) => {
|
this.props.items.map((item) => {
|
||||||
if (typeof item == 'string') {
|
if (typeof item == 'string') {
|
||||||
item = this.MenuItems[item];
|
item = this.MenuItems[item];
|
||||||
@@ -445,7 +471,7 @@ class ContextMenu extends React.Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="menu" style={position}>
|
<ul className="menu" style={position} ref={this.selfRef}>
|
||||||
{menu}
|
{menu}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
// Image zoom and crop widget.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const DEFAULT_MAX_ZOOM = 2.5;
|
||||||
|
|
||||||
|
export default class Cropper extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
// Coordinates of the top left corner of the image in container coordinates.
|
||||||
|
panX: 0,
|
||||||
|
panY: 0,
|
||||||
|
// Origin of the zoom in image coordinates.
|
||||||
|
originX: 0,
|
||||||
|
originY: 0,
|
||||||
|
// Zoom scale.
|
||||||
|
zoom: 1,
|
||||||
|
// Zoom min and max.
|
||||||
|
minZoom: 0,
|
||||||
|
maxZoom: DEFAULT_MAX_ZOOM,
|
||||||
|
};
|
||||||
|
|
||||||
|
// References to DOM elements for hooking up events and getting screen dimensions.
|
||||||
|
// The transparent element which captures mouse moves (moves with the image).
|
||||||
|
this.overlay = React.createRef();
|
||||||
|
// The image cutout window or view port (static).
|
||||||
|
this.cutout = React.createRef();
|
||||||
|
// Scaled and translated image preview.
|
||||||
|
this.preview = React.createRef();
|
||||||
|
// The main container (static).
|
||||||
|
this.boundingBox = React.createRef();
|
||||||
|
|
||||||
|
// The original image width and height before any scaling.
|
||||||
|
this.imageWidth = 0;
|
||||||
|
this.imageHeight = 0;
|
||||||
|
|
||||||
|
// Mouse position when dragging.
|
||||||
|
this.mouseX = 0
|
||||||
|
this.mouseY = 0;
|
||||||
|
// Length of the previous mouse move when dragging.
|
||||||
|
this.prevDistance = 0;
|
||||||
|
|
||||||
|
// Bounding rectangles of static elements.
|
||||||
|
this.cutoutRect = {};
|
||||||
|
this.bBoxRect = {};
|
||||||
|
// Center of the bounding box.
|
||||||
|
this.originX = 0;
|
||||||
|
this.originY = 0;
|
||||||
|
|
||||||
|
this.initScaling = this.initScaling.bind(this);
|
||||||
|
this.onZoom = this.onZoom.bind(this);
|
||||||
|
this.handleZoom = this.handleZoom.bind(this);
|
||||||
|
this.mouseDown = this.mouseDown.bind(this);
|
||||||
|
this.mouseUp = this.mouseUp.bind(this);
|
||||||
|
this.mouseMove = this.mouseMove.bind(this);
|
||||||
|
this.mouseTouch = this.mouseTouch.bind(this);
|
||||||
|
this.positionAll = this.positionAll.bind(this);
|
||||||
|
this.translate = this.translate.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.overlay.current.addEventListener('mousedown', this.mouseDown, { passive: true });
|
||||||
|
this.overlay.current.addEventListener('touchstart', this.mouseDown, { passive: true });
|
||||||
|
|
||||||
|
// The rectangle position is in viewport coordinates.
|
||||||
|
this.bBoxRect = this.boundingBox.current.getBoundingClientRect();
|
||||||
|
// Center of the bounding box in container coordinates.
|
||||||
|
this.originX = this.bBoxRect.width / 2;
|
||||||
|
this.originY = this.bBoxRect.height / 2;
|
||||||
|
|
||||||
|
// The rectangle position is in viewport coordinates.
|
||||||
|
this.cutoutRect = this.cutout.current.getBoundingClientRect();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.overlay.current.removeEventListener('mousedown', this.mouseDown);
|
||||||
|
this.overlay.current.removeEventListener('touchstart', this.mouseDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position all elements.
|
||||||
|
positionAll(panX, panY, zoom) {
|
||||||
|
// Zoom origin in image preview coordinates.
|
||||||
|
this.setState({
|
||||||
|
panX: panX,
|
||||||
|
panY: panY,
|
||||||
|
zoom: zoom,
|
||||||
|
originX: this.originX - panX,
|
||||||
|
originY: this.originY - panY,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pass cut out coordinates to caller.
|
||||||
|
const left = (this.originX - panX) * zoom - this.originX;
|
||||||
|
const top = (this.originY - panY) * zoom - this.originY;
|
||||||
|
this.props.onChange(
|
||||||
|
(left + this.cutoutRect.left - this.bBoxRect.left) / zoom,
|
||||||
|
(top + this.cutoutRect.top - this.bBoxRect.top) / zoom,
|
||||||
|
this.cutoutRect.width / zoom,
|
||||||
|
this.cutoutRect.height / zoom,
|
||||||
|
zoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if new location is within the limits.
|
||||||
|
static checkBound(currPan, img, cutout, delta) {
|
||||||
|
let nextDiff = Math.min(0, cutout[0] - img[0] - delta, img[1] - cutout[1] + delta);
|
||||||
|
if (nextDiff == 0) {
|
||||||
|
// Cutout is completely within the image.
|
||||||
|
currPan += delta;
|
||||||
|
} else if (Math.min(0, cutout[0] - img[0], img[1] - cutout[1]) < nextDiff) {
|
||||||
|
// Cutout is outside of the range but closer to the bound.
|
||||||
|
currPan += delta;
|
||||||
|
}
|
||||||
|
return currPan;
|
||||||
|
}
|
||||||
|
|
||||||
|
initScaling() {
|
||||||
|
// Initialize components.
|
||||||
|
const imgRect = this.preview.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
this.imageWidth = imgRect.width;
|
||||||
|
this.imageHeight = imgRect.height;
|
||||||
|
|
||||||
|
// Set zoom slider's min and max values. Make sure the scaled image cannot be smaller than the cutout.
|
||||||
|
const minZoom = Math.max(this.cutoutRect.width / imgRect.width, this.cutoutRect.height / imgRect.height);
|
||||||
|
this.setState({
|
||||||
|
minZoom: minZoom,
|
||||||
|
maxZoom: Math.max(DEFAULT_MAX_ZOOM, minZoom + 1)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial zoom level fills the bounding box at the smallest image dimension and overflows the largest, i.e. "fill" not "fit".
|
||||||
|
const zoom = Math.max(this.bBoxRect.width / imgRect.width, this.bBoxRect.height / imgRect.height);
|
||||||
|
// Converting from viewport coordinates to container, then panning.
|
||||||
|
const panX = this.cutoutRect.left - this.bBoxRect.left -
|
||||||
|
(imgRect.width - this.cutoutRect.width) / 2;
|
||||||
|
const panY = this.cutoutRect.top - this.bBoxRect.top -
|
||||||
|
(imgRect.height - this.cutoutRect.height) / 2;
|
||||||
|
|
||||||
|
this.positionAll(panX, panY, zoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
onZoom(e) {
|
||||||
|
this.handleZoom(e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleZoom(zoom) {
|
||||||
|
let panX = this.state.panX;
|
||||||
|
let panY = this.state.panY;
|
||||||
|
|
||||||
|
// Ensure that the image at the new zoom is not outside of the cutout boundaries.
|
||||||
|
// Calculate image coordinates at the new zoom.
|
||||||
|
const imgLeft = this.originX - (this.originX - panX) * zoom;
|
||||||
|
const imgRight = imgLeft + this.imageWidth * zoom;
|
||||||
|
// Cutout in the same coordinates as the image.
|
||||||
|
const coLeft = this.cutoutRect.left - this.bBoxRect.left;
|
||||||
|
const coRight = coLeft + this.cutoutRect.width;
|
||||||
|
if (coLeft < imgLeft) {
|
||||||
|
panX -= imgLeft - coLeft;
|
||||||
|
} else if (coRight > imgRight) {
|
||||||
|
panX += coRight - imgRight;
|
||||||
|
}
|
||||||
|
const imgTop = this.originY - (this.originY - panY) * zoom;
|
||||||
|
const imgBottom = imgTop + this.imageHeight * zoom;
|
||||||
|
const coTop = this.cutoutRect.top - this.bBoxRect.top;
|
||||||
|
const coBottom = coTop + this.cutoutRect.height;
|
||||||
|
if (coTop < imgTop) {
|
||||||
|
panY -= imgTop - coTop;
|
||||||
|
} else if (coBottom > imgBottom) {
|
||||||
|
panY += coBottom - imgBottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.positionAll(panX, panY, zoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
mouseDown(e) {
|
||||||
|
if (e.touches) {
|
||||||
|
this.mouseX = e.touches[0].pageX;
|
||||||
|
this.mouseY = e.touches[0].pageY;
|
||||||
|
} else {
|
||||||
|
this.mouseX = e.pageX;
|
||||||
|
this.mouseY = e.pageY;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', this.mouseMove, { passive: false });
|
||||||
|
window.addEventListener('touchmove', this.mouseTouch, { passive: false });
|
||||||
|
window.addEventListener('mouseup', this.mouseUp, { passive: true });
|
||||||
|
window.addEventListener('touchend', this.mouseUp, { passive: true });
|
||||||
|
// Disable text selection in the entire document.
|
||||||
|
document.body.style['userSelect'] = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform image panning.
|
||||||
|
translate(pageX, pageY) {
|
||||||
|
const dX = pageX - this.mouseX;
|
||||||
|
const dY = pageY - this.mouseY;
|
||||||
|
|
||||||
|
this.mouseX = pageX;
|
||||||
|
this.mouseY = pageY;
|
||||||
|
|
||||||
|
// Make sure the image is within the cutout window.
|
||||||
|
const imgRect = this.preview.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Check if the new position is within the boundaries, and if not if it's closer to them.
|
||||||
|
let panX = Cropper.checkBound(this.state.panX, [imgRect.left, imgRect.right],
|
||||||
|
[this.cutoutRect.left, this.cutoutRect.right], dX);
|
||||||
|
let panY = Cropper.checkBound(this.state.panY, [imgRect.top, imgRect.bottom],
|
||||||
|
[this.cutoutRect.top, this.cutoutRect.bottom], dY);
|
||||||
|
|
||||||
|
this.positionAll(panX, panY, this.state.zoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image panning.
|
||||||
|
mouseMove(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.translate(e.pageX, e.pageY);
|
||||||
|
}
|
||||||
|
|
||||||
|
mouseTouch(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (e.touches.length == 1) {
|
||||||
|
this.translate(e.touches[0].pageX, e.touches[0].pageY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image zooming by pinching.
|
||||||
|
const [touch0, touch1] = e.touches;
|
||||||
|
const distance = Math.sqrt((touch0.pageX - touch1.pageX) * (touch0.pageX - touch1.pageX) +
|
||||||
|
(touch0.pageY - touch1.pageY) * (touch0.pageY - touch1.pageY));
|
||||||
|
|
||||||
|
if (!this.prevDistance) {
|
||||||
|
this.prevDistance = distance / this.state.zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scale = (distance / this.prevDistance);
|
||||||
|
this.handleZoom(Math.max(this.minZoom, Math.min(this.maxZoom, scale)));
|
||||||
|
}
|
||||||
|
|
||||||
|
mouseUp(e) {
|
||||||
|
window.removeEventListener('mousemove', this.mouseMove);
|
||||||
|
window.removeEventListener('touchmove', this.mouseTouch);
|
||||||
|
window.removeEventListener('mouseup', this.mouseUp);
|
||||||
|
window.removeEventListener('touchend', this.mouseUp);
|
||||||
|
// Re-enable text selection.
|
||||||
|
document.body.style['userSelect'] = '';
|
||||||
|
|
||||||
|
this.positionAll(this.state.panX, this.state.panY, this.state.zoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
// transform3d: position and scale.
|
||||||
|
const t3d = `translate3d(${this.state.panX}px, ${this.state.panY}px, 0) scale(${this.state.zoom})`;
|
||||||
|
// transformOrigin: zoom origin.
|
||||||
|
const orig = `${this.state.originX}px ${this.state.originY}px`;
|
||||||
|
|
||||||
|
// Overlay position and size are exactly equal to the position and size of the transformed image
|
||||||
|
// except it uses different coordinates.
|
||||||
|
const overlay = {
|
||||||
|
top: `${this.originY - this.state.originY * this.state.zoom}px`,
|
||||||
|
left: `${this.originX - this.state.originX * this.state.zoom}px`,
|
||||||
|
width: `${this.imageWidth * this.state.zoom}px`,
|
||||||
|
height: `${this.imageHeight * this.state.zoom}px`
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="cropper">
|
||||||
|
<div className="bounding-box" ref={this.boundingBox}>
|
||||||
|
<img src={this.props.source} className="preview" alt=""
|
||||||
|
style={{transform: t3d, transformOrigin: orig}} ref={this.preview} onLoad={this.initScaling} />
|
||||||
|
<div className="cutout circle" ref={this.cutout} />
|
||||||
|
<div className="overlay" style={overlay} ref={this.overlay} />
|
||||||
|
</div>
|
||||||
|
<div className="zoom-wrapper">
|
||||||
|
<input type="range" className="zoomer"
|
||||||
|
step="0.001" min={this.state.minZoom} max={this.state.maxZoom} value={this.state.zoom} onChange={this.onZoom} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import SendMessage from '../widgets/send-message.jsx';
|
import SendMessage from '../widgets/send-message.jsx';
|
||||||
|
|
||||||
import { bytesToHumanSize } from '../lib/strformat.js';
|
import { bytesToHumanSize, shortenFileName } from '../lib/strformat.js';
|
||||||
|
|
||||||
// Get material icon name from mime type.
|
// Get material icon name from mime type.
|
||||||
function iconFromMime(mime) {
|
function iconFromMime(mime) {
|
||||||
@@ -31,12 +31,14 @@ export default class DocPreview extends React.PureComponent {
|
|||||||
return (
|
return (
|
||||||
<div id="image-preview">
|
<div id="image-preview">
|
||||||
<div id="image-preview-caption-panel">
|
<div id="image-preview-caption-panel">
|
||||||
<span>{this.props.content.filename}</span>
|
<span>{this.props.content.name}</span>
|
||||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a>
|
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a>
|
||||||
</div>
|
</div>
|
||||||
<div id="image-preview-container">
|
<div id="image-preview-container">
|
||||||
<div className="flex-column narrow">
|
<div className="flex-column narrow">
|
||||||
<i className="material-icons gray">{iconFromMime(this.props.content.type)}</i>
|
<i className="material-icons gray">{iconFromMime(this.props.content.type)}</i>
|
||||||
|
<div><b><FormattedMessage id="label_file_name" defaultMessage="File name:"
|
||||||
|
description="Label for a file name" /></b> {shortenFileName(this.props.content.name, 24) || '-'}</div>
|
||||||
<div><b><FormattedMessage id="label_content_type" defaultMessage="Content type:"
|
<div><b><FormattedMessage id="label_content_type" defaultMessage="Content type:"
|
||||||
description="Label for file content type (mime)" /></b> {this.props.content.type || 'application/octet-stream'}</div>
|
description="Label for file content type (mime)" /></b> {this.props.content.type || 'application/octet-stream'}</div>
|
||||||
<div><b><FormattedMessage id="label_size" defaultMessage="Size:"
|
<div><b><FormattedMessage id="label_size" defaultMessage="Size:"
|
||||||
@@ -45,6 +47,9 @@ export default class DocPreview extends React.PureComponent {
|
|||||||
</div>
|
</div>
|
||||||
<SendMessage
|
<SendMessage
|
||||||
noInput={true}
|
noInput={true}
|
||||||
|
tinode={this.props.tinode}
|
||||||
|
reply={this.props.reply}
|
||||||
|
onCancelReply={this.props.onCancelReply}
|
||||||
onSendMessage={this.handleSendDoc}
|
onSendMessage={this.handleSendDoc}
|
||||||
onError={this.props.onError} />
|
onError={this.props.onError} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Forward Menu: message forwarding popup/dropdown menu.
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import Tinode from 'tinode-sdk';
|
||||||
|
|
||||||
|
import ContactList from './contact-list.jsx';
|
||||||
|
import SearchContacts from './search-contacts.jsx';
|
||||||
|
|
||||||
|
export default class ForwardDialog extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
query: null
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleEscapeKey = this.handleEscapeKey.bind(this);
|
||||||
|
this.handleClose = this.handleClose.bind(this);
|
||||||
|
|
||||||
|
this.handleSearchContacts = this.handleSearchContacts.bind(this);
|
||||||
|
this.handleContactSelected = this.handleContactSelected.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.props.onInitFind();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEscapeKey(e) {
|
||||||
|
if (e.keyCode === 27) {
|
||||||
|
// Not forwarding the message.
|
||||||
|
this.props.hide(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClose(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.props.hide(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSearchContacts(query) {
|
||||||
|
this.setState({ query: Tinode.isNullValue(query) ? null : query });
|
||||||
|
this.props.onSearchContacts(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleContactSelected(uid) {
|
||||||
|
this.props.onTopicSelected(uid);
|
||||||
|
this.props.hide(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let contacts = this.state.query != null ? this.props.searchResults : this.props.contacts;
|
||||||
|
// Filter out contacts without a 'W' or 'J' permissions as well as the current topic.
|
||||||
|
contacts = contacts.filter((c) => {
|
||||||
|
return (c.name != this.props.topicSelected) && c.acs.isJoiner() && c.acs.isWriter();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="alert-container">
|
||||||
|
<div className="forward-dialog">
|
||||||
|
<div className="title with-control">
|
||||||
|
<div><FormattedMessage id="forward_to" defaultMessage="Forward to"
|
||||||
|
desription="Title of the contact selector dialog when forwarding a message" /></div>
|
||||||
|
<div><a href="#" onClick={this.handleClose}><i className="material-icons">close</i></a></div>
|
||||||
|
</div>
|
||||||
|
<FormattedMessage id="forward_to_search_placeholder" defaultMessage="Search contacts"
|
||||||
|
description="Contact search prompt when forwarding a message.">{
|
||||||
|
(search_placeholder) => <SearchContacts
|
||||||
|
placeholder={search_placeholder}
|
||||||
|
onSearchContacts={this.handleSearchContacts} />
|
||||||
|
}</FormattedMessage>
|
||||||
|
<FormattedMessage id="search_no_results" defaultMessage="Search returned no results"
|
||||||
|
description="Text shown in contacts view when query returned no results.">{
|
||||||
|
(not_found_placeholder) => <ContactList
|
||||||
|
tinode={this.props.tinode}
|
||||||
|
contacts={contacts}
|
||||||
|
myUserId={this.props.myUserId}
|
||||||
|
emptyListMessage={not_found_placeholder}
|
||||||
|
showOnline={false}
|
||||||
|
showUnread={false}
|
||||||
|
showContextMenu={false}
|
||||||
|
onTopicSelected={this.handleContactSelected} />
|
||||||
|
}</FormattedMessage>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -165,6 +165,7 @@ class GroupManager extends React.Component {
|
|||||||
</div>
|
</div>
|
||||||
<div className="panel-form-row">
|
<div className="panel-form-row">
|
||||||
<ChipInput
|
<ChipInput
|
||||||
|
tinode={this.props.tinode}
|
||||||
chips={this.state.members}
|
chips={this.state.members}
|
||||||
staticMembers={this.state.staticMembers}
|
staticMembers={this.state.staticMembers}
|
||||||
prompt="add members"
|
prompt="add members"
|
||||||
@@ -178,6 +179,7 @@ class GroupManager extends React.Component {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<ContactList
|
<ContactList
|
||||||
|
tinode={this.props.tinode}
|
||||||
contacts={this.props.contacts}
|
contacts={this.props.contacts}
|
||||||
myUserId={this.props.myUserId}
|
myUserId={this.props.myUserId}
|
||||||
topicSelected={this.state.selectedContacts}
|
topicSelected={this.state.selectedContacts}
|
||||||
@@ -188,12 +190,12 @@ class GroupManager extends React.Component {
|
|||||||
showUnread={false}
|
showUnread={false}
|
||||||
onTopicSelected={this.handleContactSelected} />
|
onTopicSelected={this.handleContactSelected} />
|
||||||
<div id="group-manager-buttons" className="panel-form-row">
|
<div id="group-manager-buttons" className="panel-form-row">
|
||||||
<button className="primary" onClick={this.handleSubmit}>
|
|
||||||
<FormattedMessage id="button_ok" defaultMessage="OK" description="Button [OK]" />
|
|
||||||
</button>
|
|
||||||
<button className="secondary" onClick={this.handleCancel}>
|
<button className="secondary" onClick={this.handleCancel}>
|
||||||
<FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" />
|
<FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" />
|
||||||
</button>
|
</button>
|
||||||
|
<button className="primary" onClick={this.handleSubmit}>
|
||||||
|
<FormattedMessage id="button_ok" defaultMessage="OK" description="Button [OK]" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { FormattedMessage } from 'react-intl';
|
|||||||
import { MAX_ONLINE_IN_TOPIC } from '../config.js'
|
import { MAX_ONLINE_IN_TOPIC } from '../config.js'
|
||||||
|
|
||||||
import LetterTile from './letter-tile.jsx';
|
import LetterTile from './letter-tile.jsx';
|
||||||
import { makeImageDataUrl } from '../lib/blob-helpers.js';
|
import { makeImageUrl } from '../lib/blob-helpers.js';
|
||||||
|
|
||||||
export default class GroupSubs extends React.Component {
|
export default class GroupSubs extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -22,8 +22,9 @@ export default class GroupSubs extends React.Component {
|
|||||||
usersOnline.push(
|
usersOnline.push(
|
||||||
<div className="avatar-box" key={sub.user}>
|
<div className="avatar-box" key={sub.user}>
|
||||||
<LetterTile
|
<LetterTile
|
||||||
|
tinode={this.props.tinode}
|
||||||
topic={sub.user}
|
topic={sub.user}
|
||||||
avatar={makeImageDataUrl(sub.public ? sub.public.photo : null) || true}
|
avatar={makeImageUrl(sub.public ? sub.public.photo : null) || true}
|
||||||
title={sub.public ? sub.public.fn : null} />
|
title={sub.public ? sub.public.fn : null} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import SendMessage from '../widgets/send-message.jsx';
|
|||||||
|
|
||||||
import { REM_SIZE } from '../config.js';
|
import { REM_SIZE } from '../config.js';
|
||||||
import { fitImageSize } from '../lib/blob-helpers.js';
|
import { fitImageSize } from '../lib/blob-helpers.js';
|
||||||
import { bytesToHumanSize } from '../lib/strformat.js';
|
import { bytesToHumanSize, shortenFileName } from '../lib/strformat.js';
|
||||||
|
|
||||||
export default class ImagePreview extends React.PureComponent {
|
export default class ImagePreview extends React.PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -45,34 +45,35 @@ export default class ImagePreview extends React.PureComponent {
|
|||||||
size.maxWidth = '100%';
|
size.maxWidth = '100%';
|
||||||
size.maxHeight = '100%';
|
size.maxHeight = '100%';
|
||||||
|
|
||||||
let filename = this.props.content.filename;
|
|
||||||
// Average font aspect ratio is ~0.5; File name takes 1/3 of the viewport width.
|
// Average font aspect ratio is ~0.5; File name takes 1/3 of the viewport width.
|
||||||
const maxlength = Math.max(((this.state.width / REM_SIZE / 1.5) | 0) - 2, 12);
|
const maxlength = Math.max(((this.state.width / REM_SIZE / 1.5) | 0) - 2, 12);
|
||||||
if (filename.length > maxlength) {
|
const fname = shortenFileName(this.props.content.name, maxlength) || '-';
|
||||||
filename = filename.slice(0, maxlength/2 - 1) + '…' + filename.slice(1 - maxlength/2);
|
|
||||||
}
|
|
||||||
const width = this.props.content.width || '-';
|
const width = this.props.content.width || '-';
|
||||||
const height = this.props.content.height || '-';
|
const height = this.props.content.height || '-';
|
||||||
return (
|
return (
|
||||||
<div id="image-preview">
|
<div id="image-preview">
|
||||||
<div id="image-preview-caption-panel">
|
<div id="image-preview-caption-panel">
|
||||||
{!this.props.onSendMessage ?
|
{!this.props.onSendMessage ?
|
||||||
<a href={this.props.content.url} download={this.props.content.filename}>
|
<a href={this.props.content.url} download={this.props.content.name}>
|
||||||
<i className="material-icons">file_download</i> <FormattedMessage
|
<i className="material-icons">file_download</i> <FormattedMessage
|
||||||
id="download_action" defaultMessage="download" description="Call to action [download]" />
|
id="download_action" defaultMessage="download" description="Call to action [download]" />
|
||||||
</a>
|
</a>
|
||||||
:
|
:
|
||||||
<span>{this.props.content.filename}</span>
|
<span>{fname}</span>
|
||||||
}
|
}
|
||||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a>
|
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a>
|
||||||
</div>
|
</div>
|
||||||
<div id="image-preview-container" ref={(node) => this.assignWidth(node)}>
|
<div id="image-preview-container" ref={(node) => this.assignWidth(node)}>
|
||||||
<img src={this.props.content.url} style={size} className="image-preview" alt={this.props.content.filename} />
|
<img src={this.props.content.url} style={size} className="image-preview" alt={this.props.content.name} />
|
||||||
</div>
|
</div>
|
||||||
{this.props.onSendMessage ?
|
{this.props.onSendMessage ?
|
||||||
<SendMessage
|
<SendMessage
|
||||||
messagePrompt="add_image_caption"
|
messagePrompt="add_image_caption"
|
||||||
acceptBlank={true}
|
acceptBlank={true}
|
||||||
|
tinode={this.props.tinode}
|
||||||
|
reply={this.props.reply}
|
||||||
|
onCancelReply={this.props.onCancelReply}
|
||||||
onSendMessage={this.handleSendImage}
|
onSendMessage={this.handleSendImage}
|
||||||
onError={this.props.onError} />
|
onError={this.props.onError} />
|
||||||
:
|
:
|
||||||
@@ -80,7 +81,7 @@ export default class ImagePreview extends React.PureComponent {
|
|||||||
<div>
|
<div>
|
||||||
<div><b><FormattedMessage id="label_file_name" defaultMessage="File name:"
|
<div><b><FormattedMessage id="label_file_name" defaultMessage="File name:"
|
||||||
description="Label for a file name" /></b></div>
|
description="Label for a file name" /></b></div>
|
||||||
<div><span title={this.props.content.filename}>{filename ? filename : '-'}</span></div>
|
<div><span title={this.props.content.name}>{fname}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div><b><FormattedMessage id="label_content_type" defaultMessage="Content type:"
|
<div><b><FormattedMessage id="label_content_type" defaultMessage="Content type:"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// In-place text editor. Shows text with an icon which toggles it into an input field.
|
// In-place text editor. Shows text with an icon which toggles it into an input field.
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
|
||||||
|
|
||||||
import VisiblePassword from './visible-password.jsx';
|
import VisiblePassword from './visible-password.jsx';
|
||||||
|
|
||||||
@@ -8,6 +7,8 @@ export default class InPlaceEdit extends React.Component {
|
|||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
|
this.selfRef = React.createRef();
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
active: props.active,
|
active: props.active,
|
||||||
initialValue: props.value || '',
|
initialValue: props.value || '',
|
||||||
@@ -34,7 +35,7 @@ export default class InPlaceEdit extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handeTextChange(e) {
|
handeTextChange(e) {
|
||||||
this.setState({value: e.target.value});
|
this.setState({value: e.target.value || ''});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleKeyDown(e) {
|
handleKeyDown(e) {
|
||||||
@@ -47,21 +48,24 @@ export default class InPlaceEdit extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleStartEditing() {
|
handleStartEditing() {
|
||||||
if (!this.props.readOnly) {
|
if (!this.props.readOnly) {
|
||||||
ReactDOM.findDOMNode(this).focus();
|
this.setState({active: true}, () => {
|
||||||
this.setState({active: true});
|
if (this.selfRef.current) {
|
||||||
|
this.selfRef.current.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleEditingFinished(event) {
|
handleEditingFinished(event) {
|
||||||
if (this.props.required && !event.target.checkValidity()) {
|
const value = this.state.value.trim();
|
||||||
|
if (this.props.required && (!event.target.checkValidity() || !value)) {
|
||||||
// Empty input
|
// Empty input
|
||||||
this.setState({value: this.props.value, active: false});
|
this.setState({value: this.props.value, active: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.setState({active: false});
|
this.setState({active: false});
|
||||||
let value = this.state.value.trim();
|
|
||||||
if ((value || this.props.value) && (value !== this.props.value)) {
|
if ((value || this.props.value) && (value !== this.props.value)) {
|
||||||
this.props.onFinished(value);
|
this.props.onFinished(value);
|
||||||
}
|
}
|
||||||
@@ -75,46 +79,47 @@ export default class InPlaceEdit extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (this.state.active) {
|
if (!this.state.active) {
|
||||||
var fieldType = this.props.type || 'text';
|
let spanText = this.props.type == 'password' ? '••••••••' : this.state.value;
|
||||||
} else {
|
let spanClass = 'in-place-edit' + (this.props.readOnly ? ' disabled' : '');
|
||||||
var spanText = this.props.type == 'password' ? '••••••••' : this.state.value;
|
|
||||||
var spanClass = 'in-place-edit' +
|
|
||||||
(this.props.readOnly ? ' disabled' : '');
|
|
||||||
if (!spanText) {
|
if (!spanText) {
|
||||||
spanText = this.props.placeholder;
|
spanText = this.props.placeholder;
|
||||||
spanClass += ' placeholder';
|
spanClass += ' placeholder';
|
||||||
}
|
}
|
||||||
if (spanText.length > 20) {
|
if (!this.props.multiline || this.props.multiline == 1) {
|
||||||
// FIXME: this is wrong for RTL languages.
|
spanClass += ' short';
|
||||||
spanText = spanText.substring(0, 19) + '...';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (<span className={spanClass} onClick={this.handleStartEditing}>
|
||||||
|
<span>{spanText}</span>
|
||||||
|
</span>);
|
||||||
}
|
}
|
||||||
return (
|
|
||||||
this.state.active ?
|
let element;
|
||||||
(fieldType == 'password' ?
|
const attr = {};
|
||||||
<VisiblePassword
|
if (this.props.type == 'password') {
|
||||||
value={this.state.value}
|
element = VisiblePassword;
|
||||||
placeholder={this.props.placeholder}
|
attr.onFinished = this.handlePasswordFinished;
|
||||||
required={this.props.required ? 'required' : ''}
|
} else {
|
||||||
autoComplete={this.props.autoComplete}
|
if (this.props.multiline > 1) {
|
||||||
autoFocus={true}
|
element = 'textarea';
|
||||||
onFinished={this.handlePasswordFinished}/>
|
attr.rows = this.props.multiline;
|
||||||
:
|
attr.className = 'inplace-edit';
|
||||||
<input type={fieldType}
|
} else {
|
||||||
value={this.state.value}
|
element = 'input';
|
||||||
placeholder={this.props.placeholder}
|
attr.type = this.props.type || 'text';
|
||||||
required={this.props.required ? 'required' : ''}
|
}
|
||||||
autoComplete={this.props.autoComplete}
|
attr.value = this.state.value;
|
||||||
autoFocus
|
attr.onChange = this.handeTextChange;
|
||||||
onChange={this.handeTextChange}
|
attr.onKeyDown = this.handleKeyDown;
|
||||||
onKeyDown={this.handleKeyDown}
|
attr.onBlur = this.handleEditingFinished;
|
||||||
onBlur={this.handleEditingFinished} />
|
}
|
||||||
)
|
attr.placeholder = this.props.placeholder;
|
||||||
:
|
attr.required = this.props.required ? 'required' : '';
|
||||||
<span className={spanClass} onClick={this.handleStartEditing}>
|
attr.autoComplete = this.props.autoComplete;
|
||||||
<span className="content">{spanText}</span>
|
attr.autoFocus = true;
|
||||||
</span>
|
attr.ref = this.selfRef;
|
||||||
);
|
|
||||||
|
return React.createElement(element, attr, null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Image with a placeholder which is replaced when the promise is resolved.
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
export default class LazyImage extends React.PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
src: 'img/placeholder.png',
|
||||||
|
style: Object.assign({padding: '4px'}, this.props.style),
|
||||||
|
className: this.props.className,
|
||||||
|
alt: this.props.alt,
|
||||||
|
onClick: this.props.onClick,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// whenDone is a wrapper around an actual promise to be able to cancel it.
|
||||||
|
this.props.whenDone
|
||||||
|
.promise
|
||||||
|
.then(data => this.setState({src: data.src, style: {...this.state.style, padding: 0}}))
|
||||||
|
.catch(() => this.setState({src: 'img/broken_image.png'}));
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.props.whenDone.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (prevProps.whenDone != this.props.whenDone) {
|
||||||
|
this.setState({src: 'img/placeholder.png', style: {...this.state.style, padding: '4px'}});
|
||||||
|
this.props.whenDone
|
||||||
|
.then(data => this.setState({src: data.src, style: {...this.state.style, padding: 0}}))
|
||||||
|
.catch(() => this.setState({src: 'img/broken_image.png'}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return React.createElement('img', this.state);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,15 +2,15 @@ import React from 'react';
|
|||||||
|
|
||||||
import Tinode from 'tinode-sdk';
|
import Tinode from 'tinode-sdk';
|
||||||
|
|
||||||
import { stringHash } from '../lib/strformat.js';
|
import { idToColorClass } from '../lib/strformat.js';
|
||||||
|
import { sanitizeImageUrl } from '../lib/utils.js';
|
||||||
|
|
||||||
export default class LetterTile extends React.PureComponent {
|
export default class LetterTile extends React.PureComponent {
|
||||||
render() {
|
render() {
|
||||||
let avatar;
|
let avatar;
|
||||||
if (this.props.avatar === true) {
|
if (this.props.avatar === true) {
|
||||||
const isGroup = (Tinode.topicType(this.props.topic) == 'grp');
|
const isGroup = (Tinode.topicType(this.props.topic) == 'grp');
|
||||||
const iconColor = (isGroup ? 'light-color' : 'dark-color')
|
const iconColor = idToColorClass(this.props.topic, isGroup);
|
||||||
+ (Math.abs(stringHash(this.props.topic)) % 16);
|
|
||||||
if (this.props.topic && this.props.title && this.props.title.trim()) {
|
if (this.props.topic && this.props.title && this.props.title.trim()) {
|
||||||
const letter = this.props.title.trim().charAt(0);
|
const letter = this.props.title.trim().charAt(0);
|
||||||
const className = 'lettertile ' + iconColor;
|
const className = 'lettertile ' + iconColor;
|
||||||
@@ -21,9 +21,10 @@ export default class LetterTile extends React.PureComponent {
|
|||||||
<i className={className}>group</i> : <i className={className}>person</i>;
|
<i className={className}>group</i> : <i className={className}>person</i>;
|
||||||
}
|
}
|
||||||
} else if (this.props.avatar) {
|
} else if (this.props.avatar) {
|
||||||
|
const url = this.props.tinode.authorizeURL(sanitizeImageUrl(this.props.avatar));
|
||||||
// If avatar image is invalid, show a placeholder.
|
// If avatar image is invalid, show a placeholder.
|
||||||
avatar = <img className="avatar" alt="avatar" src={this.props.avatar}
|
avatar = <img className="avatar" alt="avatar" src={url}
|
||||||
onError={(e)=>{e.target.onerror = null; e.target.src="/img/broken_image.png"}} />;
|
onError={(e)=>{e.target.onerror = null; e.target.src="../img/broken_image.png"}} />;
|
||||||
} else {
|
} else {
|
||||||
avatar = null;
|
avatar = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import React from 'react';
|
|||||||
|
|
||||||
export default class LoadSpinner extends React.PureComponent {
|
export default class LoadSpinner extends React.PureComponent {
|
||||||
render() {
|
render() {
|
||||||
|
const className = 'load-spinner-box' + (this.props.large ? ' large' : '')
|
||||||
|
+ (this.props.clear ? ' clear' : '') + (this.props.centered ? ' centered' : '');
|
||||||
return (this.props.show ?
|
return (this.props.show ?
|
||||||
<div className="load-spinner-box"><div className="loader-spinner"></div></div> : null);
|
<div className={className}><div className="loader-spinner"></div></div> : null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
// Toggle [Title text >] -> [Title text v]
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default class MoreButton extends React.PureComponent {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
open: props.open
|
|
||||||
};
|
|
||||||
this.handleToggle = this.handleToggle.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleToggle() {
|
|
||||||
const open = !this.state.open;
|
|
||||||
this.setState({open: open});
|
|
||||||
if (this.props.onToggle) {
|
|
||||||
this.props.onToggle(open);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (<label className="small clean-clickable" onClick={this.handleToggle}>{this.props.title}...
|
|
||||||
{this.state.open ? <i className="material-icons">expand_more</i> :
|
|
||||||
<i className="material-icons">chevron_right</i>}
|
|
||||||
</label>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,34 +5,36 @@ import AvatarUpload from './avatar-upload.jsx';
|
|||||||
import CheckBox from './checkbox.jsx';
|
import CheckBox from './checkbox.jsx';
|
||||||
import TagManager from './tag-manager.jsx';
|
import TagManager from './tag-manager.jsx';
|
||||||
|
|
||||||
import { MAX_TITLE_LENGTH } from '../config.js';
|
import { MAX_TITLE_LENGTH, MAX_TOPIC_DESCRIPTION_LENGTH } from '../config.js';
|
||||||
|
|
||||||
export default class NewTopicGroup extends React.PureComponent {
|
export default class NewTopicGroup extends React.PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
|
this.fullName = React.createRef();
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
fn: '', // full/formatted name
|
fullName: '', // full/formatted name
|
||||||
private: '',
|
private: '',
|
||||||
|
description: '',
|
||||||
imageDataUrl: null,
|
imageDataUrl: null,
|
||||||
tags: [],
|
tags: [],
|
||||||
isChannel: false
|
isChannel: false
|
||||||
};
|
};
|
||||||
|
|
||||||
this.handleFnChange = this.handleFnChange.bind(this);
|
this.handleFieldEdit = this.handleFieldEdit.bind(this);
|
||||||
this.handlePrivateChange = this.handlePrivateChange.bind(this);
|
|
||||||
this.handleImageChanged = this.handleImageChanged.bind(this);
|
this.handleImageChanged = this.handleImageChanged.bind(this);
|
||||||
this.handleTagsChanged = this.handleTagsChanged.bind(this);
|
this.handleTagsChanged = this.handleTagsChanged.bind(this);
|
||||||
this.handleChannelToggle = this.handleChannelToggle.bind(this);
|
this.handleChannelToggle = this.handleChannelToggle.bind(this);
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleFnChange(e) {
|
componentDidMount() {
|
||||||
this.setState({fn: e.target.value});
|
// this.fullName.current.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePrivateChange(e) {
|
handleFieldEdit(name, e) {
|
||||||
this.setState({private: e.target.value});
|
this.setState({[name]: e.target.value || ''});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleImageChanged(img) {
|
handleImageChanged(img) {
|
||||||
@@ -50,10 +52,11 @@ export default class NewTopicGroup extends React.PureComponent {
|
|||||||
handleSubmit(e) {
|
handleSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const fn = this.state.fn.trim().substring(0, MAX_TITLE_LENGTH);
|
const fn = this.state.fullName.trim().substring(0, MAX_TITLE_LENGTH);
|
||||||
const comment = this.state.private.trim().substring(0, MAX_TITLE_LENGTH);
|
const comment = this.state.private.trim().substring(0, MAX_TITLE_LENGTH);
|
||||||
|
const description = this.state.description.trim().substring(0, MAX_TOPIC_DESCRIPTION_LENGTH);
|
||||||
if (fn) {
|
if (fn) {
|
||||||
this.props.onSubmit(fn, this.state.imageDataUrl, comment, this.state.tags, this.state.isChannel);
|
this.props.onSubmit(fn, description, this.state.imageDataUrl, comment, this.state.tags, this.state.isChannel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,8 +67,14 @@ export default class NewTopicGroup extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<form className="panel-form" onSubmit={this.handleSubmit}>
|
<form className="panel-form" onSubmit={this.handleSubmit}>
|
||||||
<div className="panel-form-row">
|
<div className="panel-form-column">
|
||||||
<div className="panel-form-column">
|
<center>
|
||||||
|
<AvatarUpload
|
||||||
|
tinode={this.props.tinode}
|
||||||
|
onError={this.props.onError}
|
||||||
|
onImageChanged={this.handleImageChanged} />
|
||||||
|
</center>
|
||||||
|
<div className="group">
|
||||||
<label className="small" htmlFor="new-topic-fn">
|
<label className="small" htmlFor="new-topic-fn">
|
||||||
<FormattedMessage id="label_topic_name" defaultMessage="Name"
|
<FormattedMessage id="label_topic_name" defaultMessage="Name"
|
||||||
description="Label for editing topic name" />
|
description="Label for editing topic name" />
|
||||||
@@ -73,9 +82,11 @@ export default class NewTopicGroup extends React.PureComponent {
|
|||||||
<FormattedMessage id="topic_name_editing_placeholder" defaultMessage="Freeform name of the group"
|
<FormattedMessage id="topic_name_editing_placeholder" defaultMessage="Freeform name of the group"
|
||||||
description="Prompt for entering topic name">{
|
description="Prompt for entering topic name">{
|
||||||
(placeholder) => <input type="text" id="new-topic-fn" placeholder={placeholder}
|
(placeholder) => <input type="text" id="new-topic-fn" placeholder={placeholder}
|
||||||
value={this.state.fn} onChange={this.handleFnChange} autoFocus required />
|
ref={this.fullName} value={this.state.fullName} onChange={this.handleFieldEdit.bind(this, 'fullName')}
|
||||||
|
autoFocus required tabIndex={0} />
|
||||||
}</FormattedMessage>
|
}</FormattedMessage>
|
||||||
<br />
|
</div>
|
||||||
|
<div className="group">
|
||||||
<label className="small" htmlFor="new-topic-priv">
|
<label className="small" htmlFor="new-topic-priv">
|
||||||
<FormattedMessage id="label_private" defaultMessage="Private comment"
|
<FormattedMessage id="label_private" defaultMessage="Private comment"
|
||||||
description="Label for editing 'private'" />
|
description="Label for editing 'private'" />
|
||||||
@@ -83,26 +94,35 @@ export default class NewTopicGroup extends React.PureComponent {
|
|||||||
<FormattedMessage id="private_editing_placeholder" defaultMessage="Visible to you only"
|
<FormattedMessage id="private_editing_placeholder" defaultMessage="Visible to you only"
|
||||||
description="Placeholder for editing 'private'">{
|
description="Placeholder for editing 'private'">{
|
||||||
(placeholder) => <input type="text" id="new-topic-priv" placeholder={placeholder}
|
(placeholder) => <input type="text" id="new-topic-priv" placeholder={placeholder}
|
||||||
value={this.state.private} onChange={this.handlePrivateChange} />
|
value={this.state.private} onChange={this.handleFieldEdit.bind(this, 'private')} tabIndex={1} />
|
||||||
|
}</FormattedMessage>
|
||||||
|
</div>
|
||||||
|
<div className="group">
|
||||||
|
<label className="small" htmlFor="new-topic-desc">
|
||||||
|
<FormattedMessage id="label_description" defaultMessage="Description"
|
||||||
|
description="Label for editing topic description" />
|
||||||
|
</label>
|
||||||
|
<FormattedMessage id="description_editing_placeholder" defaultMessage="Description (optional)"
|
||||||
|
description="Placeholder for editing topic description">{
|
||||||
|
(placeholder) => <input type="text" id="new-topic-desc" placeholder={placeholder}
|
||||||
|
value={this.state.description} onChange={this.handleFieldEdit.bind(this, 'description')} tabIndex={2} />
|
||||||
}</FormattedMessage>
|
}</FormattedMessage>
|
||||||
</div>
|
</div>
|
||||||
<AvatarUpload
|
|
||||||
onError={this.props.onError}
|
|
||||||
onImageChanged={this.handleImageChanged} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-form-row">
|
<div className="panel-form-row">
|
||||||
<CheckBox checked={this.state.isChannel} onChange={this.handleChannelToggle}/>
|
<CheckBox checked={this.state.isChannel} tabIndex={3} onChange={this.handleChannelToggle} />
|
||||||
<label onClick={this.handleChannelToggle}><FormattedMessage id="channel_prompt"
|
<label onClick={this.handleChannelToggle}><FormattedMessage id="channel_prompt"
|
||||||
defaultMessage="This is a channel"
|
defaultMessage="This is a channel"
|
||||||
description="Checkbox label when creating a channel" /></label>
|
description="Checkbox label when creating a channel" /></label>
|
||||||
</div>
|
</div>
|
||||||
<FormattedMessage id="title_tag_manager" defaultMessage="Tags (user discovery)"
|
<FormattedMessage id="title_tag_manager" defaultMessage="Tags (search & discovery)"
|
||||||
description="Section title for TagManager">{
|
description="Section title for TagManager">{
|
||||||
(title) => <TagManager
|
(title) => <TagManager
|
||||||
|
tinode={this.props.tinode}
|
||||||
tags={this.state.tags}
|
tags={this.state.tags}
|
||||||
activated={true}
|
activated={true}
|
||||||
onTagsChanged={this.handleTagsChanged}
|
onTagsChanged={this.handleTagsChanged}
|
||||||
tinode={this.props.tinode}
|
tabIndex={4}
|
||||||
title={title} />
|
title={title} />
|
||||||
}</FormattedMessage>
|
}</FormattedMessage>
|
||||||
<div className="dialog-buttons">
|
<div className="dialog-buttons">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
|||||||
import CheckBox from './checkbox.jsx';
|
import CheckBox from './checkbox.jsx';
|
||||||
import Contact from './contact.jsx';
|
import Contact from './contact.jsx';
|
||||||
|
|
||||||
import { makeImageDataUrl } from '../lib/blob-helpers.js';
|
import { makeImageUrl } from '../lib/blob-helpers.js';
|
||||||
|
|
||||||
// Translatable permission names.
|
// Translatable permission names.
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
@@ -67,7 +67,7 @@ class PermissionsEditor extends React.Component {
|
|||||||
|
|
||||||
handleChange(val) {
|
handleChange(val) {
|
||||||
let mode = this.state.mode;
|
let mode = this.state.mode;
|
||||||
let idx = mode.indexOf(val);
|
const idx = mode.indexOf(val);
|
||||||
if (idx == -1) {
|
if (idx == -1) {
|
||||||
mode += val;
|
mode += val;
|
||||||
} else {
|
} else {
|
||||||
@@ -78,8 +78,8 @@ class PermissionsEditor extends React.Component {
|
|||||||
|
|
||||||
handleSubmit() {
|
handleSubmit() {
|
||||||
// Normalize string, otherwise cannot check if mode has changed.
|
// Normalize string, otherwise cannot check if mode has changed.
|
||||||
var mode = (this.state.mode || 'N').split('').sort().join('');
|
const mode = (this.state.mode || 'N').split('').sort().join('');
|
||||||
var before = (this.props.mode || 'N').split('').sort().join('')
|
const before = (this.props.mode || 'N').split('').sort().join('')
|
||||||
if (mode !== before) {
|
if (mode !== before) {
|
||||||
this.props.onSubmit(mode);
|
this.props.onSubmit(mode);
|
||||||
} else {
|
} else {
|
||||||
@@ -132,10 +132,12 @@ class PermissionsEditor extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<div className="panel-form-column">
|
<div className="panel-form-column">
|
||||||
{this.props.userTitle ?
|
{this.props.userTitle ?
|
||||||
<ul className="contact-box"><Contact
|
<ul className="contact-box small"><Contact
|
||||||
|
tinode={this.props.tinode}
|
||||||
item={this.props.item}
|
item={this.props.item}
|
||||||
title={this.props.userTitle}
|
title={this.props.userTitle}
|
||||||
avatar={makeImageDataUrl(this.props.userAvatar ? this.props.userAvatar : null)} /></ul> : null}
|
small={true}
|
||||||
|
avatar={makeImageUrl(this.props.userAvatar ? this.props.userAvatar : null)} /></ul> : null}
|
||||||
<label className="small"><FormattedMessage id="title_permissions"
|
<label className="small"><FormattedMessage id="title_permissions"
|
||||||
defaultMessage="Permissions" description="Section title"/></label>
|
defaultMessage="Permissions" description="Section title"/></label>
|
||||||
<table className="permission-editor">
|
<table className="permission-editor">
|
||||||
|
|||||||
@@ -57,16 +57,14 @@ export default class SearchContacts extends React.PureComponent {
|
|||||||
<div className="panel-form">
|
<div className="panel-form">
|
||||||
<div className="panel-form-row">
|
<div className="panel-form-row">
|
||||||
<i className="material-icons search">search</i>
|
<i className="material-icons search">search</i>
|
||||||
<FormattedMessage id="search_placeholder" defaultMessage="List like email:alice@example.com, tel:17025550003..."
|
<input className="search" type="text"
|
||||||
description="Placeholder in contacts search field">{
|
placeholder={this.props.placeholder}
|
||||||
(search_placeholder) => <input className="search" type="text"
|
|
||||||
placeholder={search_placeholder}
|
|
||||||
value={this.state.search} onChange={this.handleSearchChange}
|
value={this.state.search} onChange={this.handleSearchChange}
|
||||||
onKeyDown={this.handleKeyDown} required autoFocus />
|
onKeyDown={this.handleKeyDown} required autoFocus />
|
||||||
}</FormattedMessage>
|
{this.state.search ?
|
||||||
<a href="#" onClick={this.handleClear}>
|
<a href="#" onClick={this.handleClear}><i className="material-icons">highlight_off</i></a>
|
||||||
<i className="material-icons">close</i>
|
:
|
||||||
</a>
|
<span><i className="material-icons"> </i></span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Drafty } from 'tinode-sdk';
|
|||||||
|
|
||||||
import { KEYPRESS_DELAY } from '../config.js';
|
import { KEYPRESS_DELAY } from '../config.js';
|
||||||
import { filePasted } from '../lib/blob-helpers.js';
|
import { filePasted } from '../lib/blob-helpers.js';
|
||||||
|
import { quoteFormatter, replyFormatter } from '../lib/formatters.js';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
messaging_disabled: {
|
messaging_disabled: {
|
||||||
@@ -50,12 +51,18 @@ class SendMessage extends React.PureComponent {
|
|||||||
this.handleSend = this.handleSend.bind(this);
|
this.handleSend = this.handleSend.bind(this);
|
||||||
this.handleKeyPress = this.handleKeyPress.bind(this);
|
this.handleKeyPress = this.handleKeyPress.bind(this);
|
||||||
this.handleMessageTyping = this.handleMessageTyping.bind(this);
|
this.handleMessageTyping = this.handleMessageTyping.bind(this);
|
||||||
|
|
||||||
|
this.handleQuoteClick = this.handleQuoteClick.bind(this);
|
||||||
|
|
||||||
|
this.formatReply = this.formatReply.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
if (this.messageEditArea) {
|
if (this.messageEditArea) {
|
||||||
this.messageEditArea.addEventListener('paste', this.handlePasteEvent, false);
|
this.messageEditArea.addEventListener('paste', this.handlePasteEvent, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.setState({quote: this.formatReply()});
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
@@ -64,10 +71,22 @@ class SendMessage extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate() {
|
componentDidUpdate(prevProps) {
|
||||||
if (this.messageEditArea) {
|
if (this.messageEditArea) {
|
||||||
this.messageEditArea.focus();
|
this.messageEditArea.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (prevProps.reply != this.props.reply) {
|
||||||
|
this.setState({quote: this.formatReply()});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatReply() {
|
||||||
|
return this.props.reply ?
|
||||||
|
Drafty.format(this.props.reply.content, replyFormatter, {
|
||||||
|
formatMessage: this.props.intl.formatMessage.bind(this.props.intl),
|
||||||
|
authorizeURL: this.props.tinode.authorizeURL.bind(this.props.tinode)
|
||||||
|
}) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePasteEvent(e) {
|
handlePasteEvent(e) {
|
||||||
@@ -141,46 +160,67 @@ class SendMessage extends React.PureComponent {
|
|||||||
this.setState(newState);
|
this.setState(newState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleQuoteClick(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (this.props.reply && this.props.onQuoteClick) {
|
||||||
|
const replyToSeq = this.props.reply.seq;
|
||||||
|
this.props.onQuoteClick(replyToSeq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {formatMessage} = this.props.intl;
|
const { formatMessage } = this.props.intl;
|
||||||
const prompt = this.props.disabled ?
|
const prompt = this.props.disabled ?
|
||||||
formatMessage(messages.messaging_disabled) :
|
formatMessage(messages.messaging_disabled) :
|
||||||
(this.props.messagePrompt ?
|
(this.props.messagePrompt ?
|
||||||
formatMessage(messages[this.props.messagePrompt]) :
|
formatMessage(messages[this.props.messagePrompt]) :
|
||||||
formatMessage(messages.type_new_message));
|
formatMessage(messages.type_new_message));
|
||||||
|
|
||||||
|
const quote =
|
||||||
|
(<div id="reply-quote-preview">
|
||||||
|
<div className="cancel">
|
||||||
|
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onCancelReply();}}><i className="material-icons gray">close</i></a>
|
||||||
|
</div>
|
||||||
|
{this.state.quote}
|
||||||
|
</div>);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="send-message-panel">
|
<div id="send-message-wrapper">
|
||||||
{!this.props.disabled ?
|
{this.state.quote && !this.props.noInput ? quote : null}
|
||||||
<>
|
<div id="send-message-panel">
|
||||||
{this.props.onAttachFile ?
|
{!this.props.disabled ?
|
||||||
<>
|
<>
|
||||||
<a href="#" onClick={(e) => {e.preventDefault(); this.attachImage.click();}} title="Add image">
|
{this.props.onAttachFile ?
|
||||||
<i className="material-icons secondary">photo</i>
|
<>
|
||||||
</a>
|
<a href="#" onClick={(e) => {e.preventDefault(); this.attachImage.click();}} title="Add image">
|
||||||
<a href="#" onClick={(e) => {e.preventDefault(); this.attachFile.click();}} title="Attach file">
|
<i className="material-icons secondary">photo</i>
|
||||||
<i className="material-icons secondary">attach_file</i>
|
</a>
|
||||||
</a>
|
<a href="#" onClick={(e) => {e.preventDefault(); this.attachFile.click();}} title="Attach file">
|
||||||
</>
|
<i className="material-icons secondary">attach_file</i>
|
||||||
:
|
</a>
|
||||||
null}
|
</>
|
||||||
{this.props.noInput ?
|
:
|
||||||
<div className="hr thin" /> :
|
null}
|
||||||
<textarea id="sendMessage" placeholder={prompt}
|
{this.props.noInput ?
|
||||||
value={this.state.message} onChange={this.handleMessageTyping}
|
(this.state.quote ? quote : <div className="hr thin" />) :
|
||||||
onKeyPress={this.handleKeyPress}
|
<textarea id="sendMessage" placeholder={prompt}
|
||||||
ref={(ref) => {this.messageEditArea = ref;}}
|
value={this.state.message} onChange={this.handleMessageTyping}
|
||||||
autoFocus />}
|
onKeyPress={this.handleKeyPress}
|
||||||
<a href="#" onClick={this.handleSend} title="Send">
|
ref={(ref) => {this.messageEditArea = ref;}}
|
||||||
<i className="material-icons">send</i>
|
autoFocus />}
|
||||||
</a>
|
<a href="#" onClick={this.handleSend} title="Send">
|
||||||
<input type="file" ref={(ref) => {this.attachFile = ref;}}
|
<i className="material-icons">send</i>
|
||||||
onChange={this.handleAttachFile} style={{display: 'none'}} />
|
</a>
|
||||||
<input type="file" ref={(ref) => {this.attachImage = ref;}} accept="image/*"
|
<input type="file" ref={(ref) => {this.attachFile = ref;}}
|
||||||
onChange={this.handleAttachImage} style={{display: 'none'}} />
|
onChange={this.handleAttachFile} style={{display: 'none'}} />
|
||||||
</>
|
<input type="file" ref={(ref) => {this.attachImage = ref;}} accept="image/*"
|
||||||
:
|
onChange={this.handleAttachImage} style={{display: 'none'}} />
|
||||||
<div id="writing-disabled">{prompt}</div>
|
</>
|
||||||
}
|
:
|
||||||
|
<div id="writing-disabled">{prompt}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,30 +2,44 @@ import React from 'react';
|
|||||||
|
|
||||||
import LetterTile from './letter-tile.jsx';
|
import LetterTile from './letter-tile.jsx';
|
||||||
import ButtonBack from './button-back.jsx';
|
import ButtonBack from './button-back.jsx';
|
||||||
|
import ContactBadges from './contact-badges.jsx';
|
||||||
import MenuContacts from './menu-contacts.jsx';
|
import MenuContacts from './menu-contacts.jsx';
|
||||||
import MenuStart from './menu-start.jsx';
|
import MenuStart from './menu-start.jsx';
|
||||||
|
|
||||||
|
import { sanitizeImageUrl } from '../lib/utils.js';
|
||||||
|
|
||||||
export default class SideNavbar extends React.PureComponent {
|
export default class SideNavbar extends React.PureComponent {
|
||||||
render() {
|
render() {
|
||||||
|
const icon_badges = [];
|
||||||
|
if (this.props.trustedBadges) {
|
||||||
|
this.props.trustedBadges.map((b) => {
|
||||||
|
icon_badges.push({icon: b, color: 'badge-inv'});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let avatar = null;
|
||||||
|
if (this.props.tinode) {
|
||||||
|
avatar = this.props.tinode.authorizeURL(sanitizeImageUrl(this.props.avatar));
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div id="side-caption-panel" className="caption-panel">
|
<div id="side-caption-panel" className="caption-panel">
|
||||||
{this.props.onCancel ? <ButtonBack onBack={this.props.onCancel} /> : null}
|
{this.props.onCancel ? <ButtonBack onBack={this.props.onCancel} /> : null}
|
||||||
{this.props.avatar ?
|
{avatar ?
|
||||||
<div id="self-avatar" className="avatar-box">
|
<div id="self-avatar" className="avatar-box">
|
||||||
<LetterTile
|
<LetterTile
|
||||||
avatar={this.props.avatar}
|
tinode={this.props.tinode}
|
||||||
topic={this.props.myUserId}
|
avatar={avatar}
|
||||||
title={this.props.title} />
|
topic={this.props.myUserId}
|
||||||
</div>
|
title={this.props.title} />
|
||||||
:
|
</div>
|
||||||
null}
|
:
|
||||||
<div id="sidepanel-title" className="panel-title">{this.props.title}</div>
|
null}
|
||||||
{this.props.state === 'login' ?
|
<div id="sidepanel-title" className="panel-title">{this.props.title}<ContactBadges badges={icon_badges}/></div>
|
||||||
<MenuStart onSignUp={this.props.onSignUp} onSettings={this.props.onSettings} /> :
|
{this.props.state === 'login' ?
|
||||||
this.props.state === 'contacts' ?
|
<MenuStart onSignUp={this.props.onSignUp} onSettings={this.props.onSettings} /> :
|
||||||
<MenuContacts onNewTopic={this.props.onNewTopic} onSettings={this.props.onSettings} /> :
|
this.props.state === 'contacts' ?
|
||||||
null}
|
<MenuContacts onNewTopic={this.props.onNewTopic} onSettings={this.props.onSettings} /> :
|
||||||
</div>
|
null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default class TagManager extends React.Component {
|
|||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
tags: this.props.tags,
|
tags: this.props.tags || [],
|
||||||
tagInput: '',
|
tagInput: '',
|
||||||
activated: this.props.activated
|
activated: this.props.activated
|
||||||
};
|
};
|
||||||
@@ -25,8 +25,9 @@ export default class TagManager extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static getDerivedStateFromProps(nextProps, prevState) {
|
static getDerivedStateFromProps(nextProps, prevState) {
|
||||||
if (!arrayEqual(nextProps.tags, prevState.tags) && !prevState.activated) {
|
const tags = nextProps.tags || [];
|
||||||
return {tags: nextProps.tags};
|
if (!arrayEqual(tags, prevState.tags) && !prevState.activated) {
|
||||||
|
return {tags: tags};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -75,11 +76,11 @@ export default class TagManager extends React.Component {
|
|||||||
handleSubmit() {
|
handleSubmit() {
|
||||||
// Add unprocessed input to tags, submit the list.
|
// Add unprocessed input to tags, submit the list.
|
||||||
this.props.onSubmit(this.handleAddTag(this.state.tagInput.trim()));
|
this.props.onSubmit(this.handleAddTag(this.state.tagInput.trim()));
|
||||||
this.setState({activated: false, tags: this.props.tags});
|
this.setState({activated: false, tags: this.props.tags || []});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCancel() {
|
handleCancel() {
|
||||||
this.setState({activated: false, tagInput: '', tags: this.props.tags});
|
this.setState({activated: false, tagInput: '', tags: this.props.tags || []});
|
||||||
if (this.props.onCancel) {
|
if (this.props.onCancel) {
|
||||||
this.props.onCancel();
|
this.props.onCancel();
|
||||||
}
|
}
|
||||||
@@ -116,9 +117,11 @@ export default class TagManager extends React.Component {
|
|||||||
<FormattedMessage id="tags_editor_no_tags" defaultMessage="Add some tags"
|
<FormattedMessage id="tags_editor_no_tags" defaultMessage="Add some tags"
|
||||||
description="Tag editor prompt when no tags are found.">{
|
description="Tag editor prompt when no tags are found.">{
|
||||||
(add_tags_prompt) => <ChipInput
|
(add_tags_prompt) => <ChipInput
|
||||||
|
tinode={this.props.tinode}
|
||||||
chips={tags}
|
chips={tags}
|
||||||
avatarDisabled={true}
|
avatarDisabled={true}
|
||||||
prompt={add_tags_prompt}
|
prompt={add_tags_prompt}
|
||||||
|
tabIndex={this.props.tabIndex}
|
||||||
onEnter={this.handleAddTag}
|
onEnter={this.handleAddTag}
|
||||||
onFocusLost={this.handleAddTag}
|
onFocusLost={this.handleAddTag}
|
||||||
onCancel={this.handleCancel}
|
onCancel={this.handleCancel}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// View for editing topic (including 'me' topic) parameters.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import AvatarUpload from '../widgets/avatar-upload.jsx';
|
||||||
|
import TopicDescEdit from '../widgets/topic-desc-edit.jsx';
|
||||||
|
|
||||||
|
import { arrayEqual, asEmail, asPhone } from '../lib/utils.js';
|
||||||
|
|
||||||
|
export default class TopicCommon extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||||
|
const acs = topic.getAccessMode();
|
||||||
|
this.state = {
|
||||||
|
isMe: this.props.topic == 'me',
|
||||||
|
owner: acs && acs.isOwner(),
|
||||||
|
credentials: (topic.getCredentials ? topic.getCredentials() : null) || [],
|
||||||
|
addCredActive: false,
|
||||||
|
addCredInvalid: false,
|
||||||
|
newCred: '',
|
||||||
|
tags: []
|
||||||
|
};
|
||||||
|
|
||||||
|
this.previousTagsUpdated = undefined;
|
||||||
|
|
||||||
|
this.onTagsUpdated = this.onTagsUpdated.bind(this);
|
||||||
|
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
|
||||||
|
this.tnCredsUpdated = this.tnCredsUpdated.bind(this);
|
||||||
|
this.handleCredChange = this.handleCredChange.bind(this);
|
||||||
|
this.handleCredKeyDown = this.handleCredKeyDown.bind(this);
|
||||||
|
this.handleCredEntered = this.handleCredEntered.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No need to separately handle component mount.
|
||||||
|
componentDidUpdate(props) {
|
||||||
|
const topic = this.props.tinode.getTopic(props.topic);
|
||||||
|
if (!topic) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
topic.onCredsUpdated = this.tnCredsUpdated;
|
||||||
|
|
||||||
|
if (topic.onTagsUpdated != this.onTagsUpdated) {
|
||||||
|
if (topic.getType() == 'grp') {
|
||||||
|
this.previousTagsUpdated = topic.onTagsUpdated;
|
||||||
|
topic.onTagsUpdated = this.onTagsUpdated;
|
||||||
|
} else {
|
||||||
|
this.previousTagsUpdated = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.topic != props.topic) {
|
||||||
|
this.setState({topic: props.topic});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||||
|
topic.onCredsUpdated = undefined;
|
||||||
|
topic.onTagsUpdated = this.previousTagsUpdated;
|
||||||
|
}
|
||||||
|
|
||||||
|
tnCredsUpdated(creds) {
|
||||||
|
this.setState({credentials: creds || []});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCredChange(e) {
|
||||||
|
this.setState({newCred: e.target.value, addCredInvalid: false});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCredKeyDown(e) {
|
||||||
|
if (e.keyCode === 27) {
|
||||||
|
// Escape pressed
|
||||||
|
this.setState({newCred: '', addCredActive: false});
|
||||||
|
} else if (e.keyCode === 13) {
|
||||||
|
// Enter pressed
|
||||||
|
this.handleCredEntered(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCredEntered(e) {
|
||||||
|
const value = this.state.newCred.trim();
|
||||||
|
if (!value) {
|
||||||
|
this.setState({addCredActive: false, addCredInvalid: false});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let val = asPhone(value);
|
||||||
|
let method;
|
||||||
|
if (val) {
|
||||||
|
method = 'tel';
|
||||||
|
} else {
|
||||||
|
val = asEmail(value);
|
||||||
|
if (val) {
|
||||||
|
method = 'email';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (method) {
|
||||||
|
this.props.onCredAdd(method, val);
|
||||||
|
this.setState({addCredActive: false, newCred: ''});
|
||||||
|
} else {
|
||||||
|
this.setState({addCredInvalid: true});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server informs that the tags have been updated.
|
||||||
|
onTagsUpdated(tags) {
|
||||||
|
this.setState({tags: tags});
|
||||||
|
|
||||||
|
if (this.previousTagsUpdated && this.previousTagsUpdated != this.onTagsUpdated) {
|
||||||
|
this.previousTagsUpdated(tags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request server to change tags.
|
||||||
|
handleTagsUpdated(tags) {
|
||||||
|
if (!arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
|
||||||
|
this.props.onTopicTagsUpdateRequest(this.props.topic, tags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const credentials = [];
|
||||||
|
if (this.state.isMe) {
|
||||||
|
this.state.credentials.map((cred) => {
|
||||||
|
credentials.push(
|
||||||
|
<div key={cred.meth + ":" + cred.val + ":" + cred.done}>{cred.meth}: <tt>{cred.val}</tt>
|
||||||
|
<span> {!cred.done ?
|
||||||
|
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onCredConfirm(cred.meth, cred.val);}}>
|
||||||
|
<FormattedMessage id="validate_credential_action" defaultMessage="confirm"
|
||||||
|
description="Validate credentail call to action" />
|
||||||
|
</a>
|
||||||
|
: null} <a href="#" onClick={(e) => {e.preventDefault(); this.props.onCredDelete(cred.meth, cred.val);}}><i
|
||||||
|
className="material-icons gray">delete_outline</i></a></span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="scrollable-panel">
|
||||||
|
<TopicDescEdit
|
||||||
|
tinode={this.props.tinode}
|
||||||
|
topic={this.props.topic}
|
||||||
|
onUpdateTopicDesc={this.props.onUpdateTopicDesc}
|
||||||
|
onUpdateTags={this.handleTagsUpdated}
|
||||||
|
onError={this.props.onError} />
|
||||||
|
{this.state.isMe ?
|
||||||
|
<>
|
||||||
|
<div className="hr" />
|
||||||
|
<div className="panel-form-column">
|
||||||
|
<label className="small">
|
||||||
|
<FormattedMessage id="label_user_contacts" defaultMessage="Contacts:"
|
||||||
|
description="Label for user contacts" />
|
||||||
|
</label>
|
||||||
|
<div className="quoted">
|
||||||
|
{credentials}
|
||||||
|
{this.state.addCredActive ?
|
||||||
|
<input type="text" value={this.state.value}
|
||||||
|
className={this.state.addCredInvalid ? 'invalid' : null}
|
||||||
|
placeholder="Phone number or email" required="required" autoFocus
|
||||||
|
onChange={this.handleCredChange} onKeyDown={this.handleCredKeyDown} onBlur={this.handleCredEntered} />
|
||||||
|
: null}
|
||||||
|
<div>
|
||||||
|
<a href="#" onClick={(e) => {e.preventDefault(); this.setState({addCredActive: true});}}>
|
||||||
|
<i className="material-icons">add</i>
|
||||||
|
<FormattedMessage id="button_add_another" defaultMessage="Add another" description="Call to action [+ add another]" />
|
||||||
|
</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
// Widget for editing topic description.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import Tinode from 'tinode-sdk';
|
||||||
|
|
||||||
|
import AvatarCrop from './avatar-crop.jsx';
|
||||||
|
import AvatarUpload from './avatar-upload.jsx';
|
||||||
|
import InPlaceEdit from './in-place-edit.jsx';
|
||||||
|
import TagManager from './tag-manager.jsx';
|
||||||
|
|
||||||
|
import { AVATAR_SIZE, MAX_AVATAR_BYTES, MAX_EXTERN_ATTACHMENT_SIZE,
|
||||||
|
MAX_INBAND_ATTACHMENT_SIZE, MAX_TITLE_LENGTH, MAX_TOPIC_DESCRIPTION_LENGTH } from '../config.js';
|
||||||
|
import { imageScaled, blobToBase64, makeImageUrl } from '../lib/blob-helpers.js';
|
||||||
|
import { arrayEqual, theCard } from '../lib/utils.js';
|
||||||
|
|
||||||
|
export default class TopicDescEdit extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||||
|
const acs = topic.getAccessMode();
|
||||||
|
this.state = {
|
||||||
|
isMe: Tinode.isMeTopicName(this.props.topic),
|
||||||
|
owner: acs && acs.isOwner(),
|
||||||
|
fullName: topic.public ? topic.public.fn : undefined,
|
||||||
|
private: topic.private ? topic.private.comment : null,
|
||||||
|
description: topic.public ? topic.public.note : undefined,
|
||||||
|
avatar: makeImageUrl(topic.public ? topic.public.photo : null),
|
||||||
|
tags: topic.tags() || [],
|
||||||
|
newAvatar: null,
|
||||||
|
newAvatarMime: null
|
||||||
|
};
|
||||||
|
|
||||||
|
this.previousOnTags = null;
|
||||||
|
|
||||||
|
this.tnNewTags = this.tnNewTags.bind(this);
|
||||||
|
this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this);
|
||||||
|
this.handleImageUpdated = this.handleImageUpdated.bind(this);
|
||||||
|
this.handleAvatarCropped = this.handleAvatarCropped.bind(this);
|
||||||
|
this.handlePrivateUpdate = this.handlePrivateUpdate.bind(this);
|
||||||
|
this.handleDescriptionUpdate = this.handleDescriptionUpdate.bind(this);
|
||||||
|
this.uploadAvatar = this.uploadAvatar.bind(this);
|
||||||
|
this.handleAvatarCropCancel = this.handleAvatarCropCancel.bind(this);
|
||||||
|
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||||
|
this.previousOnTags = topic.onTagsUpdated;
|
||||||
|
topic.onTagsUpdated = this.tnNewTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||||
|
topic.onTagsUpdated = this.previousOnTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
tnNewTags(tags) {
|
||||||
|
this.setState({tags: tags});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFullNameUpdate(fn) {
|
||||||
|
fn = fn.trim().substring(0, MAX_TITLE_LENGTH);
|
||||||
|
if (fn && this.state.fullName !== fn) {
|
||||||
|
this.setState({fullName: fn});
|
||||||
|
this.props.onUpdateTopicDesc(this.props.topic, theCard(fn, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePrivateUpdate(comment) {
|
||||||
|
comment = comment.trim().substring(0, MAX_TITLE_LENGTH);
|
||||||
|
if (this.state.private !== comment) {
|
||||||
|
this.setState({private: comment});
|
||||||
|
this.props.onUpdateTopicDesc(this.props.topic, null, comment || Tinode.DEL_CHAR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDescriptionUpdate(desc) {
|
||||||
|
desc = desc.trim().substring(0, MAX_TOPIC_DESCRIPTION_LENGTH);
|
||||||
|
if (desc) {
|
||||||
|
this.setState({description: desc});
|
||||||
|
this.props.onUpdateTopicDesc(this.props.topic, theCard(null, null, null, desc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AvatarUpload widget has changed the avatar: received a new file or deleted the avatar.
|
||||||
|
handleImageUpdated(mime, img) {
|
||||||
|
// This will show the AvatarCropView is img is not null.
|
||||||
|
this.setState({newAvatar: img, newAvatarMime: mime});
|
||||||
|
if (!img) {
|
||||||
|
// Deleting the avatar.
|
||||||
|
this.setState({avatar: null});
|
||||||
|
this.props.onUpdateTopicDesc(this.props.topic, theCard(null, Tinode.DEL_CHAR));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AvatarCropView calls this method when the user has cropped the image.
|
||||||
|
handleAvatarCropped(mime, blob, width, height) {
|
||||||
|
const url = blob ? URL.createObjectURL(blob) : null;
|
||||||
|
this.setState({avatar: url, newAvatar: null, newAvatarMime: null});
|
||||||
|
if (blob) {
|
||||||
|
this.uploadAvatar(mime, blob, width, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility method for converting cropped avatar blob to bytes for sending inband or
|
||||||
|
// for uploading it to the server out of band.
|
||||||
|
uploadAvatar(mime, blob, width, height) {
|
||||||
|
const readyToUpload = (image) => {
|
||||||
|
let {mime, blob, width, height} = image;
|
||||||
|
if (blob.size > MAX_AVATAR_BYTES) {
|
||||||
|
// Too large to send inband - uploading out of band and sending as a link.
|
||||||
|
const uploader = this.props.tinode.getLargeFileHelper();
|
||||||
|
|
||||||
|
this.setState({uploading: true});
|
||||||
|
uploader.upload(blob)
|
||||||
|
.then((url) => {
|
||||||
|
this.props.onUpdateTopicDesc(this.props.topic, theCard(null, url));
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
this.props.onError(err, 'err');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.setState({uploading: false});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.setState({uploading: true});
|
||||||
|
// Convert blob to base64-encoded bits.
|
||||||
|
blobToBase64(blob)
|
||||||
|
.then(b64 => {
|
||||||
|
const du = makeImageUrl({data: b64.bits, type: mime});
|
||||||
|
this.setState({source: du});
|
||||||
|
this.props.onUpdateTopicDesc(this.props.topic, theCard(null, du));
|
||||||
|
this.setState({uploading: false});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (width > AVATAR_SIZE || height > AVATAR_SIZE || width != height) {
|
||||||
|
// Avatar is not square or too large even after cropping. Shrink it and make square.
|
||||||
|
imageScaled(blob, AVATAR_SIZE, AVATAR_SIZE, MAX_EXTERN_ATTACHMENT_SIZE, true)
|
||||||
|
.then(scaled => readyToUpload(scaled))
|
||||||
|
.catch(err => this.props.onError(err, 'err'));
|
||||||
|
} else {
|
||||||
|
readyToUpload({mime: mime, blob: blob, width: width, height: height});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAvatarCropCancel(img) {
|
||||||
|
this.setState({newAvatar: null, newAvatarMime: null});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTagsUpdated(tags) {
|
||||||
|
// Check if tags have actually changed.
|
||||||
|
if (arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.props.onUpdateTags(tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.newAvatar) {
|
||||||
|
return (
|
||||||
|
<AvatarCrop
|
||||||
|
avatar={this.state.newAvatar}
|
||||||
|
mime={this.state.newAvatarMime}
|
||||||
|
onSubmit={this.handleAvatarCropped}
|
||||||
|
onCancel={this.handleAvatarCropCancel}
|
||||||
|
onError={this.props.onError} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const editable = this.state.isMe || this.state.owner;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="panel-form-column">
|
||||||
|
<center>
|
||||||
|
<AvatarUpload
|
||||||
|
tinode={this.props.tinode}
|
||||||
|
avatar={this.state.avatar}
|
||||||
|
readOnly={!editable}
|
||||||
|
uid={this.props.topic}
|
||||||
|
title={this.state.fullName}
|
||||||
|
onImageUpdated={this.handleImageUpdated}
|
||||||
|
onError={this.props.onError} />
|
||||||
|
</center>
|
||||||
|
{this.state.isMe ?
|
||||||
|
<div className="group">
|
||||||
|
<label className="small">
|
||||||
|
<FormattedMessage id="label_your_name" defaultMessage="Your name"
|
||||||
|
description="Label for full name editing" />
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<FormattedMessage id="full_name_prompt" defaultMessage="Full name, e.g. John Doe"
|
||||||
|
description="Input placeholder for person's full name">{
|
||||||
|
(full_name_placeholder) => <InPlaceEdit
|
||||||
|
placeholder={full_name_placeholder}
|
||||||
|
value={this.state.fullName}
|
||||||
|
required={true}
|
||||||
|
onFinished={this.handleFullNameUpdate} />
|
||||||
|
}</FormattedMessage>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
<>
|
||||||
|
<div className="group">
|
||||||
|
<div><label className="small">
|
||||||
|
<FormattedMessage id="label_topic_name" defaultMessage="Name"
|
||||||
|
description="Label for editing topic name" />
|
||||||
|
</label></div>
|
||||||
|
<div>
|
||||||
|
<FormattedMessage id="topic_name_editing_placeholder" defaultMessage="Freeform name of the group"
|
||||||
|
description="Prompt for entering topic name">{
|
||||||
|
(group_name_placeholder) => <InPlaceEdit
|
||||||
|
placeholder={group_name_placeholder}
|
||||||
|
readOnly={!editable}
|
||||||
|
value={this.state.fullName}
|
||||||
|
required={true}
|
||||||
|
onFinished={this.handleFullNameUpdate} />
|
||||||
|
}</FormattedMessage>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="group">
|
||||||
|
<div>
|
||||||
|
<label className="small">
|
||||||
|
<FormattedMessage id="label_private" defaultMessage="Private comment"
|
||||||
|
description="Label for editing 'private'" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FormattedMessage id="private_editing_placeholder" defaultMessage="Visible to you only"
|
||||||
|
description="Placeholder for editing 'private'">{
|
||||||
|
(private_placeholder) => <InPlaceEdit
|
||||||
|
placeholder={private_placeholder}
|
||||||
|
value={this.state.private}
|
||||||
|
onFinished={this.handlePrivateUpdate} />
|
||||||
|
}</FormattedMessage>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
{editable || this.state.description ?
|
||||||
|
<div className="group">
|
||||||
|
<div><label className="small">
|
||||||
|
<FormattedMessage id="label_description" defaultMessage="Description"
|
||||||
|
description="Label for editing topic description" />
|
||||||
|
</label></div>
|
||||||
|
<div>
|
||||||
|
<FormattedMessage id="description_editing_placeholder"
|
||||||
|
defaultMessage="Description (optional)"
|
||||||
|
description="Placeholder for editing topic description">{
|
||||||
|
(private_placeholder) => <InPlaceEdit
|
||||||
|
placeholder={private_placeholder}
|
||||||
|
readOnly={!editable}
|
||||||
|
value={this.state.description}
|
||||||
|
multiline={2}
|
||||||
|
onFinished={this.handleDescriptionUpdate} />
|
||||||
|
}</FormattedMessage>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{editable ?
|
||||||
|
<>
|
||||||
|
<div className="hr" />
|
||||||
|
<FormattedMessage id="title_tag_manager" defaultMessage="Tags (search & discovery)"
|
||||||
|
description="Section title for TagManager">{
|
||||||
|
(title_tag_manager) => <TagManager
|
||||||
|
tinode={this.props.tinode}
|
||||||
|
title={title_tag_manager}
|
||||||
|
activated={false}
|
||||||
|
tags={this.state.tags}
|
||||||
|
onSubmit={this.handleTagsUpdated} />
|
||||||
|
}</FormattedMessage>
|
||||||
|
</>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
// Edit account parameters.
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import InPlaceEdit from './in-place-edit.jsx';
|
||||||
|
|
||||||
|
import { NO_ACCESS_MODE } from '../config.js';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
clear_messages: {
|
||||||
|
id: 'action_clear_messages',
|
||||||
|
defaultMessage: 'Clear Messages',
|
||||||
|
description: 'Flat button [Clear Messages] (soft-delete messages)'
|
||||||
|
},
|
||||||
|
clear_messages_warning: {
|
||||||
|
id: 'clear_messages_warning',
|
||||||
|
defaultMessage: 'Are you sure you want to clear all messages? It cannot be undone.',
|
||||||
|
description: 'Alert dialog warning when deleting all messages.'
|
||||||
|
},
|
||||||
|
delete_messages: {
|
||||||
|
id: 'action_delete_messages',
|
||||||
|
defaultMessage: 'Clear Messages for All',
|
||||||
|
description: 'Flat button [Clear for All] (hard-delete all messages)'
|
||||||
|
},
|
||||||
|
delete_messages_warning: {
|
||||||
|
id: 'delete_messages_warning',
|
||||||
|
defaultMessage: 'Are you sure you want to delete all messages for everyone? It cannot be undone.',
|
||||||
|
description: 'Alert dialog warning when hard-deleting all messages.'
|
||||||
|
},
|
||||||
|
topic_delete: {
|
||||||
|
id: 'topic_delete',
|
||||||
|
defaultMessage: 'Delete Conversation',
|
||||||
|
description: 'Alert title when deleting the topic.'
|
||||||
|
},
|
||||||
|
topic_delete_warning: {
|
||||||
|
id: 'topic_delete_warning',
|
||||||
|
defaultMessage: 'Are you sure you want to delete this conversation? It cannot be undone.',
|
||||||
|
description: 'Alert warning when deleting entire topic'
|
||||||
|
},
|
||||||
|
leave_chat: {
|
||||||
|
id: 'action_leave_chat',
|
||||||
|
defaultMessage: 'Leave Conversation',
|
||||||
|
description: 'Flat button [Leave Conversation]'
|
||||||
|
},
|
||||||
|
leave_chat_warning: {
|
||||||
|
id: 'leave_chat_warning',
|
||||||
|
defaultMessage: 'Are you sure you want to leave this conversation?',
|
||||||
|
description: 'Alert dialog warning when unsubscribing from a chat.'
|
||||||
|
},
|
||||||
|
block_contact: {
|
||||||
|
id: 'action_block_contact',
|
||||||
|
defaultMessage: "Block Contact",
|
||||||
|
description: "Flat button [Block Contact]"
|
||||||
|
},
|
||||||
|
block_contact_warning: {
|
||||||
|
id: 'block_contact_warning',
|
||||||
|
defaultMessage: 'Are you sure you want to block this contact?',
|
||||||
|
description: 'Alert dialog warning when blocking a contact.'
|
||||||
|
},
|
||||||
|
report_chat: {
|
||||||
|
id: 'action_report_chat',
|
||||||
|
defaultMessage: 'Report Conversation',
|
||||||
|
description: 'Flat button [Report Group]'
|
||||||
|
},
|
||||||
|
report_chat_warning: {
|
||||||
|
id: 'report_chat_warning',
|
||||||
|
defaultMessage: 'Are you sure you want to block and report this conversation?',
|
||||||
|
description: 'Alert dialog warning when reporting a conversation for abuse'
|
||||||
|
},
|
||||||
|
other_user: {
|
||||||
|
id: 'label_other_user',
|
||||||
|
defaultMessage: 'Other',
|
||||||
|
description: 'Label for the other user when the user is unnamed'
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
class TopicSecurity extends React.PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.handleDeleteTopic = this.handleDeleteTopic.bind(this);
|
||||||
|
this.handleDeleteMessages = this.handleDeleteMessages.bind(this);
|
||||||
|
this.handleLeave = this.handleLeave.bind(this);
|
||||||
|
this.handleBlock = this.handleBlock.bind(this);
|
||||||
|
this.handleReport = this.handleReport.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDeleteTopic(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const {formatMessage} = this.props.intl;
|
||||||
|
this.props.onShowAlert(
|
||||||
|
formatMessage(messages.topic_delete), // title
|
||||||
|
formatMessage(messages.topic_delete_warning), // content
|
||||||
|
(() => { this.props.onDeleteTopic(this.props.topic); }), // onConfirm
|
||||||
|
null, // "OK"
|
||||||
|
true, // Show Reject button
|
||||||
|
null // "Cancel"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDeleteMessages(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const {formatMessage} = this.props.intl;
|
||||||
|
this.props.onShowAlert(
|
||||||
|
formatMessage(this.props.deleter ? messages.delete_messages : messages.clear_messages), // title
|
||||||
|
formatMessage(this.props.deleter ? messages.delete_messages_warning : messages.clear_messages_warning), // content
|
||||||
|
(() => { this.props.onDeleteMessages(this.props.topic); }), // onConfirm
|
||||||
|
null, // "OK"
|
||||||
|
true, // Show Reject button
|
||||||
|
null // "Cancel"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLeave(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const {formatMessage} = this.props.intl;
|
||||||
|
this.props.onShowAlert(
|
||||||
|
formatMessage(messages.leave_chat), // title
|
||||||
|
formatMessage(messages.leave_chat_warning), // content
|
||||||
|
(() => { this.props.onLeaveTopic(this.props.topic); }), // onConfirm
|
||||||
|
null, // "OK"
|
||||||
|
true, // Show Reject button
|
||||||
|
null // "Cancel"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBlock(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const {formatMessage} = this.props.intl;
|
||||||
|
this.props.onShowAlert(
|
||||||
|
formatMessage(messages.block_contact), // title
|
||||||
|
formatMessage(messages.block_contact_warning), // content
|
||||||
|
(() => { this.props.onBlockTopic(this.props.topic); }), // onConfirm
|
||||||
|
null, // "OK"
|
||||||
|
true, // Show Reject button
|
||||||
|
null // "Cancel"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReport(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const {formatMessage} = this.props.intl;
|
||||||
|
this.props.onShowAlert(
|
||||||
|
formatMessage(messages.report_chat), // title
|
||||||
|
formatMessage(messages.report_chat_warning), // content
|
||||||
|
(() => { this.props.onReportTopic(this.props.topic); }), // onConfirm
|
||||||
|
null, // "OK"
|
||||||
|
true, // Show Reject button
|
||||||
|
null // "Cancel"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {formatMessage} = this.props.intl;
|
||||||
|
return (
|
||||||
|
<div className="scrollable-panel">
|
||||||
|
<div className="panel-form-column">
|
||||||
|
{!this.props.channel ?
|
||||||
|
<a href="#" className="flat-button" onClick={this.handleDeleteMessages}>
|
||||||
|
<i className="material-icons">delete_outline</i> {
|
||||||
|
formatMessage(this.props.deleter ? messages.delete_messages : messages.clear_messages)
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
{this.props.owner ?
|
||||||
|
<a href="#" className="danger flat-button" onClick={this.handleDeleteTopic}>
|
||||||
|
<i className="material-icons">delete</i> {formatMessage(messages.topic_delete)}
|
||||||
|
</a>
|
||||||
|
:
|
||||||
|
<a href="#" className="danger flat-button" onClick={this.handleLeave}>
|
||||||
|
<i className="material-icons">exit_to_app</i> {formatMessage(messages.leave_chat)}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
{!this.props.groupTopic ?
|
||||||
|
<a href="#" className="danger flat-button" onClick={this.handleBlock}>
|
||||||
|
<i className="material-icons">block</i> {formatMessage(messages.block_contact)}
|
||||||
|
</a>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
{!this.props.owner ?
|
||||||
|
<a href="#" className="danger flat-button" onClick={this.handleReport}>
|
||||||
|
<i className="material-icons">report</i> {formatMessage(messages.report_chat)}
|
||||||
|
</a>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="hr" />
|
||||||
|
<div className="panel-form-column">
|
||||||
|
{this.props.groupTopic ?
|
||||||
|
<>
|
||||||
|
<div className="group">
|
||||||
|
<label>
|
||||||
|
<FormattedMessage id="label_your_permissions" defaultMessage="Your permissions:"
|
||||||
|
description="Label for current user permissions" />
|
||||||
|
</label> <tt className="clickable"
|
||||||
|
onClick={(e) => {e.preventDefault(); this.props.onLaunchPermissionsEditor('want')}}>
|
||||||
|
{this.props.access}
|
||||||
|
</tt>
|
||||||
|
</div>
|
||||||
|
{!this.props.channel ?
|
||||||
|
<div className="group">
|
||||||
|
<div>
|
||||||
|
<label className="small">
|
||||||
|
<FormattedMessage id="label_default_access_mode" defaultMessage="Default access mode:"
|
||||||
|
description="Label for default access mode" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="quoted">
|
||||||
|
<div>Auth: <tt className={this.props.owner ? 'clickable' : null}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (this.props.owner) {
|
||||||
|
this.props.onLaunchPermissionsEditor('auth');
|
||||||
|
}
|
||||||
|
}}>{this.props.auth}</tt>
|
||||||
|
</div>
|
||||||
|
<div>Anon: <tt className={this.props.owner ? 'clickable' : null}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (this.props.owner) {
|
||||||
|
this.props.onLaunchPermissionsEditor('anon');
|
||||||
|
}
|
||||||
|
}}>{this.props.anon}</tt>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
:
|
||||||
|
<div className="group">
|
||||||
|
<div>
|
||||||
|
<label className="small">
|
||||||
|
<FormattedMessage id="label_permissions" defaultMessage="Permissions:"
|
||||||
|
description="Section title" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="quoted">
|
||||||
|
<div>
|
||||||
|
<FormattedMessage id="label_you" defaultMessage="You:"
|
||||||
|
description="Label for the current user" /> <tt className="clickable"
|
||||||
|
onClick={(e) => {e.preventDefault(); this.props.onLaunchPermissionsEditor('want')}}>
|
||||||
|
{this.props.access}
|
||||||
|
</tt>
|
||||||
|
</div>
|
||||||
|
<div>{this.props.fullName ? this.props.fullName : formatMessage(messages.other_user)}:
|
||||||
|
<tt className="clickable"
|
||||||
|
onClick={(e) => {e.preventDefault(); this.props.onLaunchPermissionsEditor('given')}}>
|
||||||
|
{this.props.modeGiven2}
|
||||||
|
</tt>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(TopicSecurity);
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
// This is a generated file. Don't edit.
|
// This is a generated file. Don't edit.
|
||||||
|
|
||||||
const PACKAGE_VERSION = "0.17.10";
|
const PACKAGE_VERSION = "0.18.0-beta1";
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 473 KiB After Width: | Height: | Size: 393 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 592 KiB After Width: | Height: | Size: 474 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 121 KiB |
@@ -52,8 +52,8 @@ module.exports = (env, argv) => {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
performance: {
|
performance: {
|
||||||
maxEntrypointSize: 262144,
|
maxEntrypointSize: 294912,
|
||||||
maxAssetSize: 262144
|
maxAssetSize: 294912
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
new CopyPlugin({
|
new CopyPlugin({
|
||||||
|
|||||||