chore: seed migration objects batch 1
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
+614
-153
File diff suppressed because it is too large
Load Diff
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Executable → Regular
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
+2
-2
@@ -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>
|
||||||
|
|||||||
+2
-2
@@ -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>
|
||||||
|
|||||||
Generated
+2879
-2667
File diff suppressed because it is too large
Load Diff
+8
-6
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
+127
@@ -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");
|
|
||||||
+8
-4
@@ -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;
|
||||||
|
|||||||
+16
-3
@@ -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';
|
||||||
|
|
||||||
|
|||||||
+1355
-180
File diff suppressed because it is too large
Load Diff
+1411
-180
File diff suppressed because it is too large
Load Diff
+1355
-180
File diff suppressed because it is too large
Load Diff
+79
-24
@@ -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": {
|
||||||
|
|||||||
+1355
-183
File diff suppressed because it is too large
Load Diff
+1355
-180
File diff suppressed because it is too large
Load Diff
+1355
-180
File diff suppressed because it is too large
Load Diff
+1355
-180
File diff suppressed because it is too large
Load Diff
+1355
-180
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -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>
|
||||||
|
|||||||
+145
-75
@@ -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);
|
||||||
|
}
|
||||||
+12
-2
@@ -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;
|
||||||
|
|||||||
+24
-1
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
+32
-7
@@ -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
-1458
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user