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.
|
||||
* Emoji support is weak.
|
||||
* Mentions, hashtags.
|
||||
* Replying or forwarding messages.
|
||||
* Previews not generated for videos, audio, links or docs.
|
||||
|
||||
## Other
|
||||
@@ -79,5 +78,5 @@ If you want to use the app with your own server and want web push notification t
|
||||
### Mobile screenshots
|
||||
|
||||
<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>
|
||||
|
||||
+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 -->
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@5/react-intl.iife.js"></script>
|
||||
<!-- 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@8/firebase-messaging.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@9/firebase-messaging.js"></script>
|
||||
</head>
|
||||
|
||||
<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-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/firebase@8.9.1/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-app.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@9.4.0/firebase-messaging.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
|
||||
|
||||
Generated
+2878
-2666
File diff suppressed because it is too large
Load Diff
+8
-6
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tinode-webapp",
|
||||
"description": "Tinode messenger for the web",
|
||||
"version": "0.17.10",
|
||||
"version": "0.18.0-beta1",
|
||||
"repository": {
|
||||
"type": "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:dev": "webpack --mode development",
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"browserslist": "> 0.5%, not IE 11, not op_mini all, not and_uc >0",
|
||||
@@ -64,11 +66,11 @@
|
||||
"author": "Tinode Authors <info@tinode.co>",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"firebase": "^8.9.1",
|
||||
"firebase": "^9.4.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-intl": "^5.20.2",
|
||||
"tinode-sdk": "^0.17.10"
|
||||
"tinode-sdk": "^0.18.0-beta1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.14.5",
|
||||
@@ -83,7 +85,7 @@
|
||||
"glob": "^7.1.7",
|
||||
"mkdirp": "^1.0.4",
|
||||
"postcss": "^8.3.2",
|
||||
"postcss-cli": "^8.3.1",
|
||||
"postcss-cli": "^9.0.2",
|
||||
"webpack": "^5.38.1",
|
||||
"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.
|
||||
fbMessaging.onBackgroundMessage((payload) => {
|
||||
if (payload.data.silent == 'true') {
|
||||
// TODO: if this is an 'msg', fetch the new message in the background.
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,8 +69,8 @@ fbMessaging.onBackgroundMessage((payload) => {
|
||||
const pushType = payload.data.what || 'msg';
|
||||
const title = payload.data.title || self.i18nMessage(pushType == 'msg' ? 'new_message' : 'new_chat');
|
||||
const options = {
|
||||
body: payload.data.content || '',
|
||||
icon: '/img/logo96.png',
|
||||
body: payload.data.content || '', // TODO: content for 'sub' should be topic's or user's title.
|
||||
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',
|
||||
tag: payload.data.topic || undefined,
|
||||
data: {
|
||||
@@ -135,7 +136,10 @@ self.addEventListener('fetch', event => {
|
||||
event.respondWith((async () => {
|
||||
// Try to find the response in the cache.
|
||||
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) {
|
||||
return cachedResponse;
|
||||
}
|
||||
@@ -144,7 +148,7 @@ self.addEventListener('fetch', event => {
|
||||
if (!response || response.status != 200 || response.type != 'basic') {
|
||||
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());
|
||||
}
|
||||
return response;
|
||||
|
||||
+16
-3
@@ -45,9 +45,12 @@ export const MEDIA_BREAKPOINT = 640;
|
||||
// Size of css 'rem' unit in pixels. Default 1rem = 10pt = 13px.
|
||||
export const REM_SIZE = 13;
|
||||
|
||||
// Size of the avatar image: When an avatar image is uploaded, it's resized to
|
||||
// a square of this size.
|
||||
export const AVATAR_SIZE = 128;
|
||||
// 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 or less.
|
||||
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
|
||||
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.
|
||||
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
|
||||
// hidden under "+X more"
|
||||
export const MAX_ONLINE_IN_TOPIC = 4;
|
||||
|
||||
// Maximum length of user name, topic title, and private comment.
|
||||
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.
|
||||
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".
|
||||
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",
|
||||
"description": "Flat button [Leave Conversation]"
|
||||
},
|
||||
"action_more": {
|
||||
"defaultMessage": "More",
|
||||
"description": "Action for showing more content"
|
||||
},
|
||||
"action_report_chat": {
|
||||
"defaultMessage": "Report Conversation",
|
||||
"description": "Flat button [Report Group]"
|
||||
@@ -31,10 +27,22 @@
|
||||
"defaultMessage": "Archived contacts ({count})",
|
||||
"description": "Label for archived chats"
|
||||
},
|
||||
"badge_danger": {
|
||||
"defaultMessage": "Untrustworthy",
|
||||
"description": "Suspicious or untrustworthy account or topic badge"
|
||||
},
|
||||
"badge_owner": {
|
||||
"defaultMessage": "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": {
|
||||
"defaultMessage": "you",
|
||||
"description": "Badge for indicating the current user"
|
||||
@@ -87,6 +95,10 @@
|
||||
"defaultMessage": "Reset",
|
||||
"description": "Button [Reset]"
|
||||
},
|
||||
"button_security": {
|
||||
"defaultMessage": "Security",
|
||||
"description": "Navigaton button for security panel."
|
||||
},
|
||||
"button_send_request": {
|
||||
"defaultMessage": "Send request",
|
||||
"description": "Button [Send request]"
|
||||
@@ -159,7 +171,7 @@
|
||||
"defaultMessage": "Delete account",
|
||||
"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.",
|
||||
"description": "Warning message when deleting an account"
|
||||
},
|
||||
@@ -171,6 +183,10 @@
|
||||
"defaultMessage": "content deleted",
|
||||
"description": "Shown when messages are deleted"
|
||||
},
|
||||
"description_editing_placeholder": {
|
||||
"defaultMessage": "Description (optional)",
|
||||
"description": "Placeholder for editing topic description"
|
||||
},
|
||||
"download_action": {
|
||||
"defaultMessage": "download",
|
||||
"description": "Call to action [download]"
|
||||
@@ -215,6 +231,13 @@
|
||||
"defaultMessage": "Forgot password?",
|
||||
"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": {
|
||||
"defaultMessage": "Full name, e.g. John Doe",
|
||||
"description": "Input placeholder for person's full name"
|
||||
@@ -237,7 +260,7 @@
|
||||
},
|
||||
"invalid_content": {
|
||||
"defaultMessage": "invalid content",
|
||||
"description": "Shown when message is unreadable"
|
||||
"description": "Shown when the message is unreadable"
|
||||
},
|
||||
"invalid_security_token": {
|
||||
"defaultMessage": "Invalid security token",
|
||||
@@ -251,14 +274,14 @@
|
||||
"defaultMessage": "Content type:",
|
||||
"description": "Label for file content type (mime)"
|
||||
},
|
||||
"label_default_access": {
|
||||
"defaultMessage": "Default access mode:",
|
||||
"description": "Section title"
|
||||
},
|
||||
"label_default_access_mode": {
|
||||
"defaultMessage": "Default access mode:",
|
||||
"description": "Label for default access mode"
|
||||
},
|
||||
"label_description": {
|
||||
"defaultMessage": "Description",
|
||||
"description": "Label for editing topic description"
|
||||
},
|
||||
"label_file_name": {
|
||||
"defaultMessage": "File name:",
|
||||
"description": "Label for a file name"
|
||||
@@ -329,6 +352,10 @@
|
||||
"defaultMessage": "Name",
|
||||
"description": "Label for editing topic name"
|
||||
},
|
||||
"label_unarchive_topic": {
|
||||
"defaultMessage": "Archived:",
|
||||
"description": "Label for unarchiving the topic"
|
||||
},
|
||||
"label_user_contacts": {
|
||||
"defaultMessage": "Contacts:",
|
||||
"description": "Label for user contacts"
|
||||
@@ -409,6 +436,10 @@
|
||||
"defaultMessage": "Edit permissions",
|
||||
"description": "Menu item [Edit permissions]"
|
||||
},
|
||||
"menu_item_forward": {
|
||||
"defaultMessage": "Forward",
|
||||
"description": "Forward message"
|
||||
},
|
||||
"menu_item_info": {
|
||||
"defaultMessage": "Info",
|
||||
"description": "Show extended topic information"
|
||||
@@ -421,6 +452,10 @@
|
||||
"defaultMessage": "Mute",
|
||||
"description": "Turn off notifications"
|
||||
},
|
||||
"menu_item_reply": {
|
||||
"defaultMessage": "Reply",
|
||||
"description": "Reply to message"
|
||||
},
|
||||
"menu_item_restore_topic": {
|
||||
"defaultMessage": "Restore",
|
||||
"description": "Restore topic from archive"
|
||||
@@ -481,6 +516,26 @@
|
||||
"defaultMessage": "online now",
|
||||
"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": {
|
||||
"defaultMessage": "Password",
|
||||
"description": "Placeholder/prompt for entering password"
|
||||
@@ -529,6 +584,18 @@
|
||||
"defaultMessage": "Write ({val})",
|
||||
"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": {
|
||||
"defaultMessage": "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...",
|
||||
"description": "Placeholder in contacts search field"
|
||||
},
|
||||
"sidepanel_title_acc_general": {
|
||||
"defaultMessage": "General",
|
||||
"description": "Sidepanel title for AccGeneralView."
|
||||
},
|
||||
"sidepanel_title_acc_notifications": {
|
||||
"defaultMessage": "Notifications",
|
||||
"description": "Sidepanel title for AccNotificationsView."
|
||||
},
|
||||
"sidepanel_title_acc_security": {
|
||||
"defaultMessage": "Security",
|
||||
"description": "Sidepanel title for AccSecurityView."
|
||||
},
|
||||
"sidepanel_title_acc_support": {
|
||||
"defaultMessage": "Support",
|
||||
"description": "Sidepanel title for AccSupportView."
|
||||
},
|
||||
"sidepanel_title_account_settings": {
|
||||
"defaultMessage": "Account Settings",
|
||||
"description": "Sidepanel title for EditAccountView."
|
||||
"description": "Sidepanel title for AccountSettingsView."
|
||||
},
|
||||
"sidepanel_title_archive": {
|
||||
"defaultMessage": "Archived Chats",
|
||||
@@ -661,10 +720,6 @@
|
||||
"defaultMessage": "Group Members",
|
||||
"description": "Section title"
|
||||
},
|
||||
"title_info": {
|
||||
"defaultMessage": "Info",
|
||||
"description": "Title for InfoView"
|
||||
},
|
||||
"title_manage_tags": {
|
||||
"defaultMessage": "Manage",
|
||||
"description": "Section title for the list of tags"
|
||||
@@ -678,7 +733,7 @@
|
||||
"description": "Section title"
|
||||
},
|
||||
"title_tag_manager": {
|
||||
"defaultMessage": "Tags (user discovery)",
|
||||
"defaultMessage": "Tags (search & discovery)",
|
||||
"description": "Section title for TagManager"
|
||||
},
|
||||
"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];
|
||||
|
||||
// Try the full locale first, then the locale without the region code, fallback to 'en'.
|
||||
const messages =
|
||||
allMessages[language] ||
|
||||
allMessages[baseLanguage] ||
|
||||
allMessages.en;
|
||||
const htmlLang = allMessages[language] ? langauge : allMessages[baseLanguage] ? baseLanguage : 'en';
|
||||
const messages = allMessages[htmlLang];
|
||||
|
||||
// Set lang attribute of the HTML element: <html lang="XX">
|
||||
document.getElementsByTagName('html')[0].setAttribute('lang', htmlLang);
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
|
||||
+108
-38
@@ -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 MIME_EXTENSIONS = ['jpg', 'gif', 'png', 'svg', 'svg'];
|
||||
|
||||
// Make a data: URL from public.photo
|
||||
export function makeImageDataUrl(photo) {
|
||||
if (photo) {
|
||||
// Get an URL from a theCard photo: either create a data: URL or return reference URL.
|
||||
export function makeImageUrl(photo) {
|
||||
if (photo && typeof photo == 'object') {
|
||||
if (photo.data && photo.type) {
|
||||
const mime = photo.type.startsWith('image/') ? photo.type : ('image/' + photo.type);
|
||||
return 'data:' + mime + ';base64,' + photo.data;
|
||||
@@ -77,21 +77,22 @@ export function fileNameForMime(fname, mime) {
|
||||
}
|
||||
|
||||
// Scale uploaded image to fit under certain dimensions and byte size, optionally constraining to a square.
|
||||
// On success calls onSuccess callback with the scaled image as Blob.
|
||||
export function imageScaled(fileOrBlob, maxWidth, maxHeight, maxSize, forceSquare, onSuccess, onError) {
|
||||
// Returns a promise which is resolven on success or rejected on failure.
|
||||
export function imageScaled(fileOrBlob, maxWidth, maxHeight, maxSize, forceSquare) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'Anonymous';
|
||||
img.onerror = function(err) {
|
||||
onError("Image format unrecognized");
|
||||
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);
|
||||
const dim = fitImageSize(img.width, img.height, maxWidth, maxHeight, forceSquare);
|
||||
if (!dim) {
|
||||
onError("Invalid image");
|
||||
reject(new Error("Invalid image"));
|
||||
return;
|
||||
}
|
||||
let canvas = document.createElement('canvas');
|
||||
@@ -99,17 +100,16 @@ export function imageScaled(fileOrBlob, maxWidth, maxHeight, maxSize, forceSquar
|
||||
canvas.height = dim.dstHeight;
|
||||
let ctx = canvas.getContext('2d');
|
||||
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);
|
||||
|
||||
const mime = SUPPORTED_IMAGE_FORMATS.includes(fileOrBlob.type) ? fileOrBlob.type : 'image/jpeg';
|
||||
// Generate blob to check size of the image.
|
||||
let blob = await new Promise(resolve => canvas.toBlob(resolve, mime));
|
||||
if (!blob) {
|
||||
onError("Unsupported image format");
|
||||
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) {
|
||||
@@ -119,57 +119,103 @@ export function imageScaled(fileOrBlob, maxWidth, maxHeight, maxSize, forceSquar
|
||||
canvas.height = dim.dstHeight;
|
||||
ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(this, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight,
|
||||
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;
|
||||
onSuccess(mime, blob, dim.dstWidth, dim.dstHeight, fileNameForMime(fileOrBlob.name, mime));
|
||||
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.
|
||||
export function fileToBase64(file, onSuccess) {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', function() {
|
||||
onSuccess(file.type, reader.result.split(',')[1], file.name);
|
||||
// Scale and crop image according to specified dimensions.
|
||||
// The coordinates are in unscaled image pixels, i.e. cut the rectangle first then scale it.
|
||||
// returns a promise.
|
||||
export function imageCrop(mime, objURL, left, top, width, height, scale) {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
// Convert file to base64 string.
|
||||
export function fileToBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
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.
|
||||
export function blobToBase64(blob, onSuccess) {
|
||||
// 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.addEventListener('load', function() {
|
||||
onSuccess(blob.type, reader.result.split(',')[1]);
|
||||
});
|
||||
reader.onerror = (evt) => {
|
||||
reject(reader.error);
|
||||
};
|
||||
reader.onload = () => {
|
||||
resolve({mime: blob.type, bits: reader.result.split(',')[1]});
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
// File pasted from the clipboard. It's either an inline image or a file attachment.
|
||||
// FIXME: handle large files out of band.
|
||||
export function filePasted(event, onImageSuccess, onAttachmentSuccess, onError) {
|
||||
var items = (event.clipboardData || event.originalEvent.clipboardData || {}).items;
|
||||
for (var i in items) {
|
||||
var item = items[i];
|
||||
const items = (event.clipboardData || event.originalEvent.clipboardData || {}).items;
|
||||
if (!Array.isArray(items)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i in items) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'file') {
|
||||
var file = item.getAsFile();
|
||||
const file = item.getAsFile();
|
||||
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;
|
||||
}
|
||||
if (file.type && file.type.split('/')[0] == 'image') {
|
||||
// Handle inline image
|
||||
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);
|
||||
onImageSuccess(file);
|
||||
} else {
|
||||
imageFileToBase64(file, onImageSuccess, onError);
|
||||
}
|
||||
} else {
|
||||
// Handle file attachment
|
||||
fileToBase64(file, onAttachmentSuccess, onError)
|
||||
onAttachmentSuccess(file);
|
||||
}
|
||||
// Indicate that the pasted data contains a file.
|
||||
return true;
|
||||
@@ -205,9 +251,33 @@ export function base64ReEncode(str) {
|
||||
try {
|
||||
str = btoa(atob(str));
|
||||
} catch(err) {
|
||||
console.log("Failed to base64 re-encode string.", err);
|
||||
console.error("Failed to base64 re-encode string.", err);
|
||||
str = null;
|
||||
}
|
||||
}
|
||||
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 = {};
|
||||
let path = [];
|
||||
if (parts[0]) {
|
||||
path = parts[0].substr(1).split('/');
|
||||
path = parts[0].replace('#', '').split('/');
|
||||
}
|
||||
if (parts[1]) {
|
||||
parts[1].split('&').forEach(function(part) {
|
||||
let item = part.split('=');
|
||||
const item = part.split('=');
|
||||
if (item[0]) {
|
||||
params[decodeURIComponent(item[0])] = decodeURIComponent(item[1]);
|
||||
}
|
||||
@@ -58,6 +58,16 @@ export default class HashNavigation {
|
||||
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) {
|
||||
const parsed = this.parseUrlHash(hash);
|
||||
parsed.path[1] = topic;
|
||||
|
||||
+24
-1
@@ -37,9 +37,18 @@ export function bytesToHumanSize(bytes) {
|
||||
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
|
||||
// as Java's String#hash().
|
||||
export function stringHash(value) {
|
||||
function stringToColorHash(value) {
|
||||
let hash = 0;
|
||||
value = '' + value;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
@@ -48,3 +57,17 @@ export function stringHash(value) {
|
||||
}
|
||||
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.
|
||||
export function theCard(fn, imageUrl, imageMimeType) {
|
||||
export function theCard(fn, imageUrl, imageMimeType, note) {
|
||||
let card = null;
|
||||
fn = fn && fn.trim();
|
||||
note = note && note.trim();
|
||||
|
||||
if (fn) {
|
||||
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) {
|
||||
card = card || {};
|
||||
let mimeType = imageMimeType;
|
||||
@@ -38,10 +44,12 @@ export function theCard(fn, imageUrl, imageMimeType) {
|
||||
if (matches) {
|
||||
mimeType = matches[1];
|
||||
card.photo = {
|
||||
data: imageUrl.substring(imageUrl.indexOf(',') + 1)
|
||||
data: imageUrl.substring(imageUrl.indexOf(',') + 1),
|
||||
ref: Tinode.DEL_CHAR
|
||||
};
|
||||
} else {
|
||||
card.photo = {
|
||||
data: Tinode.DEL_CHAR,
|
||||
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
|
||||
// strings with permitted URL schemes, such as ['ftp', 'ftps']; otherwise accept http and https only.
|
||||
// If apikey and/or token is defined, it's appended to relative URL.
|
||||
export function sanitizeUrl(url, allowedSchemes) {
|
||||
if (!url) {
|
||||
return null;
|
||||
if (typeof url != 'string') {
|
||||
return url;
|
||||
}
|
||||
|
||||
// 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
|
||||
// have http:, https:, blob: or data: scheme.
|
||||
// The relative URL is appended with the API key and/or auth token.
|
||||
// In case of data: scheme, the URL must start with a 'data:image/XXXX;base64,'.
|
||||
export function sanitizeImageUrl(url) {
|
||||
if (!url) {
|
||||
@@ -165,7 +171,7 @@ export function deliveryMarker(received) {
|
||||
case Tinode.MESSAGE_STATUS_SENDING:
|
||||
return { name: 'access_time' }; // watch face
|
||||
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:
|
||||
return { name: 'done' }; // checkmark
|
||||
case Tinode.MESSAGE_STATUS_RECEIVED:
|
||||
@@ -175,3 +181,22 @@ export function deliveryMarker(received) {
|
||||
}
|
||||
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