chore: seed migration objects batch 1

This commit is contained in:
dev
2026-10-18 13:59:23 +08:00
parent 87c73869af
commit 3e00c3422d
29 changed files with 15247 additions and 5954 deletions
+1 -2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long
Executable → Regular
BIN
View File
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
View File
@@ -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
View File
@@ -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>
+2879 -2667
View File
File diff suppressed because it is too large Load Diff
+8 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "tinode-webapp", "name": "tinode-webapp",
"description": "Tinode messenger for the web", "description": "Tinode messenger for the web",
"version": "0.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
View File
@@ -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));
-100
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1411 -180
View File
File diff suppressed because it is too large Load Diff
+1355 -180
View File
File diff suppressed because it is too large Load Diff
+79 -24
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1355 -180
View File
File diff suppressed because it is too large Load Diff
+1355 -180
View File
File diff suppressed because it is too large Load Diff
+1355 -180
View File
File diff suppressed because it is too large Load Diff
+1355 -180
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -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
View File
@@ -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;
}
+395
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because one or more lines are too long