Merge pull request #106 from tinode/next
Message editing, video messages
This commit is contained in:
+2
-7
@@ -1,8 +1,3 @@
|
||||
index.dev.js binary
|
||||
index.dev.js.map binary
|
||||
index.prod.js binary
|
||||
index.prod.js.map binary
|
||||
base.min.css binary
|
||||
tinode.dev.js binary
|
||||
tinode.prod.js binary
|
||||
umd/* binary
|
||||
package-lock.json binary
|
||||
*.min.css binary
|
||||
|
||||
@@ -58,7 +58,9 @@ More translations are welcome. See [instructions](https://github.com/tinode/chat
|
||||
* End-to-End encryption.
|
||||
* Emoji support is weak.
|
||||
* Mentions, hashtags.
|
||||
* Previews not generated for videos, links or docs.
|
||||
* Previews not generated for links and docs.
|
||||
* Location and contact sharing.
|
||||
* Recording video messages.
|
||||
|
||||
## Other
|
||||
|
||||
|
||||
+75
-2
@@ -400,6 +400,10 @@ div.trusted-badge {
|
||||
color: #388e3c; /* green 700 */
|
||||
}
|
||||
|
||||
.white {
|
||||
color: #FFF!important; /* 100% white */
|
||||
}
|
||||
|
||||
.red {
|
||||
color: #D32F2F!important; /* red 700 */
|
||||
}
|
||||
@@ -1007,7 +1011,8 @@ i.search {
|
||||
}
|
||||
|
||||
.contact-box .contact-comment .material-icons {
|
||||
margin-bottom: .2rem;
|
||||
margin-bottom: 0.2rem;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
/* Avatar and online-offline indicator */
|
||||
@@ -1878,6 +1883,7 @@ video {
|
||||
font-size: smaller;
|
||||
padding: 0.5rem 0.5rem 0.5rem 0.5rem;
|
||||
background-color: rgb(244 244 244 / 66%);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Left triangle placed bottom left flush. */
|
||||
@@ -2022,7 +2028,7 @@ img.inline-image::before, img.image-preview::before {
|
||||
|
||||
img.inline-image::before {
|
||||
content: attr(alt);
|
||||
background-size: 2rem 2rem;
|
||||
background-size: 3rem 3rem;
|
||||
}
|
||||
|
||||
img.image-preview::before {
|
||||
@@ -2030,6 +2036,49 @@ img.image-preview::before {
|
||||
background-size: 10rem 10rem;
|
||||
}
|
||||
|
||||
.inline-image.video > img.inline-image::before, img.image-preview.video::before {
|
||||
background: center no-repeat #ccc url("../img/broken_video.png");
|
||||
}
|
||||
|
||||
.inline-image.video > img.inline-image::before {
|
||||
background-size: 3rem 3rem;
|
||||
}
|
||||
|
||||
.inline-video {
|
||||
display: flex;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.inline-video > img.inline-image::before {
|
||||
background: #ccc;
|
||||
}
|
||||
|
||||
.inline-video .duration {
|
||||
padding: 0.25rem 0.5rem;
|
||||
color: #333;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
position: absolute;
|
||||
bottom: 0.25rem;
|
||||
left: 0.25rem;
|
||||
font-size: 80%;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
|
||||
.inline-video .play-control {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Attachment styles */
|
||||
.attachment {
|
||||
display: flex;
|
||||
@@ -2470,6 +2519,9 @@ ul.tabbar li.active > a {
|
||||
.material-icons.big {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
.material-icons.bigger {
|
||||
font-size: 3rem;
|
||||
}
|
||||
.material-icons.huge {
|
||||
font-size: 4rem;
|
||||
}
|
||||
@@ -2924,6 +2976,27 @@ ul.tabbar li.active > a {
|
||||
color: #666C;
|
||||
}
|
||||
|
||||
/*
|
||||
Drag-and-drop area.
|
||||
*/
|
||||
.drag-n-drop {
|
||||
display: flex;
|
||||
border: dashed grey 0.15rem;
|
||||
border-radius: 1rem;
|
||||
background-color: rgba(255,255,255,.95);
|
||||
position: absolute;
|
||||
top: 1.5rem;
|
||||
bottom: 1.5rem;
|
||||
left: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 3;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 2rem;
|
||||
color: grey;
|
||||
outline: 0.5rem solid rgba(255,255,255,.95);
|
||||
}
|
||||
|
||||
/*
|
||||
Very wide monitor.
|
||||
*/
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 620 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
+3
-6
@@ -29,14 +29,11 @@
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
|
||||
<!-- Styles for local elements -->
|
||||
<link rel="stylesheet" href="css/base.css" />
|
||||
<!-- Add duration to audio recording (workaround for Chrome bug)
|
||||
https://bugs.chromium.org/p/chromium/issues/detail?id=642012 -->
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/fix-webm-duration@1/fix-webm-duration.js"></script>
|
||||
<!-- ReactJS -->
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react@17/umd/react.development.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@17/umd/react-dom.development.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react@18/umd/react.development.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@18/umd/react-dom.development.js"></script>
|
||||
<!-- 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@6/react-intl.iife.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
+3
-4
@@ -23,10 +23,9 @@
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400i,500,700,700i" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
|
||||
<link rel="stylesheet" href="css/base.min.css" />
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/fix-webm-duration@1.0.5/fix-webm-duration.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-intl@5.20.7/react-intl.iife.min.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react@18.2.0/umd/react.production.min.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@6.2.1/react-intl.iife.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
|
||||
|
||||
Generated
+1611
-2649
File diff suppressed because it is too large
Load Diff
+23
-27
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "tinode-webapp",
|
||||
"description": "Tinode messenger for the web",
|
||||
"version": "0.20.4",
|
||||
|
||||
"version": "0.21.0-beta1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tinode/example-react-js.git"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run vers && npm run build:css && npm run extract && npm run build:i18n && npm run build:prod && npm run build:dev",
|
||||
"clean": "rm umd/* && rm css/base.min.css && rm i18n.min/*",
|
||||
"build:css": "postcss css/base.css > css/base.min.css",
|
||||
"build:dev": "webpack --mode development",
|
||||
"build:prod": "webpack --mode production",
|
||||
@@ -21,12 +21,8 @@
|
||||
},
|
||||
"browserslist": "> 0.5%, not IE 11, not op_mini all, not and_uc >0",
|
||||
"files": [
|
||||
"umd/index.prod.js",
|
||||
"umd/index.prod.js.map",
|
||||
"umd/index.dev.js",
|
||||
"umd/index.dev.js.map",
|
||||
"umd/tinode.prod.js",
|
||||
"umd/tinode.dev.js",
|
||||
"umd/*.js",
|
||||
"umd/*.map",
|
||||
"audio/*.m4a",
|
||||
"css/base.css",
|
||||
"css/base.min.css",
|
||||
@@ -67,27 +63,27 @@
|
||||
"author": "Tinode Authors <info@tinode.co>",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"firebase": "^9.6.7",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-intl": "^5.20.2",
|
||||
"tinode-sdk": "^0.20.3"
|
||||
"firebase": "^9.14.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-intl": "^6.2.1",
|
||||
"tinode-sdk": "^0.21.0-beta1",
|
||||
"webm-duration-fix": "^1.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.14.5",
|
||||
"@babel/preset-env": "^7.14.5",
|
||||
"@babel/preset-react": "^7.14.5",
|
||||
"@formatjs/cli": "^4.2.20",
|
||||
"babel-loader": "^8.2.2",
|
||||
"babel-plugin-formatjs": "^10.2.19",
|
||||
"browserslist": "^4.16.5",
|
||||
"copy-webpack-plugin": "^10.0.0",
|
||||
"cssnano": "^5.0.6",
|
||||
"@babel/core": "^7.20.2",
|
||||
"@babel/preset-env": "^7.20.2",
|
||||
"@babel/preset-react": "^7.18.6",
|
||||
"@formatjs/cli": "^5.1.3",
|
||||
"babel-loader": "^9.1.0",
|
||||
"babel-plugin-formatjs": "^10.3.31",
|
||||
"browserslist": "^4.21.4",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"cssnano": "^5.1.14",
|
||||
"glob": "^8.0.3",
|
||||
"mkdirp": "^1.0.4",
|
||||
"postcss": "^8.3.2",
|
||||
"postcss-cli": "^9.0.2",
|
||||
"webpack": "^5.38.1",
|
||||
"webpack-cli": "^4.7.2"
|
||||
"postcss": "^8.4.19",
|
||||
"postcss-cli": "^10.0.0",
|
||||
"webpack": "^5.75.0",
|
||||
"webpack-cli": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -5,14 +5,12 @@
|
||||
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 OUTPUT_PATTERN = './src/i18n.min/%s.json';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
@@ -99,8 +97,9 @@ globSync(LANG_PATTERN)
|
||||
fs.writeFileSync(filename, JSON.stringify(translated, null, 2)+"\n");
|
||||
});
|
||||
|
||||
// Combine all translations into a single json {"lang_1": {...}, "lang_2: {...}"}
|
||||
// Legacy: Combine all translations into a single json {"lang_1": {...}, "lang_2: {...}"}
|
||||
// while removing messages marked as missing or obsolete.
|
||||
// New way: Emit one file per language with all unnecessary data stripped.
|
||||
|
||||
// Read all translations.
|
||||
const messages = globSync(LANG_PATTERN)
|
||||
@@ -122,6 +121,7 @@ const messages = globSync(LANG_PATTERN)
|
||||
return { ...collection, ...descriptors };
|
||||
}, {});
|
||||
|
||||
// Write a file with all translations combined.
|
||||
mkdirpSync(path.dirname(FINAL_FILE));
|
||||
fs.writeFileSync(FINAL_FILE, JSON.stringify(messages));
|
||||
// Write minified translation files, one per language.
|
||||
for (let lang in messages) {
|
||||
fs.writeFileSync(printf(OUTPUT_PATTERN, lang), JSON.stringify(messages[lang]));
|
||||
}
|
||||
|
||||
+6
-7
@@ -1,7 +1,6 @@
|
||||
// Must be located at the root.
|
||||
// FIXME: update Firebase version.
|
||||
importScripts('https://cdn.jsdelivr.net/npm/firebase@8.10.1/firebase-app.js');
|
||||
importScripts('https://cdn.jsdelivr.net/npm/firebase@8.10.1/firebase-messaging.js');
|
||||
importScripts('https://cdn.jsdelivr.net/npm/firebase@9.14.0/firebase-app-compat.js');
|
||||
importScripts('https://cdn.jsdelivr.net/npm/firebase@9.14.0/firebase-messaging-compat.js');
|
||||
importScripts('firebase-init.js');
|
||||
importScripts('version.js');
|
||||
|
||||
@@ -64,7 +63,7 @@ firebase.initializeApp(FIREBASE_INIT);
|
||||
const fbMessaging = firebase.messaging();
|
||||
|
||||
// This method shows the push notifications while the window is in background.
|
||||
fbMessaging.onBackgroundMessage((payload) => {
|
||||
fbMessaging.onBackgroundMessage(payload => {
|
||||
if (payload.data.silent == 'true') {
|
||||
// TODO: if this is an 'msg', fetch the new message in the background.
|
||||
return;
|
||||
@@ -94,7 +93,7 @@ fbMessaging.onBackgroundMessage((payload) => {
|
||||
|
||||
// Update service worker immediately for both the current client
|
||||
// and all other active clients.
|
||||
self.addEventListener('install', event => {
|
||||
self.addEventListener('install', _ => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
@@ -113,7 +112,7 @@ self.addEventListener('notificationclick', event => {
|
||||
event.waitUntil(self.clients.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true
|
||||
}).then((windowClients) => {
|
||||
}).then(windowClients => {
|
||||
let anyClient = null;
|
||||
for (let i = 0; i < windowClients.length; i++) {
|
||||
const url = new URL(windowClients[i].url);
|
||||
@@ -149,7 +148,7 @@ self.addEventListener('fetch', event => {
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith((async () => {
|
||||
event.respondWith((async _ => {
|
||||
// Try to find the response in the cache.
|
||||
const cache = await caches.open(PACKAGE_VERSION);
|
||||
|
||||
|
||||
+11
-1
@@ -1,7 +1,7 @@
|
||||
import { PACKAGE_VERSION } from './version.js';
|
||||
|
||||
// Name of this application, used in the User-Agent.
|
||||
export const APP_NAME = 'TinodeWeb/' + (PACKAGE_VERSION || '0.17');
|
||||
export const APP_NAME = 'TinodeWeb/' + (PACKAGE_VERSION || '0.21');
|
||||
|
||||
// API key. Use https://github.com/tinode/chat/tree/master/keygen to generate your own
|
||||
export const API_KEY = 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K';
|
||||
@@ -53,6 +53,9 @@ export const MAX_AVATAR_BYTES = 4096;
|
||||
// Size of the broken_image shown in MessagesView
|
||||
export const BROKEN_IMAGE_SIZE = 32;
|
||||
|
||||
// Size of video preview when dimensions are missing.
|
||||
export const NO_DIMENSIONS_VIDEO = 128;
|
||||
|
||||
// Number of chat messages to fetch in one call.
|
||||
export const MESSAGES_PAGE = 24;
|
||||
|
||||
@@ -76,8 +79,13 @@ 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 video screencapture: shrink image under this size for previews.
|
||||
export const VIDEO_PREVIEW_DIM = 96;
|
||||
|
||||
// Linear dimensions of image thumbnail: shrink image under this size for thumbnails in reply quote previews.
|
||||
export const IMAGE_THUMBNAIL_DIM = 36;
|
||||
// Width of video thumbnail: allow it to be wider, but not taller. Use IMAGE_THUMBNAIL_DIM for height.
|
||||
export const VIDEO_THUMBNAIL_WIDTH = 48;
|
||||
|
||||
// Maximum number of online users to be shown in a topic title bar. Others will be
|
||||
// hidden under "+X more"
|
||||
@@ -97,6 +105,8 @@ export const MESSAGE_PREVIEW_LENGTH = 80;
|
||||
export const QUOTED_REPLY_LENGTH = 30;
|
||||
// Length of a preview of a forwarded message.
|
||||
export const FORWARDED_PREVIEW_LENGTH = 84;
|
||||
// Length of preview for a message being edited.
|
||||
export const EDIT_PREVIEW_LENGTH = 48;
|
||||
|
||||
// Minimum duration of an audio recording in milliseconds (2 sec).
|
||||
export const MIN_DURATION = 2_000;
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+108
-10
@@ -307,13 +307,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"deleted_content": {
|
||||
"translation": "Inhalt gelöscht",
|
||||
"defaultMessage": "content deleted",
|
||||
"description": "Shown when messages are deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"download_action": {
|
||||
"translation": "herunterladen",
|
||||
"defaultMessage": "download",
|
||||
@@ -324,21 +317,21 @@
|
||||
"drafty_attachment": {
|
||||
"translation": "Dateianhang",
|
||||
"defaultMessage": "Attachment",
|
||||
"description": "Comment for attachment in drafty",
|
||||
"description": "Comment for attachment in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_form": {
|
||||
"translation": "Formular:",
|
||||
"defaultMessage": "Form:",
|
||||
"description": "Comment for form in drafty",
|
||||
"description": "Comment for form in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_image": {
|
||||
"translation": "Bild",
|
||||
"defaultMessage": "Picture",
|
||||
"description": "Comment for embedded images in drafty",
|
||||
"description": "Comment for embedded images in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
@@ -1451,5 +1444,110 @@
|
||||
"description": "Message explaining that the credential was successfully validated.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_edit": {
|
||||
"translation": "Bearbeiten",
|
||||
"defaultMessage": "Edit",
|
||||
"description": "Edit message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"editing_message": {
|
||||
"translation": "Bearbeitung",
|
||||
"defaultMessage": "Editing",
|
||||
"description": "Title over message editing preview",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_edited_marker": {
|
||||
"translation": ", bearbeitet",
|
||||
"defaultMessage": ", edited",
|
||||
"description": "Marker indicating that the message was edited",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_add_image": {
|
||||
"translation": "Bild hinzufügen",
|
||||
"defaultMessage": "Add image",
|
||||
"description": "Icon tool tip for attaching an image",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_attach_file": {
|
||||
"translation": "Datei anhängen",
|
||||
"defaultMessage": "Attach file",
|
||||
"description": "Icon tool tip for attaching a file",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_delete": {
|
||||
"translation": "Aufnahme löschen",
|
||||
"defaultMessage": "Delete recording",
|
||||
"description": "Icon tool tip for deleting recorded audio",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_pause": {
|
||||
"translation": "Wiedergabe pausieren",
|
||||
"defaultMessage": "Pause playback",
|
||||
"description": "Icon tool tip for pausing audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_play": {
|
||||
"translation": "Aufnahme abspielen",
|
||||
"defaultMessage": "Play recording",
|
||||
"description": "Icon tool tip for starting audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_record_voice": {
|
||||
"translation": "Sprachnachricht aufnehmen",
|
||||
"defaultMessage": "Record voice message",
|
||||
"description": "Icon tool tip for recording a voice message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_resume": {
|
||||
"translation": "Wiedergabe fortsetzen",
|
||||
"defaultMessage": "Resume playback",
|
||||
"description": "Icon tool tip for resuming audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_send": {
|
||||
"translation": "Nachricht senden",
|
||||
"defaultMessage": "Send message",
|
||||
"description": "Icon tool tip for sending a message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drag_file": {
|
||||
"translation": "Datei hierher ziehen",
|
||||
"defaultMessage": "Drag file here",
|
||||
"description": "Prompt on the file drag-n-drop overlay banner",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_video": {
|
||||
"translation": "Videoaufnahme",
|
||||
"defaultMessage": "Video recording",
|
||||
"description": "Comment for videos embedded in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_in_progress": {
|
||||
"translation": "in Arbeit",
|
||||
"defaultMessage": "in progress",
|
||||
"description": "Label for call in progress",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unrecognized_video_format": {
|
||||
"translation": "Das Format dieses Videos wird nicht erkannt",
|
||||
"defaultMessage": "Format of this video is not recognized",
|
||||
"description": "Error message when uploaded video is invalid",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+108
-10
@@ -307,13 +307,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"deleted_content": {
|
||||
"translation": "content deleted",
|
||||
"defaultMessage": "content deleted",
|
||||
"description": "Shown when messages are deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"download_action": {
|
||||
"translation": "download",
|
||||
"defaultMessage": "download",
|
||||
@@ -324,21 +317,21 @@
|
||||
"drafty_attachment": {
|
||||
"translation": "Attachment",
|
||||
"defaultMessage": "Attachment",
|
||||
"description": "Comment for attachment in drafty",
|
||||
"description": "Comment for attachment in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_form": {
|
||||
"translation": "Form:",
|
||||
"defaultMessage": "Form:",
|
||||
"description": "Comment for form in drafty",
|
||||
"description": "Comment for form in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_image": {
|
||||
"translation": "Picture",
|
||||
"defaultMessage": "Picture",
|
||||
"description": "Comment for embedded images in drafty",
|
||||
"description": "Comment for embedded images in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
@@ -1451,5 +1444,110 @@
|
||||
"description": "Message explaining that the credential was successfully validated.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_edit": {
|
||||
"translation": "Edit",
|
||||
"defaultMessage": "Edit",
|
||||
"description": "Edit message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"editing_message": {
|
||||
"translation": "Editing",
|
||||
"defaultMessage": "Editing",
|
||||
"description": "Title over message editing preview",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_edited_marker": {
|
||||
"translation": ", edited",
|
||||
"defaultMessage": ", edited",
|
||||
"description": "Marker indicating that the message was edited",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_add_image": {
|
||||
"translation": "Add image",
|
||||
"defaultMessage": "Add image",
|
||||
"description": "Icon tool tip for attaching an image",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_attach_file": {
|
||||
"translation": "Attach file",
|
||||
"defaultMessage": "Attach file",
|
||||
"description": "Icon tool tip for attaching a file",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_delete": {
|
||||
"translation": "Delete",
|
||||
"defaultMessage": "Delete recording",
|
||||
"description": "Icon tool tip for deleting recorded audio",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_pause": {
|
||||
"translation": "Pause",
|
||||
"defaultMessage": "Pause playback",
|
||||
"description": "Icon tool tip for pausing audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_play": {
|
||||
"translation": "Play",
|
||||
"defaultMessage": "Play recording",
|
||||
"description": "Icon tool tip for starting audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_record_voice": {
|
||||
"translation": "Record voice message",
|
||||
"defaultMessage": "Record voice message",
|
||||
"description": "Icon tool tip for recording a voice message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_resume": {
|
||||
"translation": "Resume",
|
||||
"defaultMessage": "Resume playback",
|
||||
"description": "Icon tool tip for resuming audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_send": {
|
||||
"translation": "Send message",
|
||||
"defaultMessage": "Send message",
|
||||
"description": "Icon tool tip for sending a message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drag_file": {
|
||||
"translation": "Drag file here",
|
||||
"defaultMessage": "Drag file here",
|
||||
"description": "Prompt on the file drag-n-drop overlay banner",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_video": {
|
||||
"translation": "Video recording",
|
||||
"defaultMessage": "Video recording",
|
||||
"description": "Comment for videos embedded in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_in_progress": {
|
||||
"translation": "in progress",
|
||||
"defaultMessage": "in progress",
|
||||
"description": "Label for call in progress",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unrecognized_video_format": {
|
||||
"translation": "Format of this video is not recognized",
|
||||
"defaultMessage": "Format of this video is not recognized",
|
||||
"description": "Error message when uploaded video is invalid",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+108
-10
@@ -307,13 +307,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"deleted_content": {
|
||||
"translation": "este mensaje fue eliminado",
|
||||
"defaultMessage": "content deleted",
|
||||
"description": "Shown when messages are deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"download_action": {
|
||||
"translation": "descargar",
|
||||
"defaultMessage": "download",
|
||||
@@ -324,21 +317,21 @@
|
||||
"drafty_attachment": {
|
||||
"translation": "Archivo",
|
||||
"defaultMessage": "Attachment",
|
||||
"description": "Comment for attachment in drafty",
|
||||
"description": "Comment for attachment in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_form": {
|
||||
"translation": "Formulario:",
|
||||
"defaultMessage": "Form:",
|
||||
"description": "Comment for form in drafty",
|
||||
"description": "Comment for form in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_image": {
|
||||
"translation": "Imagen",
|
||||
"defaultMessage": "Picture",
|
||||
"description": "Comment for embedded images in drafty",
|
||||
"description": "Comment for embedded images in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
@@ -1451,5 +1444,110 @@
|
||||
"description": "Message explaining that the credential was successfully validated.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_edit": {
|
||||
"translation": "Editar",
|
||||
"defaultMessage": "Edit",
|
||||
"description": "Edit message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"editing_message": {
|
||||
"translation": "Edición",
|
||||
"defaultMessage": "Editing",
|
||||
"description": "Title over message editing preview",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_edited_marker": {
|
||||
"translation": ", editado",
|
||||
"defaultMessage": ", edited",
|
||||
"description": "Marker indicating that the message was edited",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_add_image": {
|
||||
"translation": "Añadir imagen",
|
||||
"defaultMessage": "Add image",
|
||||
"description": "Icon tool tip for attaching an image",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_attach_file": {
|
||||
"translation": "Adjuntar archivo",
|
||||
"defaultMessage": "Attach file",
|
||||
"description": "Icon tool tip for attaching a file",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_delete": {
|
||||
"translation": "Eliminar grabación",
|
||||
"defaultMessage": "Delete recording",
|
||||
"description": "Icon tool tip for deleting recorded audio",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_pause": {
|
||||
"translation": "Pausar reproducción",
|
||||
"defaultMessage": "Pause playback",
|
||||
"description": "Icon tool tip for pausing audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_play": {
|
||||
"translation": "Reproducir grabación",
|
||||
"defaultMessage": "Play recording",
|
||||
"description": "Icon tool tip for starting audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_record_voice": {
|
||||
"translation": "Grabar mensaje de voz",
|
||||
"defaultMessage": "Record voice message",
|
||||
"description": "Icon tool tip for recording a voice message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_resume": {
|
||||
"translation": "Reanudar la reproducción",
|
||||
"defaultMessage": "Resume playback",
|
||||
"description": "Icon tool tip for resuming audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_send": {
|
||||
"translation": "Enviar mensaje",
|
||||
"defaultMessage": "Send message",
|
||||
"description": "Icon tool tip for sending a message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drag_file": {
|
||||
"translation": "Arrastra el archivo aquí",
|
||||
"defaultMessage": "Drag file here",
|
||||
"description": "Prompt on the file drag-n-drop overlay banner",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_video": {
|
||||
"translation": "Grabación de vídeo",
|
||||
"defaultMessage": "Video recording",
|
||||
"description": "Comment for videos embedded in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_in_progress": {
|
||||
"translation": "en progreso",
|
||||
"defaultMessage": "in progress",
|
||||
"description": "Label for call in progress",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unrecognized_video_format": {
|
||||
"translation": "No se reconoce el formato de este video",
|
||||
"defaultMessage": "Format of this video is not recognized",
|
||||
"description": "Error message when uploaded video is invalid",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,10 @@
|
||||
"defaultMessage": "disconnected",
|
||||
"description": "Label for disconnected call"
|
||||
},
|
||||
"call_in_progress": {
|
||||
"defaultMessage": "in progress",
|
||||
"description": "Label for call in progress"
|
||||
},
|
||||
"call_missed": {
|
||||
"defaultMessage": "missed",
|
||||
"description": "Label for missed incoming call"
|
||||
@@ -215,10 +219,6 @@
|
||||
"defaultMessage": "Are you sure you want to delete all messages for everyone? It cannot be undone.",
|
||||
"description": "Alert dialog warning when hard-deleting all messages."
|
||||
},
|
||||
"deleted_content": {
|
||||
"defaultMessage": "content deleted",
|
||||
"description": "Shown when messages are deleted"
|
||||
},
|
||||
"description_editing_placeholder": {
|
||||
"defaultMessage": "Description (optional)",
|
||||
"description": "Placeholder for editing topic description"
|
||||
@@ -229,20 +229,32 @@
|
||||
},
|
||||
"drafty_attachment": {
|
||||
"defaultMessage": "Attachment",
|
||||
"description": "Comment for attachment in drafty"
|
||||
"description": "Comment for attachment in Drafty"
|
||||
},
|
||||
"drafty_form": {
|
||||
"defaultMessage": "Form:",
|
||||
"description": "Comment for form in drafty"
|
||||
"description": "Comment for form in Drafty"
|
||||
},
|
||||
"drafty_image": {
|
||||
"defaultMessage": "Picture",
|
||||
"description": "Comment for embedded images in drafty"
|
||||
"description": "Comment for embedded images in Drafty"
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty"
|
||||
},
|
||||
"drafty_video": {
|
||||
"defaultMessage": "Video recording",
|
||||
"description": "Comment for videos embedded in Drafty"
|
||||
},
|
||||
"drag_file": {
|
||||
"defaultMessage": "Drag file here",
|
||||
"description": "Prompt on the file drag-n-drop overlay banner"
|
||||
},
|
||||
"editing_message": {
|
||||
"defaultMessage": "Editing",
|
||||
"description": "Title over message editing preview"
|
||||
},
|
||||
"email_dative": {
|
||||
"defaultMessage": "email",
|
||||
"description": "Dative case of 'email', i.e. 'email' in 'by email'"
|
||||
@@ -294,6 +306,38 @@
|
||||
"defaultMessage": "Group or User ID",
|
||||
"description": "Prompt for entering user or group ID"
|
||||
},
|
||||
"icon_title_add_image": {
|
||||
"defaultMessage": "Add image",
|
||||
"description": "Icon tool tip for attaching an image"
|
||||
},
|
||||
"icon_title_attach_file": {
|
||||
"defaultMessage": "Attach file",
|
||||
"description": "Icon tool tip for attaching a file"
|
||||
},
|
||||
"icon_title_delete": {
|
||||
"defaultMessage": "Delete recording",
|
||||
"description": "Icon tool tip for deleting recorded audio"
|
||||
},
|
||||
"icon_title_pause": {
|
||||
"defaultMessage": "Pause playback",
|
||||
"description": "Icon tool tip for pausing audio playback"
|
||||
},
|
||||
"icon_title_play": {
|
||||
"defaultMessage": "Play recording",
|
||||
"description": "Icon tool tip for starting audio playback"
|
||||
},
|
||||
"icon_title_record_voice": {
|
||||
"defaultMessage": "Record voice message",
|
||||
"description": "Icon tool tip for recording a voice message"
|
||||
},
|
||||
"icon_title_resume": {
|
||||
"defaultMessage": "Resume playback",
|
||||
"description": "Icon tool tip for resuming audio playback"
|
||||
},
|
||||
"icon_title_send": {
|
||||
"defaultMessage": "Send message",
|
||||
"description": "Icon tool tip for sending a message"
|
||||
},
|
||||
"image_caption_prompt": {
|
||||
"defaultMessage": "Image caption",
|
||||
"description": "Prompt in SendMessage for attached image"
|
||||
@@ -480,6 +524,10 @@
|
||||
"defaultMessage": "Delete",
|
||||
"description": "Delete entire topic"
|
||||
},
|
||||
"menu_item_edit": {
|
||||
"defaultMessage": "Edit",
|
||||
"description": "Edit message"
|
||||
},
|
||||
"menu_item_edit_permissions": {
|
||||
"defaultMessage": "Edit permissions",
|
||||
"description": "Menu item [Edit permissions]"
|
||||
@@ -524,6 +572,10 @@
|
||||
"defaultMessage": "Video call",
|
||||
"description": "Start video call"
|
||||
},
|
||||
"message_edited_marker": {
|
||||
"defaultMessage": ", edited",
|
||||
"description": "Marker indicating that the message was edited"
|
||||
},
|
||||
"message_sending": {
|
||||
"defaultMessage": "sending...",
|
||||
"description": "Message being sent, in place of time stamp"
|
||||
@@ -812,6 +864,10 @@
|
||||
"defaultMessage": "Unnamed",
|
||||
"description": "Title shown when the topic has no name"
|
||||
},
|
||||
"unrecognized_video_format": {
|
||||
"defaultMessage": "Format of this video is not recognized",
|
||||
"description": "Error message when uploaded video is invalid"
|
||||
},
|
||||
"update_available": {
|
||||
"defaultMessage": "Update available.",
|
||||
"description": "Message shown when an app update is available."
|
||||
|
||||
+108
-10
@@ -307,13 +307,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"deleted_content": {
|
||||
"translation": "contenu supprimé",
|
||||
"defaultMessage": "content deleted",
|
||||
"description": "Shown when messages are deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"download_action": {
|
||||
"translation": "télécharger",
|
||||
"defaultMessage": "download",
|
||||
@@ -324,21 +317,21 @@
|
||||
"drafty_attachment": {
|
||||
"translation": "Pièce(s) jointe(s)",
|
||||
"defaultMessage": "Attachment",
|
||||
"description": "Comment for attachment in drafty",
|
||||
"description": "Comment for attachment in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_form": {
|
||||
"translation": "Formulaire:",
|
||||
"defaultMessage": "Form:",
|
||||
"description": "Comment for form in drafty",
|
||||
"description": "Comment for form in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_image": {
|
||||
"translation": "Image",
|
||||
"defaultMessage": "Picture",
|
||||
"description": "Comment for embedded images in drafty",
|
||||
"description": "Comment for embedded images in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
@@ -1451,5 +1444,110 @@
|
||||
"description": "Message explaining that the credential was successfully validated.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_edit": {
|
||||
"translation": "Modifier",
|
||||
"defaultMessage": "Edit",
|
||||
"description": "Edit message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"editing_message": {
|
||||
"translation": "Édition",
|
||||
"defaultMessage": "Editing",
|
||||
"description": "Title over message editing preview",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_edited_marker": {
|
||||
"translation": ", édité",
|
||||
"defaultMessage": ", edited",
|
||||
"description": "Marker indicating that the message was edited",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_add_image": {
|
||||
"translation": "Ajouter une image",
|
||||
"defaultMessage": "Add image",
|
||||
"description": "Icon tool tip for attaching an image",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_attach_file": {
|
||||
"translation": "Pièce jointe",
|
||||
"defaultMessage": "Attach file",
|
||||
"description": "Icon tool tip for attaching a file",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_delete": {
|
||||
"translation": "Supprimer l'enregistrement",
|
||||
"defaultMessage": "Delete recording",
|
||||
"description": "Icon tool tip for deleting recorded audio",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_pause": {
|
||||
"translation": "Interrompre la lecture",
|
||||
"defaultMessage": "Pause playback",
|
||||
"description": "Icon tool tip for pausing audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_play": {
|
||||
"translation": "Lire l'enregistrement",
|
||||
"defaultMessage": "Play recording",
|
||||
"description": "Icon tool tip for starting audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_record_voice": {
|
||||
"translation": "Enregistrer un message vocal",
|
||||
"defaultMessage": "Record voice message",
|
||||
"description": "Icon tool tip for recording a voice message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_resume": {
|
||||
"translation": "Reprendre la lecture",
|
||||
"defaultMessage": "Resume playback",
|
||||
"description": "Icon tool tip for resuming audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_send": {
|
||||
"translation": "Envoyer le message",
|
||||
"defaultMessage": "Send message",
|
||||
"description": "Icon tool tip for sending a message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drag_file": {
|
||||
"translation": "Faites glisser le fichier ici",
|
||||
"defaultMessage": "Drag file here",
|
||||
"description": "Prompt on the file drag-n-drop overlay banner",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_video": {
|
||||
"translation": "Enregistrement video",
|
||||
"defaultMessage": "Video recording",
|
||||
"description": "Comment for videos embedded in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_in_progress": {
|
||||
"translation": "en cours",
|
||||
"defaultMessage": "in progress",
|
||||
"description": "Label for call in progress",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unrecognized_video_format": {
|
||||
"translation": "Le format de cette vidéo n'est pas reconnu",
|
||||
"defaultMessage": "Format of this video is not recognized",
|
||||
"description": "Error message when uploaded video is invalid",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+108
-10
@@ -307,13 +307,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"deleted_content": {
|
||||
"translation": "내용이 삭제되었습니다",
|
||||
"defaultMessage": "content deleted",
|
||||
"description": "Shown when messages are deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"download_action": {
|
||||
"translation": "다운로드",
|
||||
"defaultMessage": "download",
|
||||
@@ -324,21 +317,21 @@
|
||||
"drafty_attachment": {
|
||||
"translation": "부착",
|
||||
"defaultMessage": "Attachment",
|
||||
"description": "Comment for attachment in drafty",
|
||||
"description": "Comment for attachment in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_form": {
|
||||
"translation": "형태:",
|
||||
"defaultMessage": "Form:",
|
||||
"description": "Comment for form in drafty",
|
||||
"description": "Comment for form in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_image": {
|
||||
"translation": "이미지",
|
||||
"defaultMessage": "Picture",
|
||||
"description": "Comment for embedded images in drafty",
|
||||
"description": "Comment for embedded images in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
@@ -1451,5 +1444,110 @@
|
||||
"description": "Message explaining that the credential was successfully validated.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_edit": {
|
||||
"translation": "편집",
|
||||
"defaultMessage": "Edit",
|
||||
"description": "Edit message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"editing_message": {
|
||||
"translation": "교정하다",
|
||||
"defaultMessage": "Editing",
|
||||
"description": "Title over message editing preview",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_edited_marker": {
|
||||
"translation": " 수정됨",
|
||||
"defaultMessage": ", edited",
|
||||
"description": "Marker indicating that the message was edited",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_add_image": {
|
||||
"translation": "이미지 추가",
|
||||
"defaultMessage": "Add image",
|
||||
"description": "Icon tool tip for attaching an image",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_attach_file": {
|
||||
"translation": "파일을 첨부",
|
||||
"defaultMessage": "Attach file",
|
||||
"description": "Icon tool tip for attaching a file",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_delete": {
|
||||
"translation": "녹음 삭제",
|
||||
"defaultMessage": "Delete recording",
|
||||
"description": "Icon tool tip for deleting recorded audio",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_pause": {
|
||||
"translation": "재생 일시 중지",
|
||||
"defaultMessage": "Pause playback",
|
||||
"description": "Icon tool tip for pausing audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_play": {
|
||||
"translation": "녹음 재생",
|
||||
"defaultMessage": "Play recording",
|
||||
"description": "Icon tool tip for starting audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_record_voice": {
|
||||
"translation": "음성 메시지 녹음",
|
||||
"defaultMessage": "Record voice message",
|
||||
"description": "Icon tool tip for recording a voice message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_resume": {
|
||||
"translation": "재생 재개",
|
||||
"defaultMessage": "Resume playback",
|
||||
"description": "Icon tool tip for resuming audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_send": {
|
||||
"translation": "문자 보내",
|
||||
"defaultMessage": "Send message",
|
||||
"description": "Icon tool tip for sending a message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drag_file": {
|
||||
"translation": "여기로 파일 드래그",
|
||||
"defaultMessage": "Drag file here",
|
||||
"description": "Prompt on the file drag-n-drop overlay banner",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_video": {
|
||||
"translation": "녹화",
|
||||
"defaultMessage": "Video recording",
|
||||
"description": "Comment for videos embedded in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_in_progress": {
|
||||
"translation": "통화 중",
|
||||
"defaultMessage": "in progress",
|
||||
"description": "Label for call in progress",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unrecognized_video_format": {
|
||||
"translation": "이 동영상의 형식을 인식할 수 없습니다.",
|
||||
"defaultMessage": "Format of this video is not recognized",
|
||||
"description": "Error message when uploaded video is invalid",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+108
-10
@@ -307,13 +307,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"deleted_content": {
|
||||
"translation": "conținut șters",
|
||||
"defaultMessage": "content deleted",
|
||||
"description": "Shown when messages are deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"download_action": {
|
||||
"translation": "download",
|
||||
"defaultMessage": "download",
|
||||
@@ -324,21 +317,21 @@
|
||||
"drafty_attachment": {
|
||||
"translation": "Atasament",
|
||||
"defaultMessage": "Attachment",
|
||||
"description": "Comment for attachment in drafty",
|
||||
"description": "Comment for attachment in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_form": {
|
||||
"translation": "Formă:",
|
||||
"defaultMessage": "Form:",
|
||||
"description": "Comment for form in drafty",
|
||||
"description": "Comment for form in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_image": {
|
||||
"translation": "Imagine",
|
||||
"defaultMessage": "Picture",
|
||||
"description": "Comment for embedded images in drafty",
|
||||
"description": "Comment for embedded images in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
@@ -1451,5 +1444,110 @@
|
||||
"description": "Message explaining that the credential was successfully validated.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_edit": {
|
||||
"translation": "Editează",
|
||||
"defaultMessage": "Edit",
|
||||
"description": "Edit message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"editing_message": {
|
||||
"translation": "Editare",
|
||||
"defaultMessage": "Editing",
|
||||
"description": "Title over message editing preview",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_edited_marker": {
|
||||
"translation": ", editat",
|
||||
"defaultMessage": ", edited",
|
||||
"description": "Marker indicating that the message was edited",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_add_image": {
|
||||
"translation": "Adauga imagine",
|
||||
"defaultMessage": "Add image",
|
||||
"description": "Icon tool tip for attaching an image",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_attach_file": {
|
||||
"translation": "Ataseaza fisier",
|
||||
"defaultMessage": "Attach file",
|
||||
"description": "Icon tool tip for attaching a file",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_delete": {
|
||||
"translation": "Ștergeți înregistrarea",
|
||||
"defaultMessage": "Delete recording",
|
||||
"description": "Icon tool tip for deleting recorded audio",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_pause": {
|
||||
"translation": "Întrerupeți redarea",
|
||||
"defaultMessage": "Pause playback",
|
||||
"description": "Icon tool tip for pausing audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_play": {
|
||||
"translation": "Redați înregistrarea",
|
||||
"defaultMessage": "Play recording",
|
||||
"description": "Icon tool tip for starting audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_record_voice": {
|
||||
"translation": "Înregistrați mesajul vocal",
|
||||
"defaultMessage": "Record voice message",
|
||||
"description": "Icon tool tip for recording a voice message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_resume": {
|
||||
"translation": "Reluați redarea",
|
||||
"defaultMessage": "Resume playback",
|
||||
"description": "Icon tool tip for resuming audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_send": {
|
||||
"translation": "Trimite mesaj",
|
||||
"defaultMessage": "Send message",
|
||||
"description": "Icon tool tip for sending a message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drag_file": {
|
||||
"translation": "Trageți fișierul aici",
|
||||
"defaultMessage": "Drag file here",
|
||||
"description": "Prompt on the file drag-n-drop overlay banner",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_video": {
|
||||
"translation": "Înregistrare video",
|
||||
"defaultMessage": "Video recording",
|
||||
"description": "Comment for videos embedded in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_in_progress": {
|
||||
"translation": "în curs",
|
||||
"defaultMessage": "in progress",
|
||||
"description": "Label for call in progress",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unrecognized_video_format": {
|
||||
"translation": "Formatul acestui videoclip nu este recunoscut",
|
||||
"defaultMessage": "Format of this video is not recognized",
|
||||
"description": "Error message when uploaded video is invalid",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+108
-10
@@ -307,13 +307,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"deleted_content": {
|
||||
"translation": "удалено",
|
||||
"defaultMessage": "content deleted",
|
||||
"description": "Shown when messages are deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"download_action": {
|
||||
"translation": "скачать",
|
||||
"defaultMessage": "download",
|
||||
@@ -324,21 +317,21 @@
|
||||
"drafty_attachment": {
|
||||
"translation": "Аттачмент",
|
||||
"defaultMessage": "Attachment",
|
||||
"description": "Comment for attachment in drafty",
|
||||
"description": "Comment for attachment in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_form": {
|
||||
"translation": "Форма:",
|
||||
"defaultMessage": "Form:",
|
||||
"description": "Comment for form in drafty",
|
||||
"description": "Comment for form in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_image": {
|
||||
"translation": "Картинка",
|
||||
"defaultMessage": "Picture",
|
||||
"description": "Comment for embedded images in drafty",
|
||||
"description": "Comment for embedded images in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
@@ -1451,5 +1444,110 @@
|
||||
"description": "Message explaining that the credential was successfully validated.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_edit": {
|
||||
"translation": "Изменить",
|
||||
"defaultMessage": "Edit",
|
||||
"description": "Edit message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"editing_message": {
|
||||
"translation": "Редактирование",
|
||||
"defaultMessage": "Editing",
|
||||
"description": "Title over message editing preview",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_edited_marker": {
|
||||
"translation": ", изменено",
|
||||
"defaultMessage": ", edited",
|
||||
"description": "Marker indicating that the message was edited",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_add_image": {
|
||||
"translation": "Добавить изображение",
|
||||
"defaultMessage": "Add image",
|
||||
"description": "Icon tool tip for attaching an image",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_attach_file": {
|
||||
"translation": "Прикрепить файл",
|
||||
"defaultMessage": "Attach file",
|
||||
"description": "Icon tool tip for attaching a file",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_delete": {
|
||||
"translation": "Удалить запись",
|
||||
"defaultMessage": "Delete recording",
|
||||
"description": "Icon tool tip for deleting recorded audio",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_pause": {
|
||||
"translation": "Пауза воспроизведения",
|
||||
"defaultMessage": "Pause playback",
|
||||
"description": "Icon tool tip for pausing audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_play": {
|
||||
"translation": "Воспроизвести запись",
|
||||
"defaultMessage": "Play recording",
|
||||
"description": "Icon tool tip for starting audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_record_voice": {
|
||||
"translation": "Записать голосовое сообщение",
|
||||
"defaultMessage": "Record voice message",
|
||||
"description": "Icon tool tip for recording a voice message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_resume": {
|
||||
"translation": "Продолжить",
|
||||
"defaultMessage": "Resume playback",
|
||||
"description": "Icon tool tip for resuming audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_send": {
|
||||
"translation": "Отправить сообщение",
|
||||
"defaultMessage": "Send message",
|
||||
"description": "Icon tool tip for sending a message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drag_file": {
|
||||
"translation": "Перетащите файл сюда",
|
||||
"defaultMessage": "Drag file here",
|
||||
"description": "Prompt on the file drag-n-drop overlay banner",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_video": {
|
||||
"translation": "Видеозапись",
|
||||
"defaultMessage": "Video recording",
|
||||
"description": "Comment for videos embedded in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_in_progress": {
|
||||
"translation": "в процессе",
|
||||
"defaultMessage": "in progress",
|
||||
"description": "Label for call in progress",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unrecognized_video_format": {
|
||||
"translation": "Неизвестный формат видео файла",
|
||||
"defaultMessage": "Format of this video is not recognized",
|
||||
"description": "Error message when uploaded video is invalid",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+108
-10
@@ -307,13 +307,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"deleted_content": {
|
||||
"translation": "內容已被刪除",
|
||||
"defaultMessage": "content deleted",
|
||||
"description": "Shown when messages are deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"download_action": {
|
||||
"translation": "下載",
|
||||
"defaultMessage": "download",
|
||||
@@ -324,21 +317,21 @@
|
||||
"drafty_attachment": {
|
||||
"translation": "附加檔案",
|
||||
"defaultMessage": "Attachment",
|
||||
"description": "Comment for attachment in drafty",
|
||||
"description": "Comment for attachment in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_form": {
|
||||
"translation": "表單:",
|
||||
"defaultMessage": "Form:",
|
||||
"description": "Comment for form in drafty",
|
||||
"description": "Comment for form in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_image": {
|
||||
"translation": "圖片",
|
||||
"defaultMessage": "Picture",
|
||||
"description": "Comment for embedded images in drafty",
|
||||
"description": "Comment for embedded images in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
@@ -1451,5 +1444,110 @@
|
||||
"description": "Message explaining that the credential was successfully validated.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_edit": {
|
||||
"translation": "編輯",
|
||||
"defaultMessage": "Edit",
|
||||
"description": "Edit message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"editing_message": {
|
||||
"translation": "編輯",
|
||||
"defaultMessage": "Editing",
|
||||
"description": "Title over message editing preview",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_edited_marker": {
|
||||
"translation": ",編輯",
|
||||
"defaultMessage": ", edited",
|
||||
"description": "Marker indicating that the message was edited",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_add_image": {
|
||||
"translation": "添加圖片",
|
||||
"defaultMessage": "Add image",
|
||||
"description": "Icon tool tip for attaching an image",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_attach_file": {
|
||||
"translation": "附加文件",
|
||||
"defaultMessage": "Attach file",
|
||||
"description": "Icon tool tip for attaching a file",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_delete": {
|
||||
"translation": "刪除錄音",
|
||||
"defaultMessage": "Delete recording",
|
||||
"description": "Icon tool tip for deleting recorded audio",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_pause": {
|
||||
"translation": "暫停播放",
|
||||
"defaultMessage": "Pause playback",
|
||||
"description": "Icon tool tip for pausing audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_play": {
|
||||
"translation": "播放錄音",
|
||||
"defaultMessage": "Play recording",
|
||||
"description": "Icon tool tip for starting audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_record_voice": {
|
||||
"translation": "錄製語音消息",
|
||||
"defaultMessage": "Record voice message",
|
||||
"description": "Icon tool tip for recording a voice message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_resume": {
|
||||
"translation": "恢復播放",
|
||||
"defaultMessage": "Resume playback",
|
||||
"description": "Icon tool tip for resuming audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_send": {
|
||||
"translation": "發信息",
|
||||
"defaultMessage": "Send message",
|
||||
"description": "Icon tool tip for sending a message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drag_file": {
|
||||
"translation": "將文件拖到這裡",
|
||||
"defaultMessage": "Drag file here",
|
||||
"description": "Prompt on the file drag-n-drop overlay banner",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_video": {
|
||||
"translation": "視頻錄製",
|
||||
"defaultMessage": "Video recording",
|
||||
"description": "Comment for videos embedded in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_in_progress": {
|
||||
"translation": "通話中",
|
||||
"defaultMessage": "in progress",
|
||||
"description": "Label for call in progress",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unrecognized_video_format": {
|
||||
"translation": "無法識別此視頻的格式",
|
||||
"defaultMessage": "Format of this video is not recognized",
|
||||
"description": "Error message when uploaded video is invalid",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+108
-10
@@ -307,13 +307,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"deleted_content": {
|
||||
"translation": "内容已删除",
|
||||
"defaultMessage": "content deleted",
|
||||
"description": "Shown when messages are deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"download_action": {
|
||||
"translation": "下载",
|
||||
"defaultMessage": "download",
|
||||
@@ -324,21 +317,21 @@
|
||||
"drafty_attachment": {
|
||||
"translation": "附件",
|
||||
"defaultMessage": "Attachment",
|
||||
"description": "Comment for attachment in drafty",
|
||||
"description": "Comment for attachment in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_form": {
|
||||
"translation": "形式:",
|
||||
"defaultMessage": "Form:",
|
||||
"description": "Comment for form in drafty",
|
||||
"description": "Comment for form in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_image": {
|
||||
"translation": "图像",
|
||||
"defaultMessage": "Picture",
|
||||
"description": "Comment for embedded images in drafty",
|
||||
"description": "Comment for embedded images in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
@@ -1451,5 +1444,110 @@
|
||||
"description": "Message explaining that the credential was successfully validated.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_edit": {
|
||||
"translation": "编辑",
|
||||
"defaultMessage": "Edit",
|
||||
"description": "Edit message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"editing_message": {
|
||||
"translation": "编辑",
|
||||
"defaultMessage": "Editing",
|
||||
"description": "Title over message editing preview",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_edited_marker": {
|
||||
"translation": ",编辑",
|
||||
"defaultMessage": ", edited",
|
||||
"description": "Marker indicating that the message was edited",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_add_image": {
|
||||
"translation": "添加图片",
|
||||
"defaultMessage": "Add image",
|
||||
"description": "Icon tool tip for attaching an image",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_attach_file": {
|
||||
"translation": "附加文件",
|
||||
"defaultMessage": "Attach file",
|
||||
"description": "Icon tool tip for attaching a file",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_delete": {
|
||||
"translation": "删除录音",
|
||||
"defaultMessage": "Delete recording",
|
||||
"description": "Icon tool tip for deleting recorded audio",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_pause": {
|
||||
"translation": "暂停播放",
|
||||
"defaultMessage": "Pause playback",
|
||||
"description": "Icon tool tip for pausing audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_play": {
|
||||
"translation": "播放录音",
|
||||
"defaultMessage": "Play recording",
|
||||
"description": "Icon tool tip for starting audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_record_voice": {
|
||||
"translation": "录制语音信息",
|
||||
"defaultMessage": "Record voice message",
|
||||
"description": "Icon tool tip for recording a voice message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_resume": {
|
||||
"translation": "恢复播放",
|
||||
"defaultMessage": "Resume playback",
|
||||
"description": "Icon tool tip for resuming audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_send": {
|
||||
"translation": "发信息",
|
||||
"defaultMessage": "Send message",
|
||||
"description": "Icon tool tip for sending a message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drag_file": {
|
||||
"translation": "将文件拖到这里",
|
||||
"defaultMessage": "Drag file here",
|
||||
"description": "Prompt on the file drag-n-drop overlay banner",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_video": {
|
||||
"translation": "视频录制",
|
||||
"defaultMessage": "Video recording",
|
||||
"description": "Comment for videos embedded in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_in_progress": {
|
||||
"translation": "通话中",
|
||||
"defaultMessage": "in progress",
|
||||
"description": "Label for call in progress",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unrecognized_video_format": {
|
||||
"translation": "无法识别此视频的格式",
|
||||
"defaultMessage": "Format of this video is not recognized",
|
||||
"description": "Error message when uploaded video is invalid",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+24
-11
@@ -2,14 +2,26 @@
|
||||
// Used to generate umd/index.prod.js
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { IntlProvider } from 'react-intl';
|
||||
|
||||
import allMessages from './messages.json';
|
||||
import TinodeWeb from './views/tinode-web.jsx';
|
||||
import HashNavigation from './lib/navigation.js';
|
||||
|
||||
// Allow loading translation strings for just one language.
|
||||
const messageLoader = {
|
||||
'de': _ => import('./i18n.min/de.json'),
|
||||
'en': _ => import('./i18n.min/en.json'),
|
||||
'es': _ => import('./i18n.min/es.json'),
|
||||
'fr': _ => import('./i18n.min/fr.json'),
|
||||
'ko': _ => import('./i18n.min/ko.json'),
|
||||
'ro': _ => import('./i18n.min/ro.json'),
|
||||
'ru': _ => import('./i18n.min/ru.json'),
|
||||
'zh': _ => import('./i18n.min/zh.json'),
|
||||
'zh-TW': _ => import('./i18n.min/zh-TW.json')
|
||||
};
|
||||
|
||||
// Detect human language to use in the UI:
|
||||
// Check parameters from URL hash #?hl=ru, then browser, then use 'en' as a fallback.
|
||||
const { params } = HashNavigation.parseUrlHash(window.location.hash);
|
||||
@@ -19,21 +31,22 @@ const language = (params && params.hl) ||
|
||||
navigator.userLanguage ||
|
||||
'en';
|
||||
|
||||
// Get the base language 'en' from a more specific 'en_GB' or 'en-US' as a partial fallback.
|
||||
const baseLanguage = language.toLowerCase().split(/[-_]/)[0];
|
||||
// Make sure names like 'en_GB' and 'en-GB' consistently use '-'.
|
||||
const normalized = language.replace('_', '-');
|
||||
// Get the base language 'en' from a more specific 'en-US' as a partial fallback.
|
||||
const baseLanguage = normalized.split('-')[0].toLowerCase();
|
||||
|
||||
// Try the full locale first, then the locale without the region code, fallback to 'en'.
|
||||
const htmlLang = allMessages[language] ? language : allMessages[baseLanguage] ? baseLanguage : 'en';
|
||||
const messages = allMessages[htmlLang];
|
||||
const htmlLang = messageLoader[normalized] ? language : messageLoader[baseLanguage] ? baseLanguage : 'en';
|
||||
|
||||
// Set lang attribute of the HTML element: <html lang="XX">
|
||||
document.getElementsByTagName('html')[0].setAttribute('lang', htmlLang);
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
// Render the app.
|
||||
const root = createRoot(document.getElementById('mountPoint'));
|
||||
messageLoader[htmlLang]().then(messages =>
|
||||
root.render(
|
||||
<IntlProvider locale={language} messages={messages} textComponent={React.Fragment}>
|
||||
<TinodeWeb />
|
||||
</IntlProvider>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('mountPoint')
|
||||
);
|
||||
));
|
||||
|
||||
@@ -138,10 +138,10 @@ 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) => {
|
||||
img.onerror = _ => {
|
||||
reject(new Error("Image format unrecognized"));
|
||||
};
|
||||
img.onload = () => {
|
||||
img.onload = _ => {
|
||||
// Once the image is loaded, the URL is no longer needed.
|
||||
URL.revokeObjectURL(img.src);
|
||||
|
||||
@@ -172,10 +172,10 @@ export function imageCrop(mime, objURL, left, top, width, height, scale) {
|
||||
export function fileToBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = (evt) => {
|
||||
reader.onerror = _ => {
|
||||
reject(reader.error);
|
||||
};
|
||||
reader.onload = () => {
|
||||
reader.onload = _ => {
|
||||
resolve({mime: file.type, bits: reader.result.split(',')[1], name: file.name});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
@@ -189,7 +189,7 @@ export function blobToBase64(blob) {
|
||||
reader.onerror = _ => {
|
||||
reject(reader.error);
|
||||
};
|
||||
reader.onload = () => {
|
||||
reader.onload = _ => {
|
||||
resolve({mime: blob.type, bits: reader.result.split(',')[1]});
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
|
||||
+179
-67
@@ -6,10 +6,11 @@ import { Drafty } from 'tinode-sdk';
|
||||
import AudioPlayer from '../widgets/audio-player.jsx'
|
||||
import CallMessage from '../widgets/call-message.jsx'
|
||||
import CallStatus from '../widgets/call-status.jsx';
|
||||
import InlineVideo from '../widgets/inline-video.jsx';
|
||||
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 { BROKEN_IMAGE_SIZE, IMAGE_THUMBNAIL_DIM, NO_DIMENSIONS_VIDEO, REM_SIZE, VIDEO_THUMBNAIL_WIDTH } from '../config.js';
|
||||
import { base64ToBlob, blobToBase64, fitImageSize, imageScaled } from './blob-helpers.js';
|
||||
import { idToColorClass, secondsToTime, shortenFileName } from './strformat.js';
|
||||
import { cancelablePromise, sanitizeUrlForMime } from './utils.js';
|
||||
@@ -18,17 +19,22 @@ const messages = defineMessages({
|
||||
drafty_form: {
|
||||
id: 'drafty_form',
|
||||
defaultMessage: 'Form: ',
|
||||
description: 'Comment for form in drafty'
|
||||
description: 'Comment for form in Drafty'
|
||||
},
|
||||
drafty_attachment: {
|
||||
id: 'drafty_attachment',
|
||||
defaultMessage: 'Attachment',
|
||||
description: 'Comment for attachment in drafty'
|
||||
description: 'Comment for attachment in Drafty'
|
||||
},
|
||||
drafty_image: {
|
||||
id: 'drafty_image',
|
||||
defaultMessage: 'Picture',
|
||||
description: 'Comment for embedded images in drafty'
|
||||
description: 'Comment for embedded images in Drafty'
|
||||
},
|
||||
drafty_video: {
|
||||
id: 'drafty_video',
|
||||
defaultMessage: 'Video recording',
|
||||
description: 'Comment for videos embedded in Drafty'
|
||||
},
|
||||
drafty_unknown: {
|
||||
id: 'drafty_unknown',
|
||||
@@ -37,57 +43,14 @@ const messages = defineMessages({
|
||||
}
|
||||
});
|
||||
|
||||
// Additional processing of image data.
|
||||
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(sanitizeUrlForMime(attr.src, 'image'));
|
||||
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;
|
||||
}
|
||||
|
||||
// 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)
|
||||
// viewportWidth:
|
||||
// authorizeURL:
|
||||
// onImagePreview:
|
||||
// onVideoPreview:
|
||||
// onFormButtonClick:
|
||||
// onQuoteClick:
|
||||
export function fullFormatter(style, data, values, key, stack) {
|
||||
if (stack.includes('QQ')) {
|
||||
return quoteFormatter.call(this, style, data, values, key);
|
||||
@@ -175,6 +138,12 @@ export function fullFormatter(style, data, values, key, stack) {
|
||||
attr.duration = data.duration;
|
||||
}
|
||||
break;
|
||||
case 'VD':
|
||||
// Additional processing for videos.
|
||||
el = handleVideoData.call(this, el, data, attr);
|
||||
// Video element cannot have content.
|
||||
values = null;
|
||||
break;
|
||||
default:
|
||||
if (!el) {
|
||||
// Unknown element.
|
||||
@@ -195,6 +164,90 @@ export function fullFormatter(style, data, values, key, stack) {
|
||||
return React.createElement(el, attr, values);
|
||||
}
|
||||
|
||||
// Additional processing of image data.
|
||||
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(sanitizeUrlForMime(attr.src, 'image'));
|
||||
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 = null;
|
||||
}
|
||||
} else {
|
||||
// Use custom element instead of <img>.
|
||||
el = UploadingImage;
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
// Additional processing of image data.
|
||||
function handleVideoData(el, data, attr) {
|
||||
if (!data) {
|
||||
attr.src = 'img/broken_video.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: NO_DIMENSIONS_VIDEO, dstHeight: NO_DIMENSIONS_VIDEO};
|
||||
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(sanitizeUrlForMime(attr.src, 'image'));
|
||||
attr.alt = data.name;
|
||||
if (data.ref || data.val) {
|
||||
attr.onClick = this.onVideoPreview;
|
||||
attr.loading = 'lazy';
|
||||
} else {
|
||||
attr.src = null;
|
||||
}
|
||||
el = InlineVideo;
|
||||
} else {
|
||||
// Use custom element instead of <img>.
|
||||
el = UploadingImage;
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
// Converts Drafty object into a one-line preview. 'this' is set by the caller.
|
||||
// 'this' must contain:
|
||||
// formatMessage: this.props.intl.formatMessage
|
||||
@@ -271,6 +324,11 @@ export function previewFormatter(style, data, values, key) {
|
||||
el = null;
|
||||
values = null;
|
||||
break;
|
||||
case 'VD':
|
||||
// Replace image with '[icon] Video'.
|
||||
el = React.Fragment;
|
||||
values = [<i key="im" className="material-icons">play_circle_outline</i>, ' ', this.formatMessage(messages.drafty_video)];
|
||||
break;
|
||||
default:
|
||||
if (!el) {
|
||||
// Unknown element.
|
||||
@@ -285,7 +343,7 @@ export function previewFormatter(style, data, values, key) {
|
||||
return React.createElement(el, attr, values);
|
||||
};
|
||||
|
||||
// Converts Drafty object into a quoted reply. 'this' is set by the caller.
|
||||
// Converts Drafty object into a quoted reply; 'this' is set by the caller.
|
||||
function inlineImageAttr(attr, data) {
|
||||
attr.style = {
|
||||
width: IMAGE_THUMBNAIL_DIM + 'px',
|
||||
@@ -297,11 +355,33 @@ function inlineImageAttr(attr, data) {
|
||||
attr.alt = this.formatMessage(messages.drafty_image);
|
||||
if (!data) {
|
||||
attr.src = 'img/broken_image.png';
|
||||
} else {
|
||||
attr.src = attr.src || 'img/broken_image.png';
|
||||
}
|
||||
attr.title = attr.alt;
|
||||
return attr;
|
||||
}
|
||||
|
||||
// Converts Drafty object into a quoted reply; 'this' is set by the caller.
|
||||
function inlineVideoAttr(attr, data) {
|
||||
const dim = fitImageSize(data.width, data.height, VIDEO_THUMBNAIL_WIDTH, IMAGE_THUMBNAIL_DIM);
|
||||
attr.style = {
|
||||
width: dim.width + 'px',
|
||||
height: dim.height + 'px',
|
||||
maxWidth: VIDEO_THUMBNAIL_WIDTH + 'px',
|
||||
maxHeight: IMAGE_THUMBNAIL_DIM + 'px',
|
||||
}
|
||||
attr.className = 'inline-image';
|
||||
attr.alt = this.formatMessage(messages.drafty_video);
|
||||
attr.title = attr.alt;
|
||||
if (!data) {
|
||||
attr.src = 'img/broken_video.png';
|
||||
} else {
|
||||
attr.src = attr.src || 'img/broken_video.png';
|
||||
}
|
||||
return attr;
|
||||
}
|
||||
|
||||
// Displays a portion of Drafty within 'QQ' quotes. 'this' is set by the caller.
|
||||
// 'this' must contain:
|
||||
// formatMessage: this.props.intl.formatMessage
|
||||
@@ -309,7 +389,7 @@ function inlineImageAttr(attr, data) {
|
||||
// authorizeURL: this.props.tinode.authorizeURL
|
||||
// onQuoteClick: this.handleQuoteClick (optional)
|
||||
function quoteFormatter(style, data, values, key) {
|
||||
if (['BR', 'EX', 'IM', 'MN'].includes(style)) {
|
||||
if (['BR', 'EX', 'IM', 'MN', 'VD'].includes(style)) {
|
||||
let el = Drafty.tagName(style);
|
||||
let attr = Drafty.attrValue(style, data) || {};
|
||||
attr.key = key;
|
||||
@@ -324,6 +404,13 @@ function quoteFormatter(style, data, values, key) {
|
||||
// Fragment attributes.
|
||||
attr = {key: key};
|
||||
break;
|
||||
case 'VD':
|
||||
attr = inlineVideoAttr.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'
|
||||
@@ -354,17 +441,27 @@ function quoteFormatter(style, data, values, key) {
|
||||
}
|
||||
|
||||
// Create image thumbnail suitable for inclusion in a quote.
|
||||
function quoteImage(data) {
|
||||
function quoteImageOrVideo(data, isVideo) {
|
||||
let promise;
|
||||
let bits, ref, mime;
|
||||
if (isVideo) {
|
||||
bits = data.preview;
|
||||
mime = data.premime || 'image/jpeg';
|
||||
ref = data.preref;
|
||||
} else {
|
||||
bits = data.val;
|
||||
mime = data.mime;
|
||||
ref = data.ref;
|
||||
}
|
||||
// Get the blob from the image data.
|
||||
if (data.val) {
|
||||
const blob = base64ToBlob(data.val, data.mime);
|
||||
if (bits) {
|
||||
const blob = base64ToBlob(bits, mime);
|
||||
if (!blob) {
|
||||
throw new Error("Invalid image");
|
||||
}
|
||||
promise = Promise.resolve(blob);
|
||||
} else if (data.ref) {
|
||||
promise = fetch(this.authorizeURL(sanitizeUrlForMime(data.ref, 'image')))
|
||||
} else if (ref) {
|
||||
promise = fetch(this.authorizeURL(sanitizeUrlForMime(ref, 'image')))
|
||||
.then(evt => {
|
||||
if (evt.ok) {
|
||||
return evt.blob();
|
||||
@@ -379,23 +476,34 @@ function quoteImage(data) {
|
||||
// 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)
|
||||
// If it's an image, cut the square from the center of the image and shrink it.
|
||||
// If it's a video, allow it to be rectantular.
|
||||
return imageScaled(blob, isVideo ? VIDEO_THUMBNAIL_WIDTH : IMAGE_THUMBNAIL_DIM, IMAGE_THUMBNAIL_DIM, -1, !isVideo)
|
||||
}).then(scaled => {
|
||||
data.mime = scaled.mime;
|
||||
if (isVideo) {
|
||||
data.premime = scaled.mime;
|
||||
} else {
|
||||
data.mime = scaled.mime;
|
||||
}
|
||||
data.size = scaled.blob.size;
|
||||
data.width = scaled.width;
|
||||
data.height = scaled.height;
|
||||
delete data.ref;
|
||||
delete data.preref;
|
||||
// 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;
|
||||
if (isVideo) {
|
||||
data.preview = b64.bits;
|
||||
} else {
|
||||
data.val = b64.bits;
|
||||
}
|
||||
return data;
|
||||
}).catch(err => {
|
||||
delete data.val;
|
||||
delete data.preview;
|
||||
delete data.src;
|
||||
data.width = IMAGE_THUMBNAIL_DIM;
|
||||
data.height = IMAGE_THUMBNAIL_DIM;
|
||||
@@ -406,12 +514,16 @@ function quoteImage(data) {
|
||||
|
||||
// Create a preview of a reply.
|
||||
export function replyFormatter(style, data, values, key, stack) {
|
||||
if (style == 'IM') {
|
||||
const attr = inlineImageAttr.call(this, {key: key}, data);
|
||||
if (style == 'IM' || style == 'VD') {
|
||||
const isImage = style == 'IM';
|
||||
const attr = isImage ? inlineImageAttr.call(this, {key: key}, data) :
|
||||
inlineVideoAttr.call(this, {key: key}, data);
|
||||
|
||||
let loadedPromise;
|
||||
try {
|
||||
loadedPromise = cancelablePromise(quoteImage.call(this, data));
|
||||
loadedPromise = cancelablePromise(quoteImageOrVideo.call(this, data, style == 'VD'));
|
||||
} catch (error) {
|
||||
console.warn("Failed to quote image:", error.message);
|
||||
loadedPromise = cancelablePromise(error);
|
||||
}
|
||||
attr.whenDone = loadedPromise;
|
||||
|
||||
+22
-3
@@ -16,16 +16,35 @@ export function shortDateFormat(then, locale) {
|
||||
return then.toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'});
|
||||
}
|
||||
|
||||
// Representation a date relative to now.
|
||||
export function relativeDateFormat(then, locale) {
|
||||
locale = locale || window.navigator.userLanguage || window.navigator.language;
|
||||
const now = new Date();
|
||||
const thenDays = Math.floor((then.getTime() - then.getTimezoneOffset() * 60_000) / 86_400_000);
|
||||
const nowDays = Math.floor((now.getTime() - now.getTimezoneOffset() * 60_000) / 86_400_000);
|
||||
const diff = thenDays - nowDays;
|
||||
if (Math.abs(diff) < 2) {
|
||||
// Today or yesterday.
|
||||
return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(diff, 'day')
|
||||
}
|
||||
// More than two days: just show the date.
|
||||
return new Intl.DateTimeFormat(locale).format(then);
|
||||
}
|
||||
|
||||
// Convert seconds to [hours:]minutes:seconds, i.e. 156 sec -> 2:36, 3756 sec -> 1:02:36.
|
||||
// If <code>fixedMins</code> is true, then minutes are represented by at least two digits.
|
||||
export function secondsToTime(seconds, fixedMin) {
|
||||
let min = Math.floor(seconds / 60) | 0;
|
||||
let hours = Math.floor(min / 60) | 0;
|
||||
if (typeof seconds != 'number') {
|
||||
return '';
|
||||
}
|
||||
|
||||
let min = (Math.floor(seconds / 60) | 0) % 60;
|
||||
let hours = Math.floor(seconds / 36000) | 0;
|
||||
|
||||
if (fixedMin || hours > 0) {
|
||||
min = min < 10 ? `0${min}` : min;
|
||||
}
|
||||
let sec = (seconds % 60) | 0;
|
||||
let sec = (seconds | 0) % 60;
|
||||
sec = sec < 10 ? `0${sec}` : sec;
|
||||
if (hours == 0) {
|
||||
return `${min}:${sec}`;
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,3 +1,3 @@
|
||||
// This is a generated file. Don't edit.
|
||||
|
||||
export const PACKAGE_VERSION = "0.20.4";
|
||||
export const PACKAGE_VERSION = "0.21.0-beta1";
|
||||
|
||||
@@ -76,7 +76,7 @@ class AccSecurityView extends React.Component {
|
||||
this.props.onShowAlert(
|
||||
formatMessage(messages.delete_account), // title
|
||||
formatMessage(messages.delete_account_warning), // content
|
||||
(() => { this.props.onDeleteAccount(); }), // onConfirm
|
||||
(_ => this.props.onDeleteAccount()), // onConfirm
|
||||
null, // use default text "OK"
|
||||
true, // Show Reject button
|
||||
null // use default text "Cancel"
|
||||
|
||||
@@ -410,11 +410,10 @@ class InfoView extends React.Component {
|
||||
}
|
||||
|
||||
const isMe = this.props.tinode.isMe(params.topicName);
|
||||
const menuItems = [
|
||||
{title: formatMessage(messages.edit_permissions), handler: () => {
|
||||
this.handleLaunchPermissionsEditor(isMe ? 'want' : 'user', params.topicName);
|
||||
}}
|
||||
];
|
||||
const menuItems = [{
|
||||
title: formatMessage(messages.edit_permissions),
|
||||
handler: _ => this.handleLaunchPermissionsEditor(isMe ? 'want' : 'user', params.topicName)
|
||||
}];
|
||||
if (!isMe) {
|
||||
menuItems.push('member_delete');
|
||||
}
|
||||
|
||||
+416
-117
@@ -18,14 +18,15 @@ import LoadSpinner from '../widgets/load-spinner.jsx';
|
||||
import LogoView from './logo-view.jsx';
|
||||
import MetaMessage from '../widgets/meta-message.jsx';
|
||||
import SendMessage from '../widgets/send-message.jsx';
|
||||
import VideoPreview from '../widgets/video-preview.jsx';
|
||||
|
||||
import { DEFAULT_P2P_ACCESS_MODE, IMAGE_PREVIEW_DIM, KEYPRESS_DELAY,
|
||||
import { DEFAULT_P2P_ACCESS_MODE, EDIT_PREVIEW_LENGTH, IMAGE_PREVIEW_DIM, KEYPRESS_DELAY,
|
||||
MESSAGES_PAGE, MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE,
|
||||
READ_DELAY, QUOTED_REPLY_LENGTH } from '../config.js';
|
||||
READ_DELAY, QUOTED_REPLY_LENGTH, VIDEO_PREVIEW_DIM } from '../config.js';
|
||||
import { CALL_STATE_OUTGOING_INITATED, CALL_STATE_IN_PROGRESS } from '../constants.js';
|
||||
import { blobToBase64, fileToBase64, imageScaled, makeImageUrl } from '../lib/blob-helpers.js';
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import { bytesToHumanSize, shortDateFormat } from '../lib/strformat.js';
|
||||
import { bytesToHumanSize, relativeDateFormat, shortDateFormat } from '../lib/strformat.js';
|
||||
|
||||
// Run timer with this frequency (ms) for checking notification queue.
|
||||
const NOTIFICATION_EXEC_INTERVAL = 300;
|
||||
@@ -63,6 +64,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'invalid content',
|
||||
description: 'Shown when the message is unreadable'
|
||||
},
|
||||
editing_message: {
|
||||
id: 'editing_message',
|
||||
defaultMessage: 'Editing',
|
||||
description: 'Title over message editing preview'
|
||||
},
|
||||
drag_file: {
|
||||
id: 'drag_file',
|
||||
defaultMessage: 'Drag file here',
|
||||
description: 'Prompt on the file drag-n-drop overlay banner'
|
||||
}
|
||||
});
|
||||
|
||||
// Checks if the access permissions are granted but not yet accepted.
|
||||
@@ -99,18 +110,20 @@ class MessagesView extends React.Component {
|
||||
this.sendMessage = this.sendMessage.bind(this);
|
||||
this.retrySend = this.retrySend.bind(this);
|
||||
this.sendImageAttachment = this.sendImageAttachment.bind(this);
|
||||
this.sendVideoAttachment = this.sendVideoAttachment.bind(this);
|
||||
this.sendFileAttachment = this.sendFileAttachment.bind(this);
|
||||
this.sendAudioAttachment = this.sendAudioAttachment.bind(this);
|
||||
this.sendKeyPress = this.sendKeyPress.bind(this);
|
||||
this.subscribe = this.subscribe.bind(this);
|
||||
this.handleScrollReference = this.handleScrollReference.bind(this);
|
||||
this.mountDnDEvents = this.mountDnDEvents.bind(this);
|
||||
this.handleScrollEvent = this.handleScrollEvent.bind(this);
|
||||
this.handleDescChange = this.handleDescChange.bind(this);
|
||||
this.handleSubsUpdated = this.handleSubsUpdated.bind(this);
|
||||
this.handleMessageUpdate = this.handleMessageUpdate.bind(this);
|
||||
this.handleAllMessagesReceived = this.handleAllMessagesReceived.bind(this);
|
||||
this.handleInfoReceipt = this.handleInfoReceipt.bind(this);
|
||||
this.handleImagePostview = this.handleImagePostview.bind(this);
|
||||
this.handleExpandMedia = this.handleExpandMedia.bind(this);
|
||||
this.handleClosePreview = this.handleClosePreview.bind(this);
|
||||
this.handleFormResponse = this.handleFormResponse.bind(this);
|
||||
this.handleContextClick = this.handleContextClick.bind(this);
|
||||
@@ -118,20 +131,35 @@ class MessagesView extends React.Component {
|
||||
this.handleNewChatAcceptance = this.handleNewChatAcceptance.bind(this);
|
||||
this.handleEnablePeer = this.handleEnablePeer.bind(this);
|
||||
this.handleAttachFile = this.handleAttachFile.bind(this);
|
||||
this.handleAttachImage = this.handleAttachImage.bind(this);
|
||||
this.handleAttachImageOrVideo = this.handleAttachImageOrVideo.bind(this);
|
||||
this.handleCancelUpload = this.handleCancelUpload.bind(this);
|
||||
this.postReadNotification = this.postReadNotification.bind(this);
|
||||
this.clearNotificationQueue = this.clearNotificationQueue.bind(this);
|
||||
this.goToLatestMessage = this.goToLatestMessage.bind(this);
|
||||
this.handleFileDrop = this.handleFileDrop.bind(this);
|
||||
|
||||
this.handlePickReply = this.handlePickReply.bind(this);
|
||||
this.handleEditMessage = this.handleEditMessage.bind(this);
|
||||
this.handleCancelReply = this.handleCancelReply.bind(this);
|
||||
this.handleQuoteClick = this.handleQuoteClick.bind(this);
|
||||
this.handleCallHangup = this.handleCallHangup.bind(this);
|
||||
|
||||
this.isDragEnabled = this.isDragEnabled.bind(this);
|
||||
this.handleDragIn = this.handleDragIn.bind(this);
|
||||
this.handleDragOut = this.handleDragOut.bind(this);
|
||||
this.handleDrag = this.handleDrag.bind(this);
|
||||
this.handleDrop = this.handleDrop.bind(this);
|
||||
|
||||
this.chatMessageRefs = {};
|
||||
this.getOrCreateMessageRef = this.getOrCreateMessageRef.bind(this);
|
||||
|
||||
// Keeps track of the drag event.
|
||||
// Need a counter b/c the browser's 'drag' events may fire multiple times
|
||||
// when the user takes the mouse pointer over the container:
|
||||
// for the component itself and for all nested/child elements.
|
||||
this.dragCounter = 0;
|
||||
this.dndRef = null;
|
||||
|
||||
this.readNotificationQueue = [];
|
||||
this.readNotificationTimer = null;
|
||||
|
||||
@@ -151,6 +179,14 @@ class MessagesView extends React.Component {
|
||||
if (this.messagesScroller) {
|
||||
this.messagesScroller.addEventListener('scroll', this.handleScrollEvent);
|
||||
}
|
||||
|
||||
// Drag and drop events
|
||||
if (this.dndRef) {
|
||||
this.dndRef.addEventListener('dragenter', this.handleDragIn);
|
||||
this.dndRef.addEventListener('dragleave', this.handleDragOut);
|
||||
this.dndRef.addEventListener('dragover', this.handleDrag);
|
||||
this.dndRef.addEventListener('drop', this.handleDrop);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
@@ -160,6 +196,14 @@ class MessagesView extends React.Component {
|
||||
|
||||
// Flush all notifications.
|
||||
this.clearNotificationQueue();
|
||||
|
||||
// Drag and drop events
|
||||
if (this.dndRef) {
|
||||
this.dndRef.removeEventListener('dragenter', this.handleDragIn);
|
||||
this.dndRef.removeEventListener('dragleave', this.handleDragOut);
|
||||
this.dndRef.removeEventListener('dragover', this.handleDrag);
|
||||
this.dndRef.removeEventListener('drop', this.handleDrop);
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll last message into view on component update e.g. on message received
|
||||
@@ -230,6 +274,8 @@ class MessagesView extends React.Component {
|
||||
docPreview: null,
|
||||
imagePreview: null,
|
||||
imagePostview: null,
|
||||
videoPreview: null,
|
||||
videoPostview: null,
|
||||
rtcPanel: null,
|
||||
typingIndicator: false,
|
||||
scrollPosition: 0,
|
||||
@@ -237,22 +283,30 @@ class MessagesView extends React.Component {
|
||||
peerMessagingDisabled: false,
|
||||
channel: false,
|
||||
reply: null,
|
||||
showGoToLastButton: false
|
||||
contentToEdit: null,
|
||||
showGoToLastButton: false,
|
||||
dragging: false
|
||||
};
|
||||
} else if (nextProps.topic != prevState.topic) {
|
||||
const topic = nextProps.tinode.getTopic(nextProps.topic);
|
||||
|
||||
nextState = {
|
||||
topic: nextProps.topic,
|
||||
deleted: topic._deleted,
|
||||
docPreview: null,
|
||||
imagePreview: null,
|
||||
imagePostview: null,
|
||||
videoPreview: null,
|
||||
videoPostview: null,
|
||||
rtcPanel: null,
|
||||
typingIndicator: false,
|
||||
scrollPosition: 0,
|
||||
fetchingMessages: false,
|
||||
showGoToLastButton: false,
|
||||
deleted: topic._deleted
|
||||
forwardMessage: null,
|
||||
reply: null,
|
||||
contentToEdit: null,
|
||||
dragging: false
|
||||
};
|
||||
|
||||
if (nextProps.forwardMessage) {
|
||||
@@ -393,7 +447,7 @@ class MessagesView extends React.Component {
|
||||
|
||||
const setQuery = newTopic ? this.props.newTopicParams : undefined;
|
||||
topic.subscribe(getQuery.build(), setQuery)
|
||||
.then((ctrl) => {
|
||||
.then(ctrl => {
|
||||
if (ctrl.code == 303) {
|
||||
// Redirect to another topic requested.
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', ctrl.params.topic));
|
||||
@@ -423,7 +477,7 @@ class MessagesView extends React.Component {
|
||||
topic.delMessagesList(calls, true);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
.catch(err => {
|
||||
console.error("Failed subscription to", this.state.topic, err);
|
||||
this.props.onError(err.message, 'err');
|
||||
const blankState = MessagesView.getDerivedStateFromProps({}, {});
|
||||
@@ -440,8 +494,8 @@ class MessagesView extends React.Component {
|
||||
const oldTopic = this.props.tinode.getTopic(oldTopicName);
|
||||
if (oldTopic && oldTopic.isSubscribed()) {
|
||||
oldTopic.leave(false)
|
||||
.catch(() => { /* do nothing here */ })
|
||||
.finally(() => {
|
||||
.catch(_ => { /* do nothing here */ })
|
||||
.finally(_ => {
|
||||
// We don't care if the request succeeded or failed.
|
||||
// The topic is dead regardless.
|
||||
this.setState({fetchingMessages: false});
|
||||
@@ -480,15 +534,26 @@ class MessagesView extends React.Component {
|
||||
if (event.target.scrollTop <= 0) {
|
||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||
if (topic && topic.isSubscribed() && topic.msgHasMoreMessages()) {
|
||||
this.setState({fetchingMessages: true}, () => {
|
||||
this.setState({fetchingMessages: true}, _ => {
|
||||
topic.getMessagesPage(MESSAGES_PAGE)
|
||||
.catch((err) => this.props.onError(err.message, 'err'))
|
||||
.finally(() => this.setState({fetchingMessages: false}));
|
||||
.catch(err => this.props.onError(err.message, 'err'))
|
||||
.finally(_ => this.setState({fetchingMessages: false}));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Mound draf and drop events */
|
||||
mountDnDEvents(dnd) {
|
||||
if (dnd) {
|
||||
dnd.addEventListener('dragenter', this.handleDragIn);
|
||||
dnd.addEventListener('dragleave', this.handleDragOut);
|
||||
dnd.addEventListener('dragover', this.handleDrag);
|
||||
dnd.addEventListener('drop', this.handleDrop);
|
||||
this.dndRef = dnd;
|
||||
}
|
||||
}
|
||||
|
||||
goToLatestMessage() {
|
||||
this.setState({scrollPosition: 0});
|
||||
if (this.messagesScroller) {
|
||||
@@ -649,9 +714,7 @@ class MessagesView extends React.Component {
|
||||
switch (info.what) {
|
||||
case 'kp': {
|
||||
clearTimeout(this.keyPressTimer);
|
||||
this.keyPressTimer = setTimeout(() => {
|
||||
this.setState({typingIndicator: false});
|
||||
}, KEYPRESS_DELAY + 1000);
|
||||
this.keyPressTimer = setTimeout(_ => this.setState({typingIndicator: false}), KEYPRESS_DELAY + 1000);
|
||||
if (!this.state.typingIndicator) {
|
||||
this.setState({typingIndicator: true});
|
||||
}
|
||||
@@ -667,15 +730,26 @@ class MessagesView extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
handleImagePostview(content) {
|
||||
this.setState({ imagePostview: content });
|
||||
handleExpandMedia(content) {
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (content.video) {
|
||||
this.setState({ videoPostview: content });
|
||||
} else {
|
||||
this.setState({ imagePostview: content });
|
||||
}
|
||||
}
|
||||
|
||||
handleClosePreview() {
|
||||
if (this.state.imagePreview && this.state.imagePreview.url) {
|
||||
URL.revokeObjectURL(this.state.imagePreview.url);
|
||||
}
|
||||
this.setState({ imagePostview: null, imagePreview: null, docPreview: null });
|
||||
if (this.state.videoPreview && this.state.videoPreview.url) {
|
||||
URL.revokeObjectURL(this.state.videoPreview.url);
|
||||
}
|
||||
this.setState({ imagePostview: null, imagePreview: null, docPreview: null, videoPreview: null, videoPostview: null});
|
||||
}
|
||||
|
||||
handleFormResponse(action, text, data) {
|
||||
@@ -738,10 +812,14 @@ class MessagesView extends React.Component {
|
||||
this.props.onChangePermissions(this.state.topic, DEFAULT_P2P_ACCESS_MODE, this.state.topic);
|
||||
}
|
||||
|
||||
sendKeyPress() {
|
||||
sendKeyPress(audio) {
|
||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||
if (topic.isSubscribed()) {
|
||||
topic.noteKeyPress();
|
||||
if (audio) {
|
||||
topic.noteRecording(true);
|
||||
} else {
|
||||
topic.noteKeyPress();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -753,14 +831,24 @@ class MessagesView extends React.Component {
|
||||
msg = this.props.forwardMessage.msg;
|
||||
head = this.props.forwardMessage.head;
|
||||
this.handleCancelReply();
|
||||
} else if (this.state.reply && this.state.reply.content) {
|
||||
// We are replying to a message in this topic.
|
||||
head = {reply: '' + this.state.reply.seq};
|
||||
// Turn it into Drafty so we can make a quoted Drafty object later.
|
||||
if (typeof msg == 'string') {
|
||||
msg = Drafty.parse(msg);
|
||||
} else if (this.state.reply) {
|
||||
if (this.state.reply.editing) {
|
||||
if (msg == this.state.contentToEdit) {
|
||||
// Message unchanged.
|
||||
this.handleCancelReply();
|
||||
return;
|
||||
}
|
||||
// Editing an existing message.
|
||||
head = {replace: ':' + this.state.reply.seq};
|
||||
} else if (this.state.reply.content) {
|
||||
// Replying to a message in this topic.
|
||||
// Turn it into Drafty so we can make a quoted Drafty object later.
|
||||
head = {reply: '' + this.state.reply.seq};
|
||||
if (typeof msg == 'string') {
|
||||
msg = Drafty.parse(msg);
|
||||
}
|
||||
msg = Drafty.append(Drafty.sanitizeEntities(this.state.reply.content), msg);
|
||||
}
|
||||
msg = Drafty.append(Drafty.sanitizeEntities(this.state.reply.content), msg);
|
||||
this.handleCancelReply();
|
||||
}
|
||||
this.props.sendMessage(msg, uploadCompletionPromise, uploader, head);
|
||||
@@ -769,7 +857,7 @@ class MessagesView extends React.Component {
|
||||
// Retry sending a message.
|
||||
retrySend(pub) {
|
||||
this.props.sendMessage(pub.content, undefined, undefined, pub.head)
|
||||
.then(() => {
|
||||
.then(_ => {
|
||||
// All good. Remove the original message draft from the cache.
|
||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||
topic.delMessagesList([pub.seq], true);
|
||||
@@ -804,7 +892,7 @@ class MessagesView extends React.Component {
|
||||
// Small enough to send inband.
|
||||
fileToBase64(file)
|
||||
.then(b64 => this.sendMessage(Drafty.attachFile(null, {mime: b64.mime, data: b64.bits, filename: b64.name})))
|
||||
.catch(err => this.props.onError(err));
|
||||
.catch(err => this.props.onError(err.message, 'err'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -836,12 +924,12 @@ class MessagesView extends React.Component {
|
||||
this.props.onCallHangup(topic, seq);
|
||||
}
|
||||
|
||||
// sendImageAttachment sends the image bits inband as Drafty message.
|
||||
// sendImageAttachment sends the image bits as Drafty message.
|
||||
sendImageAttachment(caption, blob) {
|
||||
const mime = this.state.imagePreview.mime;
|
||||
const width = this.state.imagePreview.width;
|
||||
const height = this.state.imagePreview.height;
|
||||
const fname = this.state.imagePreview.name;
|
||||
const fname = this.state.imagePreview.filename;
|
||||
|
||||
// Server-provided limit reduced for base64 encoding and overhead.
|
||||
const maxInbandAttachmentSize = (this.props.tinode.getServerParam('maxMessageSize',
|
||||
@@ -861,33 +949,34 @@ class MessagesView extends React.Component {
|
||||
// Convert tiny image into base64 for serialization and previewing.
|
||||
.then(scaled => blobToBase64(scaled.blob))
|
||||
.then(b64 => {
|
||||
let msg = Drafty.insertImage(null, 0, {
|
||||
mime: mime,
|
||||
_tempPreview: b64.bits, // This preview will not be serialized.
|
||||
width: width,
|
||||
height: height,
|
||||
filename: fname,
|
||||
size: blob.size,
|
||||
urlPromise: uploadCompletionPromise
|
||||
});
|
||||
if (caption) {
|
||||
msg = Drafty.appendLineBreak(msg);
|
||||
msg = Drafty.append(msg, Drafty.parse(caption));
|
||||
}
|
||||
// Pass data and the uploader to the TinodeWeb.
|
||||
this.sendMessage(msg, uploadCompletionPromise, uploader);
|
||||
}).catch((err) => {
|
||||
this.props.onError(err, 'err');
|
||||
});
|
||||
return;
|
||||
let msg = Drafty.insertImage(null, 0, {
|
||||
mime: mime,
|
||||
_tempPreview: b64.bits, // This preview will not be serialized.
|
||||
bits: b64.bits, // Image thumbnail.
|
||||
width: width,
|
||||
height: height,
|
||||
filename: fname,
|
||||
size: blob.size,
|
||||
urlPromise: uploadCompletionPromise
|
||||
});
|
||||
if (caption) {
|
||||
msg = Drafty.appendLineBreak(msg);
|
||||
msg = Drafty.append(msg, Drafty.parse(caption));
|
||||
}
|
||||
// Pass data and the uploader to the TinodeWeb.
|
||||
this.sendMessage(msg, uploadCompletionPromise, uploader);
|
||||
})
|
||||
.catch(err => this.props.onError(err, 'err'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload the image if it's too big to be send inband.
|
||||
// Send the image inband if it's not too big. The image has been scaled already
|
||||
// in image preview.
|
||||
blobToBase64(blob)
|
||||
.then(b64 => {
|
||||
let msg = Drafty.insertImage(null, 0, {
|
||||
mime: b64.mime,
|
||||
preview: b64.bits, // Serializable preview
|
||||
bits: b64.bits,
|
||||
width: width,
|
||||
height: height,
|
||||
filename: fname,
|
||||
@@ -901,27 +990,137 @@ class MessagesView extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
// handleAttachImage method is called when [Attach image] button is clicked: launch image preview.
|
||||
handleAttachImage(file) {
|
||||
// sendVideoAttachment sends the video bits as Drafty message.
|
||||
sendVideoAttachment(caption, videoBlob, previewBlob, params) {
|
||||
const width = params.width;
|
||||
const height = params.height;
|
||||
|
||||
// Server-provided limit reduced for base64 encoding and overhead.
|
||||
const maxInbandAttachmentSize = (this.props.tinode.getServerParam('maxMessageSize',
|
||||
MAX_INBAND_ATTACHMENT_SIZE) * 0.75 - 1024) | 0;
|
||||
|
||||
const uploads = [];
|
||||
let uploader;
|
||||
if ((videoBlob.size + previewBlob.size) > maxInbandAttachmentSize) {
|
||||
// One or both are too large to send inband. Uploading out of band and sending as a link.
|
||||
uploader = this.props.tinode.getLargeFileHelper();
|
||||
if (!uploader) {
|
||||
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
|
||||
return;
|
||||
}
|
||||
|
||||
uploads[0] = videoBlob.size > maxInbandAttachmentSize * 0.675 ? uploader.upload(videoBlob) : null;
|
||||
uploads[1] = previewBlob.size > maxInbandAttachmentSize * 0.275 ? uploader.upload(previewBlob) : null;
|
||||
}
|
||||
|
||||
if (uploads.length == 0) {
|
||||
// Both video and preview are small enough to send inband.
|
||||
Promise.all([blobToBase64(videoBlob), blobToBase64(previewBlob)])
|
||||
.then(b64s => {
|
||||
const [v64, i64] = b64s;
|
||||
let msg = Drafty.insertVideo(null, 0, {
|
||||
mime: v64.mime,
|
||||
bits: v64.bits,
|
||||
preview: i64.bits,
|
||||
premime: i64.mime,
|
||||
width: width,
|
||||
height: height,
|
||||
duration: params.duration,
|
||||
filename: params.name,
|
||||
size: videoBlob.size
|
||||
});
|
||||
if (caption) {
|
||||
msg = Drafty.appendLineBreak(msg);
|
||||
msg = Drafty.append(msg, Drafty.parse(caption));
|
||||
}
|
||||
this.sendMessage(msg);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadCompletionPromise = Promise.all(uploads);
|
||||
|
||||
const b64conv = [];
|
||||
// Small video converted to base64.
|
||||
b64conv[0] = uploads[0] ? null : blobToBase64(videoBlob);
|
||||
// Full-size preview fits inline.
|
||||
b64conv[1] = uploads[1] ? null : imageScaled(previewBlob, MAX_IMAGE_DIM, MAX_IMAGE_DIM, -1, false)
|
||||
.then(scaled => blobToBase64(scaled.blob));
|
||||
// Small preview to show while uploading.
|
||||
b64conv[2] = imageScaled(previewBlob, VIDEO_PREVIEW_DIM, VIDEO_PREVIEW_DIM, -1, false)
|
||||
.then(scaled => blobToBase64(scaled.blob));
|
||||
Promise.all(b64conv)
|
||||
// Convert tiny image into base64 for serialization and previewing.
|
||||
.then(b64s => {
|
||||
const [video, img, preview] = b64s;
|
||||
let msg = Drafty.insertVideo(null, 0, {
|
||||
mime: params.mime,
|
||||
bits: video ? video.bits : null,
|
||||
_tempPreview: preview.bits,
|
||||
preview: img ? img.bits : preview.bits,
|
||||
premime: img ? img.mime : preview.mime,
|
||||
width: width,
|
||||
height: height,
|
||||
duration: params.duration,
|
||||
filename: params.name,
|
||||
size: videoBlob.size,
|
||||
urlPromise: uploadCompletionPromise
|
||||
});
|
||||
if (caption) {
|
||||
msg = Drafty.appendLineBreak(msg);
|
||||
msg = Drafty.append(msg, Drafty.parse(caption));
|
||||
}
|
||||
// Pass data and the uploader to the TinodeWeb.
|
||||
this.sendMessage(msg, uploadCompletionPromise, uploader);
|
||||
})
|
||||
.catch(err => this.props.onError(err.message, 'err'));
|
||||
}
|
||||
|
||||
// handleAttachImageOrVideo method is called when [Attach image or video] button is clicked: launch image or video preview.
|
||||
handleAttachImageOrVideo(file) {
|
||||
const maxExternAttachmentSize = this.props.tinode.getServerParam('maxFileUploadSize', MAX_EXTERN_ATTACHMENT_SIZE);
|
||||
|
||||
if (file.type.startsWith('video/')) {
|
||||
this.setState({videoPreview: {
|
||||
url: URL.createObjectURL(file),
|
||||
blob: file,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
mime: file.type
|
||||
}});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get image dimensions and size, optionally scale it down.
|
||||
imageScaled(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, maxExternAttachmentSize, false)
|
||||
.then(scaled => {
|
||||
this.setState({imagePreview: {
|
||||
url: URL.createObjectURL(scaled.blob),
|
||||
blob: scaled.blob,
|
||||
name: scaled.name,
|
||||
filename: scaled.name,
|
||||
width: scaled.width,
|
||||
height: scaled.height,
|
||||
size: scaled.blob.size,
|
||||
mime: scaled.mime
|
||||
}});
|
||||
}).catch(err => {
|
||||
this.props.onError(err, 'err');
|
||||
this.props.onError(err.message, 'err');
|
||||
});
|
||||
}
|
||||
|
||||
// handleFileDrop is called when the user drags & drops a file upon the message view.
|
||||
handleFileDrop(files) {
|
||||
if (!files || files.length == 0) {
|
||||
return;
|
||||
}
|
||||
const file = files[0];
|
||||
if (file.type && file.type.startsWith('image/')) {
|
||||
this.handleAttachImageOrVideo(file);
|
||||
} else {
|
||||
this.handleAttachFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
// sendAudioAttachment sends audio bits inband as Drafty message (no preview).
|
||||
sendAudioAttachment(url, preview, duration) {
|
||||
fetch(url)
|
||||
@@ -952,15 +1151,15 @@ class MessagesView extends React.Component {
|
||||
.then(b64 => {
|
||||
this.sendMessage(Drafty.appendAudio(null, {
|
||||
mime: b64.mime,
|
||||
bits: b64.bits,
|
||||
size: blob.size,
|
||||
data: b64.bits,
|
||||
duration: duration,
|
||||
preview: preview,
|
||||
}))
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(err => {this.props.onError(err)});;
|
||||
.catch(err => {this.props.onError(err.message, 'err')});;
|
||||
}
|
||||
|
||||
handleCancelUpload(seq, uploader) {
|
||||
@@ -977,9 +1176,8 @@ class MessagesView extends React.Component {
|
||||
// senderId: UID of the sender of the source message.
|
||||
// senderName: full name of the sender of the original message.
|
||||
handlePickReply(seq, content, senderId, senderName) {
|
||||
this.setState({reply: null});
|
||||
|
||||
if (!seq || !content) {
|
||||
this.setState({reply: null});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1001,8 +1199,38 @@ class MessagesView extends React.Component {
|
||||
this.props.onCancelForwardMessage();
|
||||
}
|
||||
|
||||
// seq: seq ID of the message to edit.
|
||||
// context: message content.
|
||||
handleEditMessage(seq, content) {
|
||||
if (!seq || !content) {
|
||||
this.setState({reply: null});
|
||||
return;
|
||||
}
|
||||
|
||||
content = typeof content == 'string' ? Drafty.init(content) : content;
|
||||
const editable = Drafty.toMarkdown(content);
|
||||
if (Drafty.isValid(content)) {
|
||||
content = Drafty.replyContent(content, EDIT_PREVIEW_LENGTH);
|
||||
} else {
|
||||
// /!\ invalid content.
|
||||
content = Drafty.append(Drafty.init('\u26A0 '),
|
||||
Drafty.wrapInto(this.props.intl.formatMessage(messages.invalid_content), 'EM'));
|
||||
}
|
||||
|
||||
this.setState({
|
||||
reply: {
|
||||
content: Drafty.quote(this.props.intl.formatMessage(messages.editing_message), null, content),
|
||||
seq: seq,
|
||||
editing: true
|
||||
},
|
||||
contentToEdit: editable
|
||||
});
|
||||
this.props.onCancelForwardMessage();
|
||||
}
|
||||
|
||||
|
||||
handleCancelReply() {
|
||||
this.setState({reply: null});
|
||||
this.setState({reply: null, contentToEdit: null});
|
||||
this.props.onCancelForwardMessage();
|
||||
}
|
||||
|
||||
@@ -1011,12 +1239,50 @@ class MessagesView extends React.Component {
|
||||
if (ref && ref.current) {
|
||||
ref.current.scrollIntoView({block: "center", behavior: "smooth"});
|
||||
ref.current.classList.add('flash');
|
||||
setTimeout(() => { ref.current.classList.remove('flash') } , 1000);
|
||||
setTimeout(_ => {ref.current.classList.remove('flash')} , 1000);
|
||||
} else {
|
||||
console.error("Unresolved message ref", replyToSeq);
|
||||
}
|
||||
}
|
||||
|
||||
isDragEnabled() {
|
||||
return this.state.isWriter && !this.state.unconfirmed && !this.props.forwardMessage && !this.state.peerMessagingDisabled;
|
||||
}
|
||||
|
||||
handleDrag(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
handleDragIn(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.dragCounter++;
|
||||
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
||||
this.setState({dragging: true});
|
||||
}
|
||||
}
|
||||
|
||||
handleDragOut(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.dragCounter--;
|
||||
if (this.dragCounter <= 0) {
|
||||
this.setState({dragging: false});
|
||||
}
|
||||
}
|
||||
|
||||
handleDrop(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.setState({dragging: false});
|
||||
if (this.isDragEnabled() && e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
this.handleFileDrop(e.dataTransfer.files);
|
||||
e.dataTransfer.clearData();
|
||||
this.dragCounter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {formatMessage} = this.props.intl;
|
||||
|
||||
@@ -1042,6 +1308,18 @@ class MessagesView extends React.Component {
|
||||
onClose={this.handleClosePreview}
|
||||
onSendMessage={this.sendImageAttachment} />
|
||||
);
|
||||
} else if (this.state.videoPreview) {
|
||||
// Preview video.
|
||||
component2 = (
|
||||
<VideoPreview
|
||||
content={this.state.videoPreview}
|
||||
tinode={this.props.tinode}
|
||||
reply={this.state.reply}
|
||||
onError={this.props.onError}
|
||||
onCancelReply={this.handleCancelReply}
|
||||
onClose={this.handleClosePreview}
|
||||
onSendMessage={this.sendVideoAttachment} />
|
||||
);
|
||||
} else if (this.state.imagePostview) {
|
||||
// Expand received image.
|
||||
component2 = (
|
||||
@@ -1049,6 +1327,15 @@ class MessagesView extends React.Component {
|
||||
content={this.state.imagePostview}
|
||||
onClose={this.handleClosePreview} />
|
||||
);
|
||||
} else if (this.state.videoPostview) {
|
||||
// Play received video.
|
||||
component2 = (
|
||||
<VideoPreview
|
||||
content={this.state.videoPostview}
|
||||
tinode={this.props.tinode}
|
||||
onError={this.props.onError}
|
||||
onClose={this.handleClosePreview} />
|
||||
);
|
||||
} else if (this.state.docPreview) {
|
||||
// Preview attachment before sending.
|
||||
component2 = (
|
||||
@@ -1098,7 +1385,6 @@ class MessagesView extends React.Component {
|
||||
let previousFrom = null;
|
||||
let prevDate = null;
|
||||
let chatBoxClass = null;
|
||||
const dateFmt = new Intl.DateTimeFormat(this.props.intl.locale);
|
||||
topic.messages((msg, prev, next, i) => {
|
||||
let nextFrom = next ? (next.from || 'chan') : null;
|
||||
|
||||
@@ -1146,7 +1432,7 @@ class MessagesView extends React.Component {
|
||||
if (!prevDate || prevDate.toDateString() != thisDate.toDateString()) {
|
||||
messageNodes.push(
|
||||
<MetaMessage
|
||||
date={dateFmt.format(msg.ts)}
|
||||
date={relativeDateFormat(msg.ts)}
|
||||
locale={this.props.intl.locale}
|
||||
key={'date-' + msg.seq} />
|
||||
);
|
||||
@@ -1157,6 +1443,8 @@ class MessagesView extends React.Component {
|
||||
tinode={this.props.tinode}
|
||||
content={msg.content}
|
||||
mimeType={msg.head && msg.head.mime}
|
||||
replyToSeq={replyToSeq}
|
||||
edited={msg.head && !msg.head.webrtc && msg.head.replace}
|
||||
timestamp={msg.ts}
|
||||
response={isReply}
|
||||
seq={msg.seq}
|
||||
@@ -1168,17 +1456,17 @@ class MessagesView extends React.Component {
|
||||
sequence={sequence}
|
||||
received={deliveryStatus}
|
||||
uploader={msg._uploader}
|
||||
userIsWriter={this.state.isWriter}
|
||||
viewportWidth={this.props.viewportWidth} // Used by `formatter`.
|
||||
showContextMenu={this.handleShowMessageContextMenu}
|
||||
onImagePreview={this.handleImagePostview}
|
||||
onExpandMedia={this.handleExpandMedia}
|
||||
onFormResponse={this.handleFormResponse}
|
||||
onError={this.props.onError}
|
||||
onCancelUpload={this.handleCancelUpload}
|
||||
pickReply={this.handlePickReply}
|
||||
replyToSeq={replyToSeq}
|
||||
editMessage={this.handleEditMessage}
|
||||
onQuoteClick={this.handleQuoteClick}
|
||||
onError={this.props.onError}
|
||||
ref={ref}
|
||||
userIsWriter={this.state.isWriter}
|
||||
key={msg.seq} />
|
||||
);
|
||||
}
|
||||
@@ -1205,6 +1493,59 @@ class MessagesView extends React.Component {
|
||||
|
||||
const titleClass = 'panel-title' + (this.state.deleted ? ' deleted' : '');
|
||||
|
||||
let messagesComponent = (
|
||||
<>
|
||||
<div id="messages-container">
|
||||
<button className={'action-button' + (this.state.showGoToLastButton ? '' : ' hidden')}
|
||||
onClick={this.goToLatestMessage}>
|
||||
<i className="material-icons">arrow_downward</i>
|
||||
</button>
|
||||
<div id="messages-panel" ref={this.handleScrollReference}>
|
||||
<ul id="scroller" className={chatBoxClass}>
|
||||
{messageNodes}
|
||||
</ul>
|
||||
</div>
|
||||
{!this.state.isReader ?
|
||||
<div id="write-only-background">
|
||||
{this.state.readingBlocked ?
|
||||
<div id="write-only-note">
|
||||
<FormattedMessage id="messages_not_readable" defaultMessage="no access to messages"
|
||||
description="Message shown in topic without the read access" />
|
||||
</div>
|
||||
: null }
|
||||
</div>
|
||||
: null }
|
||||
</div>
|
||||
{this.state.peerMessagingDisabled && !this.state.unconfirmed ?
|
||||
<div id="peer-messaging-disabled-note">
|
||||
<i className="material-icons secondary">block</i> <FormattedMessage
|
||||
id="peers_messaging_disabled" defaultMessage="Peer's messaging is disabled."
|
||||
description="Shown when the p2p peer's messaging is disabled" /> <a href="#"
|
||||
onClick={this.handleEnablePeer}><FormattedMessage id="enable_peers_messaging"
|
||||
defaultMessage="Enable" description="Call to action to enable peer's messaging" /></a>.
|
||||
</div> : null}
|
||||
{this.state.unconfirmed ?
|
||||
<Invitation onAction={this.handleNewChatAcceptance} />
|
||||
:
|
||||
<SendMessage
|
||||
tinode={this.props.tinode}
|
||||
topicName={this.state.topic}
|
||||
noInput={!!this.props.forwardMessage}
|
||||
disabled={!this.state.isWriter || this.state.deleted}
|
||||
reply={this.state.reply}
|
||||
initMessage={this.state.contentToEdit}
|
||||
onKeyPress={this.sendKeyPress}
|
||||
onRecordingProgress={this.sendKeyPress}
|
||||
onSendMessage={this.sendMessage}
|
||||
onAttachFile={this.props.forwardMessage ? null : this.handleAttachFile}
|
||||
onAttachImage={this.props.forwardMessage ? null : this.handleAttachImageOrVideo}
|
||||
onAttachAudio={this.props.forwardMessage ? null : this.sendAudioAttachment}
|
||||
onError={this.props.onError}
|
||||
onQuoteClick={this.handleQuoteClick}
|
||||
onCancelReply={this.handleCancelReply} />}
|
||||
</>
|
||||
);
|
||||
|
||||
component2 = (
|
||||
<>
|
||||
<div id="topic-caption-panel" className="caption-panel">
|
||||
@@ -1250,56 +1591,14 @@ class MessagesView extends React.Component {
|
||||
onClearError={this.props.onError} />
|
||||
: null}
|
||||
<LoadSpinner show={this.state.fetchingMessages} />
|
||||
<div id="messages-container">
|
||||
<button className={'action-button' + (this.state.showGoToLastButton ? '' : ' hidden')}
|
||||
onClick={this.goToLatestMessage}>
|
||||
<i className="material-icons">arrow_downward</i>
|
||||
</button>
|
||||
<div id="messages-panel" ref={this.handleScrollReference}>
|
||||
<ul id="scroller" className={chatBoxClass}>
|
||||
{messageNodes}
|
||||
</ul>
|
||||
</div>
|
||||
{!this.state.isReader ?
|
||||
<div id="write-only-background">
|
||||
{this.state.readingBlocked ?
|
||||
<div id="write-only-note">
|
||||
<FormattedMessage id="messages_not_readable" defaultMessage="no access to messages"
|
||||
description="Message shown in topic without the read access" />
|
||||
</div>
|
||||
: null }
|
||||
</div>
|
||||
: null }
|
||||
</div>
|
||||
{this.state.peerMessagingDisabled && !this.state.unconfirmed ?
|
||||
<div id="peer-messaging-disabled-note">
|
||||
<i className="material-icons secondary">block</i> <FormattedMessage
|
||||
id="peers_messaging_disabled" defaultMessage="Peer's messaging is disabled."
|
||||
description="Shown when the p2p peer's messaging is disabled" /> <a href="#"
|
||||
onClick={this.handleEnablePeer}><FormattedMessage id="enable_peers_messaging"
|
||||
defaultMessage="Enable" description="Call to action to enable peer's messaging" /></a>.
|
||||
</div> : null}
|
||||
{this.state.unconfirmed ?
|
||||
<Invitation onAction={this.handleNewChatAcceptance} />
|
||||
:
|
||||
<SendMessage
|
||||
tinode={this.props.tinode}
|
||||
topicName={this.state.topic}
|
||||
noInput={!!this.props.forwardMessage}
|
||||
disabled={!this.state.isWriter || this.state.deleted}
|
||||
onKeyPress={this.sendKeyPress}
|
||||
onSendMessage={this.sendMessage}
|
||||
onAttachFile={this.props.forwardMessage ? null : this.handleAttachFile}
|
||||
onAttachImage={this.props.forwardMessage ? null : this.handleAttachImage}
|
||||
onAttachAudio={this.props.forwardMessage ? null : this.sendAudioAttachment}
|
||||
onError={this.props.onError}
|
||||
reply={this.state.reply}
|
||||
onQuoteClick={this.handleQuoteClick}
|
||||
onCancelReply={this.handleCancelReply} />}
|
||||
{messagesComponent}
|
||||
{this.state.dragging && this.isDragEnabled() ?
|
||||
<div className="drag-n-drop">{formatMessage(messages.drag_file)}</div>
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
component = <div id="topic-view">{component2}</div>
|
||||
component = <div id="topic-view" ref={this.mountDnDEvents}>{component2}</div>
|
||||
}
|
||||
return component;
|
||||
}
|
||||
|
||||
@@ -35,9 +35,7 @@ export default class PasswordResetView extends React.PureComponent {
|
||||
} else {
|
||||
const email = this.state.email.trim();
|
||||
this.setState({email: email});
|
||||
this.props.onRequest('email', email).then(() => {
|
||||
this.setState({sent: true});
|
||||
});
|
||||
this.props.onRequest('email', email).then(_ => this.setState({sent: true}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+77
-108
@@ -169,7 +169,7 @@ class TinodeWeb extends React.Component {
|
||||
this.handleCallIceCandidate = this.handleCallIceCandidate.bind(this);
|
||||
this.handleCallSendAnswer = this.handleCallSendAnswer.bind(this);
|
||||
|
||||
this.handleCallAcceptCall = this.handleCallAcceptCall.bind(this);
|
||||
this.handleCallAccept = this.handleCallAccept.bind(this);
|
||||
|
||||
this.sendMessageToTopic = this.sendMessageToTopic.bind(this);
|
||||
|
||||
@@ -303,7 +303,7 @@ class TinodeWeb extends React.Component {
|
||||
if (this.state.desktopAlerts) {
|
||||
this.tinode.setDeviceToken(this.state.firebaseToken);
|
||||
}
|
||||
}).catch(() => {
|
||||
}).catch(_ => {
|
||||
// do nothing: handled earlier.
|
||||
// catch needed to pervent unnecessary logging of error.
|
||||
});
|
||||
@@ -322,7 +322,7 @@ class TinodeWeb extends React.Component {
|
||||
// When reading from storage, date is returned as string.
|
||||
token.expires = new Date(token.expires);
|
||||
this.tinode.setAuthToken(token);
|
||||
this.tinode.connect().catch((err) => {
|
||||
this.tinode.connect().catch(err => {
|
||||
// Socket error
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
@@ -400,7 +400,7 @@ class TinodeWeb extends React.Component {
|
||||
// (a) for channels always,
|
||||
// (b) pushes when the app is in foreground but has no focus.
|
||||
firebaseOnMessage(this.fcm, (payload) => { this.handlePushMessage(payload); });
|
||||
}).catch((err) => {
|
||||
}).catch(err => {
|
||||
// SW registration or FCM has failed :(
|
||||
onError(err);
|
||||
throw err;
|
||||
@@ -454,9 +454,9 @@ class TinodeWeb extends React.Component {
|
||||
|
||||
// Check if a newer version of TinodeWeb app is available at the server.
|
||||
checkForAppUpdate(reg) {
|
||||
reg.onupdatefound = () => {
|
||||
reg.onupdatefound = _ => {
|
||||
const installingWorker = reg.installing;
|
||||
installingWorker.onstatechange = () => {
|
||||
installingWorker.onstatechange = _ => {
|
||||
if (installingWorker.state == 'installed' && navigator.serviceWorker.controller) {
|
||||
const msg = <>
|
||||
<FormattedMessage id="update_available"
|
||||
@@ -570,7 +570,7 @@ class TinodeWeb extends React.Component {
|
||||
if (this.tinode.isConnected()) {
|
||||
this.doLogin(login, password, null, {meth: this.state.credMethod, resp: this.state.credCode});
|
||||
} else {
|
||||
this.tinode.connect().catch((err) => {
|
||||
this.tinode.connect().catch(err => {
|
||||
// Socket error
|
||||
this.setState({loginDisabled: false, autoLogin: false, loadSpinnerVisible: false});
|
||||
this.handleError(err.message, 'err');
|
||||
@@ -581,12 +581,12 @@ class TinodeWeb extends React.Component {
|
||||
// Enable or disable saving the password and IndexedDB.
|
||||
handlePersistenceChange(persist) {
|
||||
if (persist) {
|
||||
this.tinode.initStorage().then(() => {
|
||||
this.tinode.initStorage().then(_ => {
|
||||
LocalStorageUtil.setObject('keep-logged-in', true);
|
||||
this.setState({persist: true});
|
||||
});
|
||||
} else {
|
||||
this.tinode.clearStorage().then(() => {
|
||||
this.tinode.clearStorage().then(_ => {
|
||||
LocalStorageUtil.setObject('keep-logged-in', false);
|
||||
this.setState({persist: false});
|
||||
});
|
||||
@@ -625,7 +625,7 @@ class TinodeWeb extends React.Component {
|
||||
prom.then(_ => {
|
||||
// Reconnected: clear error
|
||||
this.handleError();
|
||||
}).catch((err) => {
|
||||
}).catch(err => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
return;
|
||||
@@ -701,7 +701,7 @@ class TinodeWeb extends React.Component {
|
||||
loginPromise = connectionPromise.then(_ => this.tinode.loginToken(token, cred));
|
||||
}
|
||||
|
||||
loginPromise.then((ctrl) => {
|
||||
loginPromise.then(ctrl => {
|
||||
if (ctrl.code >= 300 && ctrl.text === 'validate credentials') {
|
||||
this.setState({loadSpinnerVisible: false});
|
||||
if (cred) {
|
||||
@@ -711,7 +711,7 @@ class TinodeWeb extends React.Component {
|
||||
} else {
|
||||
this.handleLoginSuccessful();
|
||||
}
|
||||
}).catch((err) => {
|
||||
}).catch(err => {
|
||||
// Connection or login failed, report error.
|
||||
this.setState({
|
||||
loginDisabled: false,
|
||||
@@ -768,12 +768,12 @@ class TinodeWeb extends React.Component {
|
||||
withTags().
|
||||
withCred().
|
||||
build()
|
||||
).catch((err) => {
|
||||
).catch(err => {
|
||||
this.tinode.disconnect();
|
||||
localStorage.removeItem('auth-token');
|
||||
this.handleError(err.message, 'err');
|
||||
HashNavigation.navigateTo('');
|
||||
}).finally(() => {
|
||||
}).finally(_ => {
|
||||
this.setState({loadSpinnerVisible: false});
|
||||
});
|
||||
let urlHash = HashNavigation.setUrlSidePanel(window.location.hash, 'contacts');
|
||||
@@ -934,7 +934,7 @@ class TinodeWeb extends React.Component {
|
||||
if (fnd.isSubscribed()) {
|
||||
this.tnFndSubsUpdated();
|
||||
} else {
|
||||
fnd.subscribe(fnd.startMetaQuery().withSub().build()).catch((err) => {
|
||||
fnd.subscribe(fnd.startMetaQuery().withSub().build()).catch(err => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
}
|
||||
@@ -957,11 +957,9 @@ class TinodeWeb extends React.Component {
|
||||
*/
|
||||
handleSearchContacts(query) {
|
||||
const fnd = this.tinode.getFndTopic();
|
||||
fnd.setMeta({desc: {public: query}}).then((ctrl) => {
|
||||
return fnd.getMeta(fnd.startMetaQuery().withSub().build());
|
||||
}).catch((err) => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
fnd.setMeta({desc: {public: query}})
|
||||
.then(_ => fnd.getMeta(fnd.startMetaQuery().withSub().build()))
|
||||
.catch(err => this.handleError(err.message, 'err'));
|
||||
}
|
||||
|
||||
// User clicked on a topic in the side panel or deleted a topic.
|
||||
@@ -1043,7 +1041,7 @@ class TinodeWeb extends React.Component {
|
||||
// Topic is not subscribed yet. Subscribe.
|
||||
const subscribePromise =
|
||||
topic.subscribe()
|
||||
.then(() => {
|
||||
.then(_ => {
|
||||
// If there are unsent messages (except video call messages),
|
||||
// try sending them now. Unsent video call messages will be dropped.
|
||||
let calls = [];
|
||||
@@ -1069,15 +1067,13 @@ class TinodeWeb extends React.Component {
|
||||
|
||||
// TODO: check if return is required.
|
||||
return topic.publishDraft(msg, Promise.all(completion))
|
||||
.then((ctrl) => {
|
||||
.then(ctrl => {
|
||||
if (topic.isArchived()) {
|
||||
topic.archive(false);
|
||||
}
|
||||
return ctrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
.catch(err => this.handleError(err.message, 'err'));
|
||||
}
|
||||
|
||||
handleNewChatInvitation(topicName, action) {
|
||||
@@ -1091,9 +1087,7 @@ class TinodeWeb extends React.Component {
|
||||
if (topic.isP2PType()) {
|
||||
// For P2P topics change 'given' permission of the peer too.
|
||||
// In p2p topics the other user has the same name as the topic.
|
||||
response = response.then((ctrl) => {
|
||||
topic.setMeta({sub: {user: topicName, mode: mode}});
|
||||
});
|
||||
response = response.then(_ => topic.setMeta({sub: {user: topicName, mode: mode}}));
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
@@ -1104,18 +1098,14 @@ class TinodeWeb extends React.Component {
|
||||
// Ban the topic making futher invites impossible.
|
||||
// Just self-ban.
|
||||
const am = topic.getAccessMode().updateWant('-JP').getWant();
|
||||
response = topic.setMeta({sub: {mode: am}}).then((ctrl) => {
|
||||
return this.handleTopicSelected(null);
|
||||
});
|
||||
response = topic.setMeta({sub: {mode: am}}).then(_ => this.handleTopicSelected(null));
|
||||
break;
|
||||
default:
|
||||
console.warn("Unknown invitation action", '"' + action + '""');
|
||||
}
|
||||
|
||||
if (response != null) {
|
||||
response.catch((err) => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
response.catch(err => this.handleError(err.message, 'err'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1156,7 +1146,7 @@ class TinodeWeb extends React.Component {
|
||||
|
||||
const me = this.tinode.getMeTopic();
|
||||
const am = me.getAccessMode().updateWant(on ? '-P' : '+P').getWant();
|
||||
me.setMeta({sub: {mode: am}}).catch((err) => {
|
||||
me.setMeta({sub: {mode: am}}).catch(err => {
|
||||
// Request failed, keep existing state.
|
||||
this.setState({incognitoMode: !on});
|
||||
this.handleError(err.message, 'err');
|
||||
@@ -1165,9 +1155,7 @@ class TinodeWeb extends React.Component {
|
||||
|
||||
handleUpdateAccountTagsRequest(tags) {
|
||||
this.tinode.getMeTopic().setMeta({tags: tags})
|
||||
.catch((err) => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
.catch(err => this.handleError(err.message, 'err'));
|
||||
}
|
||||
|
||||
// User chose Settings menu item.
|
||||
@@ -1235,9 +1223,9 @@ class TinodeWeb extends React.Component {
|
||||
}
|
||||
}
|
||||
} else if (this.state.firebaseToken && this.fcm) {
|
||||
firebaseDelToken(this.fcm).catch((err) => {
|
||||
firebaseDelToken(this.fcm).catch(err => {
|
||||
console.error("Unable to delete token.", err);
|
||||
}).finally(() => {
|
||||
}).finally(_ => {
|
||||
LocalStorageUtil.updateObject('settings', {desktopAlerts: false});
|
||||
localStorage.removeItem('firebase-token');
|
||||
this.setState({desktopAlerts: false, firebaseToken: null});
|
||||
@@ -1259,16 +1247,14 @@ class TinodeWeb extends React.Component {
|
||||
|
||||
handleCredAdd(method, value) {
|
||||
const me = this.tinode.getMeTopic();
|
||||
me.setMeta({cred: {meth: method, val: value}}).catch((err) => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
me.setMeta({cred: {meth: method, val: value}})
|
||||
.catch(err => this.handleError(err.message, 'err'));
|
||||
}
|
||||
|
||||
handleCredDelete(method, value) {
|
||||
const me = this.tinode.getMeTopic();
|
||||
me.delCredential(method, value).catch((err) => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
me.delCredential(method, value)
|
||||
.catch(err => this.handleError(err.message, 'err'));
|
||||
}
|
||||
|
||||
handleCredConfirm(method, response) {
|
||||
@@ -1332,7 +1318,7 @@ class TinodeWeb extends React.Component {
|
||||
}
|
||||
}
|
||||
params._topicName = topicName;
|
||||
this.setState({newTopicParams: params}, () => {this.handleTopicSelected(topicName)});
|
||||
this.setState({newTopicParams: params}, _ => {this.handleTopicSelected(topicName)});
|
||||
}
|
||||
|
||||
// New topic was created, here is the new topic name.
|
||||
@@ -1377,18 +1363,15 @@ class TinodeWeb extends React.Component {
|
||||
if (defacs) {
|
||||
params.defacs = defacs;
|
||||
}
|
||||
topic.setMeta({desc: params, attachments: attachments}).catch((err) => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
topic.setMeta({desc: params, attachments: attachments})
|
||||
.catch(err => this.handleError(err.message, 'err'));
|
||||
}
|
||||
}
|
||||
|
||||
handleUnarchive(topicName) {
|
||||
const topic = this.tinode.getTopic(topicName);
|
||||
if (topic) {
|
||||
topic.archive(false).catch((err) => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
topic.archive(false).catch(err => this.handleError(err.message, 'err'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1396,9 +1379,8 @@ class TinodeWeb extends React.Component {
|
||||
this.handleError();
|
||||
|
||||
if (password) {
|
||||
this.tinode.updateAccountBasic(null, this.tinode.getCurrentLogin(), password).catch((err) => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
this.tinode.updateAccountBasic(null, this.tinode.getCurrentLogin(), password)
|
||||
.catch(err => this.handleError(err.message, 'err'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1413,18 +1395,16 @@ class TinodeWeb extends React.Component {
|
||||
am.updateWant(mode);
|
||||
mode = am.getWant();
|
||||
}
|
||||
topic.setMeta({sub: {user: uid, mode: mode}}).catch((err) => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
topic.setMeta({sub: {user: uid, mode: mode}})
|
||||
.catch(err => this.handleError(err.message, 'err'));
|
||||
}
|
||||
}
|
||||
|
||||
handleTagsUpdateRequest(topicName, tags) {
|
||||
const topic = this.tinode.getTopic(topicName);
|
||||
if (topic) {
|
||||
topic.setMeta({tags: tags}).catch((err) => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
topic.setMeta({tags: tags})
|
||||
.catch(err => this.handleError(err.message, 'err'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1451,9 +1431,9 @@ class TinodeWeb extends React.Component {
|
||||
}
|
||||
this.setState(this.getBlankState());
|
||||
|
||||
cleared.then(() => {
|
||||
cleared.then(_ => {
|
||||
this.tinode = TinodeWeb.tnSetup(this.state.serverAddress, isSecureConnection(),
|
||||
this.state.transport, this.props.intl.locale, LocalStorageUtil.getObject('keep-logged-in'), () => {
|
||||
this.state.transport, this.props.intl.locale, LocalStorageUtil.getObject('keep-logged-in'), _ => {
|
||||
this.tinode.onConnect = this.handleConnected;
|
||||
this.tinode.onDisconnect = this.handleDisconnect;
|
||||
this.tinode.onAutoreconnectIteration = this.handleAutoreconnectIteration;
|
||||
@@ -1477,10 +1457,10 @@ class TinodeWeb extends React.Component {
|
||||
}
|
||||
|
||||
// Request to hard-delete topic.
|
||||
topic.delTopic(true).then((ctrl) => {
|
||||
topic.delTopic(true).then(_ => {
|
||||
// Hide MessagesView and InfoView panels.
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, ''));
|
||||
}).catch((err) => {
|
||||
}).catch(err => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
}
|
||||
@@ -1492,9 +1472,8 @@ class TinodeWeb extends React.Component {
|
||||
}
|
||||
|
||||
// Request hard-delete all messages.
|
||||
topic.delMessagesAll(true).catch((err) => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
topic.delMessagesAll(true)
|
||||
.catch(err => this.handleError(err.message, 'err'));
|
||||
}
|
||||
|
||||
handleLeaveUnsubRequest(topicName) {
|
||||
@@ -1503,10 +1482,10 @@ class TinodeWeb extends React.Component {
|
||||
return;
|
||||
}
|
||||
|
||||
topic.leave(true).then((ctrl) => {
|
||||
topic.leave(true).then(_ => {
|
||||
// Hide MessagesView and InfoView panels.
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, ''));
|
||||
}).catch((err) => {
|
||||
}).catch(err => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
}
|
||||
@@ -1517,12 +1496,12 @@ class TinodeWeb extends React.Component {
|
||||
return;
|
||||
}
|
||||
|
||||
topic.updateMode(null, '-JP').then(_ => {
|
||||
// Hide MessagesView and InfoView panels.
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, ''));
|
||||
}).catch((err) => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
topic.updateMode(null, '-JP')
|
||||
.then(_ => {
|
||||
// Hide MessagesView and InfoView panels.
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, ''));
|
||||
})
|
||||
.catch(err => this.handleError(err.message, 'err'));
|
||||
}
|
||||
|
||||
handleReportTopic(topicName) {
|
||||
@@ -1535,12 +1514,12 @@ class TinodeWeb extends React.Component {
|
||||
this.tinode.report('report', topicName);
|
||||
|
||||
// Remove J and P permissions.
|
||||
topic.updateMode(null, '-JP').then((ctrl) => {
|
||||
topic.updateMode(null, '-JP')
|
||||
.then(_ => {
|
||||
// Hide MessagesView and InfoView panels.
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, ''));
|
||||
}).catch((err) => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
})
|
||||
.catch(err => this.handleError(err.message, 'err'));
|
||||
}
|
||||
|
||||
handleShowContextMenu(params, menuItems) {
|
||||
@@ -1637,7 +1616,7 @@ class TinodeWeb extends React.Component {
|
||||
handleContextMenuAction(action, promise, params) {
|
||||
if (action == 'topic_archive') {
|
||||
if (promise && params.topicName && params.topicName == this.state.topicSelected) {
|
||||
promise.then(() => {
|
||||
promise.then(_ => {
|
||||
this.handleTopicSelected(null);
|
||||
});
|
||||
}
|
||||
@@ -1677,17 +1656,15 @@ class TinodeWeb extends React.Component {
|
||||
|
||||
if (added && added.length > 0) {
|
||||
added.map((uid) => {
|
||||
topic.invite(uid, null).catch((err) => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
topic.invite(uid, null)
|
||||
.catch(err => this.handleError(err.message, 'err'));
|
||||
});
|
||||
}
|
||||
|
||||
if (removed && removed.length > 0) {
|
||||
removed.map((uid) => {
|
||||
topic.delSubscription(uid).catch((err) => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
topic.delSubscription(uid)
|
||||
.catch(err => this.handleError(err.message, 'err'));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1696,9 +1673,7 @@ class TinodeWeb extends React.Component {
|
||||
if (this.tinode.isAuthenticated()) {
|
||||
// Adding new email or phone number in account setting.
|
||||
this.tinode.getMeTopic().setMeta({cred: {meth: cred, resp: code}})
|
||||
.catch((err) => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
.catch(err => this.handleError(err.message, 'err'));
|
||||
} else {
|
||||
// Credential validation on signup.
|
||||
this.setState({credMethod: cred, credCode: code, credToken: token});
|
||||
@@ -1709,10 +1684,8 @@ class TinodeWeb extends React.Component {
|
||||
handlePasswordResetRequest(method, value) {
|
||||
// If already connected, connnect() will return a resolved promise.
|
||||
return this.tinode.connect()
|
||||
.then(() => {
|
||||
return this.tinode.requestResetAuthSecret('basic', method, value);
|
||||
})
|
||||
.catch((err) => {
|
||||
.then(_ => this.tinode.requestResetAuthSecret('basic', method, value))
|
||||
.catch(err => {
|
||||
// Socket error
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
@@ -1724,13 +1697,9 @@ class TinodeWeb extends React.Component {
|
||||
this.handleError(this.props.intl.formatMessage(messages.invalid_security_token), 'err');
|
||||
} else {
|
||||
this.tinode.connect()
|
||||
.then(() => {
|
||||
return this.tinode.updateAccountBasic(null, null, newPassword, {token: token});
|
||||
})
|
||||
.then(() => {
|
||||
HashNavigation.navigateTo('');
|
||||
})
|
||||
.catch((err) => {
|
||||
.then(_ => this.tinode.updateAccountBasic(null, null, newPassword, {token: token}))
|
||||
.then(_ => HashNavigation.navigateTo(''))
|
||||
.catch(err => {
|
||||
// Socket error
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
@@ -1749,7 +1718,7 @@ class TinodeWeb extends React.Component {
|
||||
case CALL_STATE_OUTGOING_INITATED:
|
||||
let head = { webrtc: CALL_HEAD_STARTED };
|
||||
this.handleSendMessage(Drafty.videoCall(), undefined, undefined, head)
|
||||
.then((ctrl) => {
|
||||
.then(ctrl => {
|
||||
if (ctrl.code < 200 || ctrl.code >= 300 || !ctrl.params || !ctrl.params.seq) {
|
||||
this.handleCallClose();
|
||||
return;
|
||||
@@ -1822,7 +1791,7 @@ class TinodeWeb extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
handleCallAcceptCall(topicName) {
|
||||
handleCallAccept(topicName) {
|
||||
const topic = this.tinode.getTopic(topicName);
|
||||
if (!topic) {
|
||||
return;
|
||||
@@ -1836,7 +1805,7 @@ class TinodeWeb extends React.Component {
|
||||
// We need to switch and subscribe to callTopic first.
|
||||
this.setState({
|
||||
callShouldStart: true,
|
||||
}, () => this.handleTopicSelected(this.state.callTopic));
|
||||
}, _ => this.handleTopicSelected(this.state.callTopic));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1945,7 +1914,7 @@ class TinodeWeb extends React.Component {
|
||||
seq={this.state.callSeq}
|
||||
callState={this.state.callState}
|
||||
onRinging={this.handleCallRinging}
|
||||
onAcceptCall={this.handleCallAcceptCall}
|
||||
onAcceptCall={this.handleCallAccept}
|
||||
onReject={this.handleCallHangup}
|
||||
/>
|
||||
:
|
||||
@@ -1955,9 +1924,9 @@ class TinodeWeb extends React.Component {
|
||||
visible={this.state.alertVisible}
|
||||
title={this.state.alertParams.title}
|
||||
content={this.state.alertParams.content}
|
||||
onReject={this.state.alertParams.onReject ? (() => { this.setState({alertVisible: false}); }) : null}
|
||||
onReject={this.state.alertParams.onReject ? (_ => this.setState({alertVisible: false})) : null}
|
||||
reject={this.state.alertParams.reject}
|
||||
onConfirm={() => { this.setState({alertVisible: false}); this.state.alertParams.onConfirm(); }}
|
||||
onConfirm={_ => {this.setState({alertVisible: false}); this.state.alertParams.onConfirm();}}
|
||||
confirm={this.state.alertParams.confirm}
|
||||
/>
|
||||
<SidepanelView
|
||||
|
||||
@@ -27,15 +27,11 @@ export default class Attachment extends React.Component {
|
||||
const downloader = this.props.tinode.getLargeFileHelper();
|
||||
this.setState({downloader: downloader});
|
||||
downloader.download(url, filename, mimetype,
|
||||
(loaded) => {
|
||||
this.setState({progress: loaded / this.props.size});
|
||||
},
|
||||
(err) => {
|
||||
this.props.onError(err, 'err');
|
||||
}
|
||||
).then(() => {
|
||||
this.setState({downloader: null, progress: 0});
|
||||
}).catch((err) => {
|
||||
loaded => this.setState({progress: loaded / this.props.size}),
|
||||
err => this.props.onError(err, 'err')
|
||||
)
|
||||
.then(_ => this.setState({downloader: null, progress: 0}))
|
||||
.catch(err => {
|
||||
if (err) {
|
||||
this.props.onError("Error downloading file: " + err.message, 'err');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Audio recorder widget.
|
||||
|
||||
import React from 'react';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import { secondsToTime } from '../lib/strformat';
|
||||
import { base64ToIntArray } from '../lib/blob-helpers';
|
||||
@@ -18,7 +19,15 @@ const THUMB_COLOR = '#444E';
|
||||
// Minimum number of amplitude bars to draw.
|
||||
const MIN_PREVIEW_LENGTH = 16;
|
||||
|
||||
export default class AudioPlayer extends React.PureComponent {
|
||||
const messages = defineMessages({
|
||||
icon_title_play: {
|
||||
id: 'icon_title_play',
|
||||
defaultMessage: 'Play recording',
|
||||
description: 'Icon tool tip for starting audio playback'
|
||||
}
|
||||
});
|
||||
|
||||
class AudioPlayer extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
@@ -118,7 +127,7 @@ export default class AudioPlayer extends React.PureComponent {
|
||||
|
||||
this.canvasContext.lineWidth = LINE_WIDTH;
|
||||
|
||||
const drawFrame = () => {
|
||||
const drawFrame = _ => {
|
||||
if (!this.canvasRef.current || !this.audioPlayer) {
|
||||
// The component is unmounted.
|
||||
return;
|
||||
@@ -237,7 +246,7 @@ export default class AudioPlayer extends React.PureComponent {
|
||||
const playClass = 'material-icons' +
|
||||
(this.props.short ? '' : ' large') +
|
||||
(this.state.canPlay ? '' : ' disabled');
|
||||
const play = (<a href="#" onClick={this.handlePlay} title="Play">
|
||||
const play = (<a href="#" onClick={this.handlePlay} title={this.props.intl.formatMessage(messages.icon_title_play)}>
|
||||
<i className={playClass}>{this.state.playing ? 'pause_circle' :
|
||||
(this.state.canPlay ? 'play_circle' : 'not_interested')}</i>
|
||||
</a>);
|
||||
@@ -258,3 +267,5 @@ export default class AudioPlayer extends React.PureComponent {
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(AudioPlayer);
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
// Audio recorder widget.
|
||||
|
||||
import React from 'react';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import AudioPlayer from './audio-player.jsx';
|
||||
// Workaround for https://bugs.chromium.org/p/chromium/issues/detail?id=642012
|
||||
// import fixWebmDuration from 'fix-webm-duration';
|
||||
// It adds duration and SeekHead to the webm record.
|
||||
import fixWebmDuration from 'webm-duration-fix';
|
||||
|
||||
import { intArrayToBase64 } from '../lib/blob-helpers.js'
|
||||
import { secondsToTime } from '../lib/strformat';
|
||||
import { MAX_DURATION, MIN_DURATION } from '../config.js';
|
||||
import { KEYPRESS_DELAY, MAX_DURATION, MIN_DURATION } from '../config.js';
|
||||
|
||||
// FFT resolution.
|
||||
const BUFFER_SIZE = 256;
|
||||
@@ -32,7 +34,30 @@ const MAX_SAMPLES_PER_BAR = 10;
|
||||
// Recording format.
|
||||
const AUDIO_MIME_TYPE = 'audio/webm';
|
||||
|
||||
export default class AudioRecorder extends React.PureComponent {
|
||||
const messages = defineMessages({
|
||||
icon_title_delete: {
|
||||
id: 'icon_title_delete',
|
||||
defaultMessage: 'Delete recording',
|
||||
description: 'Icon tool tip for deleting recorded audio'
|
||||
},
|
||||
icon_title_pause: {
|
||||
id: 'icon_title_pause',
|
||||
defaultMessage: 'Pause playback',
|
||||
description: 'Icon tool tip for pausing audio playback'
|
||||
},
|
||||
icon_title_resume: {
|
||||
id: 'icon_title_resume',
|
||||
defaultMessage: 'Resume playback',
|
||||
description: 'Icon tool tip for resuming audio playback'
|
||||
},
|
||||
icon_title_send: {
|
||||
id: 'icon_title_send',
|
||||
defaultMessage: 'Send message',
|
||||
description: 'Icon tool tip for sending a message'
|
||||
},
|
||||
});
|
||||
|
||||
class AudioRecorder extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
@@ -61,6 +86,9 @@ export default class AudioRecorder extends React.PureComponent {
|
||||
this.startedOn = null;
|
||||
this.viewBuffer = [];
|
||||
this.canvasRef = React.createRef();
|
||||
|
||||
// Timestamp for sending "recording" notifications.
|
||||
this.recordingTimestamp = 0;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -105,7 +133,7 @@ export default class AudioRecorder extends React.PureComponent {
|
||||
let prevBarCount = 0;
|
||||
let volume = 0.0;
|
||||
let countPerBar = 0;
|
||||
const drawFrame = () => {
|
||||
const drawFrame = _ => {
|
||||
if (!this.startedOn) {
|
||||
return;
|
||||
}
|
||||
@@ -167,6 +195,13 @@ export default class AudioRecorder extends React.PureComponent {
|
||||
}
|
||||
// Actually draw the bars on canvas.
|
||||
this.canvasContext.stroke();
|
||||
|
||||
// Send notification, if needed.
|
||||
const now = new Date().getTime();
|
||||
if (now - this.recordingTimestamp > KEYPRESS_DELAY) {
|
||||
this.props.onRecordingProgress();
|
||||
this.recordingTimestamp = now;
|
||||
}
|
||||
}
|
||||
|
||||
drawFrame();
|
||||
@@ -237,7 +272,7 @@ export default class AudioRecorder extends React.PureComponent {
|
||||
|
||||
this.mediaRecorder.onstop = _ => {
|
||||
if (this.durationMillis > MIN_DURATION) {
|
||||
this.getRecording(this.mediaRecorder.mimeType, this.durationMillis)
|
||||
this.getRecording(this.mediaRecorder.mimeType)
|
||||
.then(result => this.props.onFinished(result.url, result.preview, this.durationMillis));
|
||||
} else {
|
||||
this.props.onDeleted();
|
||||
@@ -264,13 +299,17 @@ export default class AudioRecorder extends React.PureComponent {
|
||||
this.startedOn = Date.now();
|
||||
this.mediaRecorder.start();
|
||||
this.visualize();
|
||||
|
||||
this.props.onRecordingProgress();
|
||||
this.recordingTimestamp = this.startedOn;
|
||||
}
|
||||
|
||||
// Obtain data in a form sutable for sending or playing back.
|
||||
// If duration is valid, apply fix for Chrome's WebM duration bug.
|
||||
getRecording(mimeType, duration) {
|
||||
let blob = new Blob(this.audioChunks, {type: mimeType || AUDIO_MIME_TYPE});
|
||||
return (duration > 0 ? ysFixWebmDuration(blob, duration, {logger: false}) : Promise.resolve(blob))
|
||||
getRecording(mimeType) {
|
||||
mimeType = mimeType || AUDIO_MIME_TYPE;
|
||||
let blob = new Blob(this.audioChunks, {type: mimeType});
|
||||
return fixWebmDuration(blob, mimeType)
|
||||
.then(fixedBlob => { blob = fixedBlob; return fixedBlob.arrayBuffer(); })
|
||||
.then(arrayBuff => this.audioContext.decodeAudioData(arrayBuff))
|
||||
.then(decoded => this.createPreview(decoded))
|
||||
@@ -313,10 +352,11 @@ export default class AudioRecorder extends React.PureComponent {
|
||||
}
|
||||
|
||||
render() {
|
||||
const {formatMessage} = this.props.intl;
|
||||
const resumeClass = 'material-icons ' + (this.state.enabled ? 'red' : 'gray');
|
||||
return (
|
||||
<div className="audio">
|
||||
<a href="#" onClick={this.handleDelete} title="Delete">
|
||||
<a href="#" onClick={this.handleDelete} title={formatMessage(messages.icon_title_delete)}>
|
||||
<i className="material-icons gray">delete_outline</i>
|
||||
</a>
|
||||
{this.state.recording ?
|
||||
@@ -330,17 +370,19 @@ export default class AudioRecorder extends React.PureComponent {
|
||||
}
|
||||
<div className="duration">{this.state.duration}</div>
|
||||
{this.state.recording ?
|
||||
<a href="#" onClick={this.handlePause} title="Pause">
|
||||
<a href="#" onClick={this.handlePause} title={formatMessage(messages.icon_title_pause)}>
|
||||
<i className="material-icons">pause_circle_outline</i>
|
||||
</a> :
|
||||
<a href="#" onClick={this.handleResume} title="Resume">
|
||||
<a href="#" onClick={this.handleResume} title={formatMessage(messages.icon_title_resume)}>
|
||||
<i className={resumeClass}>radio_button_checked</i>
|
||||
</a>
|
||||
}
|
||||
<a href="#" onClick={this.handleDone} title="Send">
|
||||
<a href="#" onClick={this.handleDone} title={formatMessage(messages.icon_title_send)}>
|
||||
<i className="material-icons">send</i>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(AudioRecorder);
|
||||
|
||||
@@ -37,7 +37,7 @@ export default class CallIncoming extends React.Component {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resetDesc(topic, this.props);
|
||||
this.resetDesc(topic);
|
||||
if (this.props.callState == CALL_STATE_INCOMING_RECEIVED) {
|
||||
// play() throws if the user did not click the app first: https://goo.gl/xX8pDD.
|
||||
RING_SOUND.play().catch(_ => {});
|
||||
@@ -77,9 +77,7 @@ export default class CallIncoming extends React.Component {
|
||||
topic.onMetaDesc = this.previousMetaDesc;
|
||||
}
|
||||
|
||||
resetDesc(topic, props) {
|
||||
const defacs = topic.getDefaultAccess() || {};
|
||||
const acs = topic.getAccessMode();
|
||||
resetDesc(topic) {
|
||||
const badges = [];
|
||||
if (topic.trusted) {
|
||||
for (const [key, val] of Object.entries(topic.trusted)) {
|
||||
@@ -101,7 +99,7 @@ export default class CallIncoming extends React.Component {
|
||||
if (!topic) {
|
||||
return;
|
||||
}
|
||||
this.resetDesc(topic, this.props);
|
||||
this.resetDesc(topic);
|
||||
|
||||
if (this.previousMetaDesc && this.previousMetaDesc != this.onMetaDesc) {
|
||||
this.previousMetaDesc(desc);
|
||||
|
||||
@@ -140,7 +140,7 @@ class CallPanel extends React.PureComponent {
|
||||
|
||||
start() {
|
||||
if (this.state.localStream) {
|
||||
this.props.onError(this.props.intl.formatMessage(messages.already_in_call));
|
||||
this.props.onError(this.props.intl.formatMessage(messages.already_in_call), 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ class CallPanel extends React.PureComponent {
|
||||
const desc = new RTCSessionDescription(info.payload);
|
||||
this.state.pc.setRemoteDescription(desc)
|
||||
.then(_ => {
|
||||
this.setState({ callInitialSetupComplete: true }, () => this.drainRemoteIceCandidatesCache());
|
||||
this.setState({ callInitialSetupComplete: true }, _ => this.drainRemoteIceCandidatesCache());
|
||||
})
|
||||
.catch(this.reportError);
|
||||
}
|
||||
@@ -357,7 +357,7 @@ class CallPanel extends React.PureComponent {
|
||||
})
|
||||
.then(_ => {
|
||||
this.props.onSendAnswer(this.props.topic, this.props.seq, pc.localDescription.toJSON());
|
||||
this.setState({ callInitialSetupComplete: true }, () => this.drainRemoteIceCandidatesCache());
|
||||
this.setState({ callInitialSetupComplete: true }, _ => this.drainRemoteIceCandidatesCache());
|
||||
})
|
||||
.catch(this.handleGetUserMediaError);
|
||||
}
|
||||
@@ -421,14 +421,14 @@ class CallPanel extends React.PureComponent {
|
||||
<div id="video-container">
|
||||
<div id="video-container-panel">
|
||||
<div className="call-party self">
|
||||
<video ref={this.localRef} autoPlay muted playsInline></video>
|
||||
<video ref={this.localRef} autoPlay muted playsInline />
|
||||
<div className="caller-name inactive">
|
||||
<FormattedMessage id="calls_you_label"
|
||||
defaultMessage="You" description="Shown over the local video screen" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="call-party peer">
|
||||
<video ref={this.remoteRef} autoPlay playsInline></video>
|
||||
<video ref={this.remoteRef} autoPlay playsInline />
|
||||
{remoteActive ?
|
||||
<div className="caller-name inactive">{peerTitle}</div> :
|
||||
<div className={`caller-card${pulseAnimation}`}>
|
||||
|
||||
@@ -28,6 +28,8 @@ export default class CallStatus extends React.PureComponent {
|
||||
duration = <FormattedMessage id="call_disconnected" defaultMessage="disconnected" description="Label for disconnected call" />;
|
||||
break;
|
||||
}
|
||||
} else if (this.props.callState == 'started' && !this.props.duration) {
|
||||
duration = <FormattedMessage id="call_in_progress" defaultMessage="in progress" description="Label for call in progress" />
|
||||
} else {
|
||||
duration = <span>{secondsToTime(this.props.duration / 1000)}</span>;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ class BaseChatMessage extends React.PureComponent {
|
||||
props.uploader.onProgress = this.handleProgress.bind(this);
|
||||
}
|
||||
|
||||
this.handleImagePreview = this.handleImagePreview.bind(this);
|
||||
this.handleExpandImage = this.handleExpandImage.bind(this);
|
||||
this.handlePlayVideo = this.handlePlayVideo.bind(this);
|
||||
this.handleFormButtonClick = this.handleFormButtonClick.bind(this);
|
||||
this.handleContextClick = this.handleContextClick.bind(this);
|
||||
this.handleCancelUpload = this.handleCancelUpload.bind(this);
|
||||
@@ -34,17 +35,18 @@ class BaseChatMessage extends React.PureComponent {
|
||||
viewportWidth: props.viewportWidth,
|
||||
authorizeURL: props.tinode.authorizeURL.bind(props.tinode),
|
||||
|
||||
onImagePreview: this.handleImagePreview,
|
||||
onImagePreview: this.handleExpandImage,
|
||||
onVideoPreview: this.handlePlayVideo,
|
||||
onFormButtonClick: this.handleFormButtonClick,
|
||||
onQuoteClick: this.handleQuoteClick
|
||||
};
|
||||
}
|
||||
|
||||
handleImagePreview(e) {
|
||||
handleExpandImage(e) {
|
||||
e.preventDefault();
|
||||
this.props.onImagePreview({
|
||||
this.props.onExpandMedia({
|
||||
url: e.target.src,
|
||||
filename: e.target.title,
|
||||
filename: e.target.dataset.name,
|
||||
width: e.target.dataset.width,
|
||||
height: e.target.dataset.height,
|
||||
size: e.target.dataset.size,
|
||||
@@ -52,6 +54,21 @@ class BaseChatMessage extends React.PureComponent {
|
||||
});
|
||||
}
|
||||
|
||||
handlePlayVideo(e) {
|
||||
e.preventDefault();
|
||||
this.props.onExpandMedia({
|
||||
video: true,
|
||||
url: e.target.dataset.src,
|
||||
preview: e.target.src,
|
||||
filename: e.target.dataset.name,
|
||||
width: e.target.dataset.width,
|
||||
height: e.target.dataset.height,
|
||||
duration: e.target.dataset.duration,
|
||||
size: e.target.dataset.size,
|
||||
type: e.target.dataset.mime
|
||||
});
|
||||
}
|
||||
|
||||
handleFormButtonClick(e) {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
@@ -76,21 +93,37 @@ class BaseChatMessage extends React.PureComponent {
|
||||
if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
|
||||
menuItems.push('menu_item_send_retry');
|
||||
}
|
||||
if (this.props.userIsWriter &&
|
||||
this.props.received > Tinode.MESSAGE_STATUS_FAILED &&
|
||||
this.props.received < Tinode.MESSAGE_STATUS_DEL_RANGE) {
|
||||
if (this.props.userIsWriter && this.props.received > Tinode.MESSAGE_STATUS_FAILED) {
|
||||
menuItems.push('menu_item_reply');
|
||||
if (!this.props.response) {
|
||||
let immutable = false;
|
||||
Drafty.entities(this.props.content, (_0, _1, tp) => {
|
||||
immutable = ['AU', 'EX', 'FM', 'IM', 'VC', 'VD'].includes(tp);
|
||||
return immutable;
|
||||
});
|
||||
if (!immutable) {
|
||||
Drafty.styles(this.props.content, tp => {
|
||||
immutable = ['QQ'].includes(tp);
|
||||
return immutable;
|
||||
});
|
||||
}
|
||||
if (!immutable) {
|
||||
menuItems.push('menu_item_edit');
|
||||
}
|
||||
}
|
||||
}
|
||||
menuItems.push('menu_item_forward');
|
||||
|
||||
this.props.showContextMenu({
|
||||
seq: this.props.seq,
|
||||
replace: this.props.edited ? parseInt(this.props.edited.split(':')[1]) : 0,
|
||||
content: this.props.content,
|
||||
userFrom: this.props.userFrom,
|
||||
userName: this.props.userName,
|
||||
y: e.pageY,
|
||||
x: e.pageX,
|
||||
pickReply: this.props.pickReply
|
||||
pickReply: this.props.pickReply,
|
||||
editMessage: this.props.editMessage,
|
||||
}, menuItems);
|
||||
}
|
||||
|
||||
@@ -141,12 +174,6 @@ class BaseChatMessage extends React.PureComponent {
|
||||
}, this);
|
||||
const tree = Drafty.format(content, fullFormatter, this.formatterContext);
|
||||
content = React.createElement(React.Fragment, null, tree);
|
||||
} else if (this.props.deleted) {
|
||||
// Message represents a range of deleted messages.
|
||||
content = <><i className="material-icons gray">block</i> <i className="gray">
|
||||
<FormattedMessage id="deleted_content"
|
||||
defaultMessage="content deleted" description="Shown when messages are deleted" />
|
||||
</i></>
|
||||
} else if (typeof content != 'string') {
|
||||
content = <><i className="material-icons gray">warning_amber</i> <i className="gray">
|
||||
<FormattedMessage id="invalid_content"
|
||||
@@ -175,6 +202,7 @@ class BaseChatMessage extends React.PureComponent {
|
||||
</div>
|
||||
{this.props.timestamp ?
|
||||
<ReceivedMarker
|
||||
edited={this.props.edited}
|
||||
timestamp={this.props.timestamp}
|
||||
received={this.props.received} />
|
||||
: null}
|
||||
|
||||
@@ -81,7 +81,7 @@ class ContactList extends React.Component {
|
||||
let previewIsResponse;
|
||||
let deliveryStatus;
|
||||
if (!this.props.showMode && c.latestMessage) {
|
||||
const msg = c.latestMessage(true);
|
||||
const msg = c.latestMessage();
|
||||
if (msg) {
|
||||
forwarded = msg.head ? msg.head.forwarded : null;
|
||||
deliveryStatus = msg._status || c.msgStatus(msg, true);
|
||||
|
||||
@@ -55,6 +55,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Forward',
|
||||
description: 'Forward message'
|
||||
},
|
||||
edit: {
|
||||
id: 'menu_item_edit',
|
||||
defaultMessage: 'Edit',
|
||||
description: 'Edit message'
|
||||
},
|
||||
topic_delete: {
|
||||
id: 'menu_item_delete_topic',
|
||||
defaultMessage: 'Delete',
|
||||
@@ -191,7 +196,14 @@ class ContextMenu extends React.Component {
|
||||
'menu_item_forward': {
|
||||
id: 'menu_item_forward',
|
||||
title: formatMessage(messages.forward),
|
||||
handler: () => {} /* the action is taken directly in tinode-web */
|
||||
handler: _ => {} /* the action is taken directly in tinode-web */
|
||||
},
|
||||
'menu_item_edit': {
|
||||
id: 'menu_item_edit',
|
||||
title: formatMessage(messages.edit),
|
||||
handler: (params, errorHandler) => {
|
||||
return this.editMessage(params, errorHandler);
|
||||
}
|
||||
},
|
||||
'topic_unmute': {
|
||||
id: 'topic_unmute',
|
||||
@@ -215,12 +227,10 @@ class ContextMenu extends React.Component {
|
||||
return props.onShowAlert(
|
||||
formatMessage(messages.block), // title
|
||||
formatMessage(messages.topic_block_warning), // content
|
||||
(() => {
|
||||
return this.topicPermissionSetter('-JP', params, errorHandler).then((ctrl) => {
|
||||
this.props.onTopicRemoved(params.topicName);
|
||||
return ctrl;
|
||||
});
|
||||
}),
|
||||
(_ => this.topicPermissionSetter('-JP', params, errorHandler).then(ctrl => {
|
||||
this.props.onTopicRemoved(params.topicName);
|
||||
return ctrl;
|
||||
})),
|
||||
null, // "OK"
|
||||
true, // Show Reject button
|
||||
null // "Cancel"
|
||||
@@ -234,13 +244,13 @@ class ContextMenu extends React.Component {
|
||||
return props.onShowAlert(
|
||||
formatMessage(messages.topic_delete), // title
|
||||
formatMessage(messages.topic_delete_warning), // content
|
||||
(() => {
|
||||
(_ => {
|
||||
const topic = this.props.tinode.getTopic(params.topicName);
|
||||
if (!topic) {
|
||||
console.warn("Topic not found: ", params.topicName);
|
||||
return;
|
||||
}
|
||||
return topic.delTopic(true).catch((err) => {
|
||||
return topic.delTopic(true).catch(err => {
|
||||
if (errorHandler) {
|
||||
errorHandler(err.message, 'err');
|
||||
}
|
||||
@@ -261,7 +271,7 @@ class ContextMenu extends React.Component {
|
||||
console.warn("Topic not found: ", params.topicName);
|
||||
return;
|
||||
}
|
||||
return topic.archive(true).catch((err) => {
|
||||
return topic.archive(true).catch(err => {
|
||||
if (errorHandler) {
|
||||
errorHandler(err.message, 'err');
|
||||
}
|
||||
@@ -277,7 +287,7 @@ class ContextMenu extends React.Component {
|
||||
console.warn("Topic not found: ", params.topicName);
|
||||
return;
|
||||
}
|
||||
return topic.archive(false).catch((err) => {
|
||||
return topic.archive(false).catch(err => {
|
||||
if (errorHandler) {
|
||||
errorHandler(err.message, 'err');
|
||||
}
|
||||
@@ -299,7 +309,7 @@ class ContextMenu extends React.Component {
|
||||
console.warn("Topic or user not found: '" + params.topicName + "', '" + params.user + "'");
|
||||
return;
|
||||
}
|
||||
return topic.delSubscription(params.user).catch((err) => {
|
||||
return topic.delSubscription(params.user).catch(err => {
|
||||
if (errorHandler) {
|
||||
errorHandler(err.message, 'err');
|
||||
}
|
||||
@@ -389,12 +399,14 @@ class ContextMenu extends React.Component {
|
||||
if (!all && topic.cancelSend(params.seq)) {
|
||||
return;
|
||||
}
|
||||
// Can't cancel. Delete instead.
|
||||
|
||||
const promise = all ?
|
||||
topic.delMessagesAll(hard) :
|
||||
topic.delMessagesList([params.seq], hard);
|
||||
params.replace > 0 ?
|
||||
topic.delMessagesEdits(params.replace, hard) :
|
||||
topic.delMessagesList([params.seq], hard);
|
||||
|
||||
return promise.catch((err) => {
|
||||
return promise.catch(err => {
|
||||
if (errorHandler) {
|
||||
errorHandler(err.message, 'err');
|
||||
}
|
||||
@@ -409,7 +421,7 @@ class ContextMenu extends React.Component {
|
||||
return;
|
||||
}
|
||||
const msg = topic.createMessage(params.content, false);
|
||||
return topic.publishDraft(msg).catch((err) => {
|
||||
return topic.publishDraft(msg).catch(err => {
|
||||
if (errorHandler) {
|
||||
errorHandler(err.message, 'err');
|
||||
}
|
||||
@@ -426,9 +438,7 @@ class ContextMenu extends React.Component {
|
||||
|
||||
let result = topic.updateMode(params.user, mode);
|
||||
if (errorHandler) {
|
||||
result = result.catch((err) => {
|
||||
errorHandler(err.message, 'err');
|
||||
});
|
||||
result = result.catch(err => errorHandler(err.message, 'err'));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -437,6 +447,10 @@ class ContextMenu extends React.Component {
|
||||
params.pickReply(params.seq, params.content, params.userFrom, params.userName, errorHandler);
|
||||
}
|
||||
|
||||
editMessage(params, errorHandler) {
|
||||
params.editMessage(params.replace || params.seq, params.content, errorHandler);
|
||||
}
|
||||
|
||||
render() {
|
||||
const menu = [];
|
||||
let count = 0;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import SendMessage from '../widgets/send-message.jsx';
|
||||
import SendMessage from './send-message.jsx';
|
||||
|
||||
import { REM_SIZE } from '../config.js';
|
||||
import { fitImageSize } from '../lib/blob-helpers.js';
|
||||
@@ -47,25 +47,25 @@ export default class ImagePreview extends React.PureComponent {
|
||||
|
||||
// Average font aspect ratio is ~0.5; File name takes 1/3 of the viewport width.
|
||||
const maxlength = Math.max(((this.state.width / REM_SIZE / 1.5) | 0) - 2, 12);
|
||||
const fname = shortenFileName(this.props.content.name, maxlength) || '-';
|
||||
const fname = shortenFileName(this.props.content.filename, maxlength) || '-';
|
||||
|
||||
const width = this.props.content.width || '-';
|
||||
const height = this.props.content.height || '-';
|
||||
return (
|
||||
<div id="image-preview">
|
||||
<div id="image-preview-caption-panel">
|
||||
{!this.props.onSendMessage ?
|
||||
<a href={this.props.content.url} download={true}>
|
||||
{this.props.onSendMessage ?
|
||||
<span>{fname}</span>
|
||||
:
|
||||
<a href={this.props.content.url} download={this.props.content.filename}>
|
||||
<i className="material-icons">file_download</i> <FormattedMessage
|
||||
id="download_action" defaultMessage="download" description="Call to action [download]" />
|
||||
</a>
|
||||
:
|
||||
<span>{fname}</span>
|
||||
}
|
||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a>
|
||||
</div>
|
||||
<div id="image-preview-container" ref={(node) => this.assignWidth(node)}>
|
||||
<img src={this.props.content.url} style={size} className="image-preview" alt={this.props.content.name} />
|
||||
<img src={this.props.content.url} style={size} className="image-preview" alt={this.props.content.filename} />
|
||||
</div>
|
||||
{this.props.onSendMessage ?
|
||||
<SendMessage
|
||||
@@ -81,7 +81,7 @@ export default class ImagePreview extends React.PureComponent {
|
||||
<div>
|
||||
<div><b><FormattedMessage id="label_file_name" defaultMessage="File name:"
|
||||
description="Label for a file name" /></b></div>
|
||||
<div><span title={this.props.content.name}>{fname}</span></div>
|
||||
<div><span title={this.props.content.filename}>{fname}</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div><b><FormattedMessage id="label_content_type" defaultMessage="Content type:"
|
||||
|
||||
@@ -50,7 +50,7 @@ export default class InPlaceEdit extends React.Component {
|
||||
|
||||
handleStartEditing() {
|
||||
if (!this.props.readOnly) {
|
||||
this.setState({active: true}, () => {
|
||||
this.setState({active: true}, _ => {
|
||||
if (this.selfRef.current) {
|
||||
this.selfRef.current.focus();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Element which shows static video preview:
|
||||
// an image with duration and a play button in the middle.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { secondsToTime } from '../lib/strformat.js'
|
||||
|
||||
export default class InlineVideo extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.videoRef = React.createRef();
|
||||
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
}
|
||||
|
||||
handleClick(e) {
|
||||
if (this.props.onClick) {
|
||||
// the click target has to be the image element.
|
||||
this.props.onClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const duration = secondsToTime(this.props['data-duration'] / 1000);
|
||||
const className = 'inline-video' + (this.props.onClick ? ' image-clickable' : '');
|
||||
return (
|
||||
<div className={className}>
|
||||
{React.createElement('img', this.props)}
|
||||
{this.props.onClick ?
|
||||
<div className="play-control">
|
||||
<i className="material-icons white bigger">play_arrow</i>
|
||||
</div>
|
||||
: null}
|
||||
{duration ? <div className="duration">{duration}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,13 +1,12 @@
|
||||
// Image with a placeholder which is replaced when the promise is resolved.
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export default class LazyImage extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
src: 'img/placeholder.png',
|
||||
src: this.props.isvideo ? 'img/blankvid.png' : 'img/blankimg.png',
|
||||
style: Object.assign({padding: '4px'}, this.props.style),
|
||||
className: this.props.className,
|
||||
alt: this.props.alt,
|
||||
@@ -20,7 +19,7 @@ export default class LazyImage extends React.PureComponent {
|
||||
this.props.whenDone
|
||||
.promise
|
||||
.then(data => this.setState({src: data.src, style: {...this.state.style, padding: 0}}))
|
||||
.catch(() => this.setState({src: 'img/broken_image.png'}));
|
||||
.catch(_ => this.setState({src: this.props.isvideo ? 'img/broken_video.png' : 'img/broken_image.png'}));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
@@ -29,11 +28,11 @@ export default class LazyImage extends React.PureComponent {
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.whenDone != this.props.whenDone) {
|
||||
this.setState({src: 'img/placeholder.png', style: {...this.state.style, padding: '4px'}});
|
||||
this.setState({src: this.props.isvideo ? 'img/blankvid.png' : 'img/blankimg.png', style: {...this.state.style, padding: '4px'}});
|
||||
this.props.whenDone
|
||||
.promise
|
||||
.then(data => this.setState({src: data.src, style: {...this.state.style, padding: 0}}))
|
||||
.catch(() => this.setState({src: 'img/broken_image.png'}));
|
||||
.catch(_ => this.setState({src: this.props.isvideo ? 'img/broken_video.png' : 'img/broken_image.png'}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Message bubble with non-payload content, such as an indicator of deleted content or a date stamp.
|
||||
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export default class MetaMessage extends React.PureComponent {
|
||||
constructor(props) {
|
||||
@@ -11,14 +10,7 @@ export default class MetaMessage extends React.PureComponent {
|
||||
render() {
|
||||
let content = null;
|
||||
let bubbleClass = 'bubble';
|
||||
if (this.props.deleted) {
|
||||
// Message represents a range of deleted messages.
|
||||
content = <><i className="material-icons gray">block</i> <i className="gray">
|
||||
<FormattedMessage id="deleted_content"
|
||||
defaultMessage="content deleted" description="Shown when messages are deleted" />
|
||||
</i></>
|
||||
bubbleClass += ' deleted';
|
||||
} else if (this.props.date) {
|
||||
if (this.props.date) {
|
||||
// A date badge.
|
||||
content = <>{this.props.date}</>
|
||||
bubbleClass += ' date';
|
||||
|
||||
@@ -16,6 +16,11 @@ const messages = defineMessages({
|
||||
id: 'message_sending_failed',
|
||||
defaultMessage: 'failed',
|
||||
description: 'Failed to send message, in place of time stamp'
|
||||
},
|
||||
message_edited_marker: {
|
||||
id: 'message_edited_marker',
|
||||
defaultMessage: ', edited',
|
||||
description: 'Marker indicating that the message was edited'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -33,10 +38,11 @@ class ReceivedMarker extends React.PureComponent {
|
||||
|
||||
const icon = deliveryMarker(this.props.received);
|
||||
const marker = icon ? <i className={'material-icons small ' + icon.color}>{icon.name}</i> : null;
|
||||
const edited = this.props.edited ? formatMessage(messages.message_edited_marker) : null;
|
||||
|
||||
return (
|
||||
<span className="timestamp">
|
||||
{timestamp}{'\u00a0'}{marker}
|
||||
{timestamp}{edited}{'\u00a0'}{marker}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// Send message form.
|
||||
import React from 'react';
|
||||
import React, { Suspense } from 'react';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { Drafty } from 'tinode-sdk';
|
||||
|
||||
import AudioRecorder from './audio-recorder.jsx';
|
||||
// Lazy-loading AudioRecorder because it's quite large due to
|
||||
// a dependency on webm-duration-fix.
|
||||
const AudioRecorder = React.lazy(_ => import('./audio-recorder.jsx'));
|
||||
|
||||
import { KEYPRESS_DELAY } from '../config.js';
|
||||
import { filePasted } from '../lib/blob-helpers.js';
|
||||
@@ -35,6 +37,26 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Cannot initiate file upload.',
|
||||
description: 'Generic error messagewhen attachment fails'
|
||||
},
|
||||
icon_title_record_voice: {
|
||||
id: 'icon_title_record_voice',
|
||||
defaultMessage: 'Record voice message',
|
||||
description: 'Icon tool tip for recording a voice message'
|
||||
},
|
||||
icon_title_attach_file: {
|
||||
id: 'icon_title_attach_file',
|
||||
defaultMessage: 'Attach file',
|
||||
description: 'Icon tool tip for attaching a file'
|
||||
},
|
||||
icon_title_add_image: {
|
||||
id: 'icon_title_add_image',
|
||||
defaultMessage: 'Add image',
|
||||
description: 'Icon tool tip for attaching an image'
|
||||
},
|
||||
icon_title_send: {
|
||||
id: 'icon_title_send',
|
||||
defaultMessage: 'Send message',
|
||||
description: 'Icon tool tip for sending a message'
|
||||
},
|
||||
});
|
||||
|
||||
class SendMessage extends React.PureComponent {
|
||||
@@ -46,10 +68,11 @@ class SendMessage extends React.PureComponent {
|
||||
message: '',
|
||||
audioRec: false,
|
||||
audioAvailable: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
|
||||
// Make initial keypress time as if it happened 5001 milliseconds in the past.
|
||||
keypressTimestamp: new Date().getTime() - KEYPRESS_DELAY - 1
|
||||
};
|
||||
|
||||
// Timestamp when the previous key press was sent to the server.
|
||||
this.keypressTimestamp = 0;
|
||||
|
||||
this.handlePasteEvent = this.handlePasteEvent.bind(this);
|
||||
this.handleAttachImage = this.handleAttachImage.bind(this);
|
||||
this.handleAttachFile = this.handleAttachFile.bind(this);
|
||||
@@ -57,6 +80,7 @@ class SendMessage extends React.PureComponent {
|
||||
this.handleSend = this.handleSend.bind(this);
|
||||
this.handleKeyPress = this.handleKeyPress.bind(this);
|
||||
this.handleMessageTyping = this.handleMessageTyping.bind(this);
|
||||
this.handleDropAttach = this.handleDropAttach.bind(this);
|
||||
|
||||
this.handleQuoteClick = this.handleQuoteClick.bind(this);
|
||||
|
||||
@@ -83,7 +107,14 @@ class SendMessage extends React.PureComponent {
|
||||
}
|
||||
|
||||
if (prevProps.topicName != this.props.topicName) {
|
||||
this.setState({message: '', audioRec: false, quote: null});
|
||||
this.setState({message: this.props.initMessage || '', audioRec: false, quote: null});
|
||||
} else if (prevProps.initMessage != this.props.initMessage) {
|
||||
const msg = this.props.initMessage || '';
|
||||
this.setState({message: msg}, _ => {
|
||||
// If there is text, scroll to bottom and set caret to the end.
|
||||
this.messageEditArea.scrollTop = this.messageEditArea.scrollHeight;
|
||||
this.messageEditArea.setSelectionRange(msg.length, msg.length);
|
||||
});
|
||||
}
|
||||
if (prevProps.reply != this.props.reply) {
|
||||
this.setState({quote: this.formatReply()});
|
||||
@@ -129,6 +160,12 @@ class SendMessage extends React.PureComponent {
|
||||
e.target.value = '';
|
||||
}
|
||||
|
||||
handleDropAttach(files) {
|
||||
if (files && files.length > 0) {
|
||||
this.props.onAttachFile(files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
handleAttachAudio(url, preview, duration) {
|
||||
this.setState({audioRec: false});
|
||||
this.props.onAttachAudio(url, preview, duration);
|
||||
@@ -165,15 +202,14 @@ class SendMessage extends React.PureComponent {
|
||||
}
|
||||
|
||||
handleMessageTyping(e) {
|
||||
const newState = {message: e.target.value};
|
||||
this.setState({message: e.target.value});
|
||||
if (this.props.onKeyPress) {
|
||||
const now = new Date().getTime();
|
||||
if (now - this.state.keypressTimestamp > KEYPRESS_DELAY) {
|
||||
if (now - this.keypressTimestamp > KEYPRESS_DELAY) {
|
||||
this.props.onKeyPress();
|
||||
newState.keypressTimestamp = now;
|
||||
this.keypressTimestamp = now;
|
||||
}
|
||||
}
|
||||
this.setState(newState);
|
||||
}
|
||||
|
||||
handleQuoteClick(e) {
|
||||
@@ -193,10 +229,13 @@ class SendMessage extends React.PureComponent {
|
||||
formatMessage(messages[this.props.messagePrompt]) :
|
||||
formatMessage(messages.type_new_message));
|
||||
|
||||
const sendIcon = (this.props.reply && this.props.reply.editing) ?
|
||||
'check_circle' : 'send';
|
||||
|
||||
const quote = this.state.quote ?
|
||||
(<div id="reply-quote-preview">
|
||||
<div className="cancel">
|
||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onCancelReply();}}><i className="material-icons gray">close</i></a>
|
||||
<a href="#" onClick={e => {e.preventDefault(); this.props.onCancelReply();}}><i className="material-icons gray">close</i></a>
|
||||
</div>
|
||||
{this.state.quote}
|
||||
</div>) : null;
|
||||
@@ -209,10 +248,10 @@ class SendMessage extends React.PureComponent {
|
||||
<>
|
||||
{this.props.onAttachFile && !this.state.audioRec ?
|
||||
<>
|
||||
<a href="#" onClick={(e) => {e.preventDefault(); this.attachImage.click();}} title="Add image">
|
||||
<a href="#" onClick={e => {e.preventDefault(); this.attachImage.click();}} title={formatMessage(messages.icon_title_add_image)}>
|
||||
<i className="material-icons secondary">photo</i>
|
||||
</a>
|
||||
<a href="#" onClick={(e) => {e.preventDefault(); this.attachFile.click();}} title="Attach file">
|
||||
<a href="#" onClick={e => {e.preventDefault(); this.attachFile.click();}} title={formatMessage(messages.icon_title_attach_file)}>
|
||||
<i className="material-icons secondary">attach_file</i>
|
||||
</a>
|
||||
</>
|
||||
@@ -221,27 +260,30 @@ class SendMessage extends React.PureComponent {
|
||||
{this.props.noInput ?
|
||||
(quote || <div className="hr thin" />) :
|
||||
(this.state.audioRec ?
|
||||
<AudioRecorder
|
||||
onDeleted={_ => this.setState({audioRec: false})}
|
||||
onFinished={this.handleAttachAudio}/> :
|
||||
(<Suspense fallback={<div>Loading...</div>}>
|
||||
<AudioRecorder
|
||||
onRecordingProgress={_ => this.props.onKeyPress(true)}
|
||||
onDeleted={_ => this.setState({audioRec: false})}
|
||||
onFinished={this.handleAttachAudio}/>
|
||||
</Suspense>) :
|
||||
<textarea id="sendMessage" placeholder={prompt}
|
||||
value={this.state.message} onChange={this.handleMessageTyping}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
ref={(ref) => {this.messageEditArea = ref;}}
|
||||
autoFocus />)}
|
||||
{this.state.message || !audioEnabled ?
|
||||
<a href="#" onClick={this.handleSend} title="Send">
|
||||
<i className="material-icons">send</i>
|
||||
<a href="#" onClick={this.handleSend} title={formatMessage(messages.icon_title_send)}>
|
||||
<i className="material-icons">{sendIcon}</i>
|
||||
</a> :
|
||||
!this.state.audioRec ?
|
||||
<a href="#" onClick={e => {e.preventDefault(); this.setState({audioRec: true})}} title="Voice">
|
||||
<a href="#" onClick={e => {e.preventDefault(); this.setState({audioRec: true})}} title={formatMessage(messages.icon_title_record_voice)}>
|
||||
<i className="material-icons">mic</i>
|
||||
</a> :
|
||||
null
|
||||
}
|
||||
<input type="file" ref={(ref) => {this.attachFile = ref;}}
|
||||
<input type="file" ref={ref => {this.attachFile = ref}}
|
||||
onChange={this.handleAttachFile} style={{display: 'none'}} />
|
||||
<input type="file" ref={(ref) => {this.attachImage = ref;}} accept="image/*"
|
||||
<input type="file" ref={ref => {this.attachImage = ref}} accept="image/*, video/*"
|
||||
onChange={this.handleAttachImage} style={{display: 'none'}} />
|
||||
</>
|
||||
:
|
||||
|
||||
@@ -91,7 +91,7 @@ class TopicSecurity extends React.PureComponent {
|
||||
this.props.onShowAlert(
|
||||
formatMessage(messages.topic_delete), // title
|
||||
formatMessage(messages.topic_delete_warning), // content
|
||||
(() => { this.props.onDeleteTopic(this.props.topic); }), // onConfirm
|
||||
_ => this.props.onDeleteTopic(this.props.topic), // onConfirm
|
||||
null, // "OK"
|
||||
true, // Show Reject button
|
||||
null // "Cancel"
|
||||
@@ -104,7 +104,7 @@ class TopicSecurity extends React.PureComponent {
|
||||
this.props.onShowAlert(
|
||||
formatMessage(this.props.deleter ? messages.delete_messages : messages.clear_messages), // title
|
||||
formatMessage(this.props.deleter ? messages.delete_messages_warning : messages.clear_messages_warning), // content
|
||||
(() => { this.props.onDeleteMessages(this.props.topic); }), // onConfirm
|
||||
_ => this.props.onDeleteMessages(this.props.topic), // onConfirm
|
||||
null, // "OK"
|
||||
true, // Show Reject button
|
||||
null // "Cancel"
|
||||
@@ -117,7 +117,7 @@ class TopicSecurity extends React.PureComponent {
|
||||
this.props.onShowAlert(
|
||||
formatMessage(messages.leave_chat), // title
|
||||
formatMessage(messages.leave_chat_warning), // content
|
||||
(() => { this.props.onLeaveTopic(this.props.topic); }), // onConfirm
|
||||
_ => this.props.onLeaveTopic(this.props.topic), // onConfirm
|
||||
null, // "OK"
|
||||
true, // Show Reject button
|
||||
null // "Cancel"
|
||||
@@ -130,7 +130,7 @@ class TopicSecurity extends React.PureComponent {
|
||||
this.props.onShowAlert(
|
||||
formatMessage(messages.block_contact), // title
|
||||
formatMessage(messages.block_contact_warning), // content
|
||||
(() => { this.props.onBlockTopic(this.props.topic); }), // onConfirm
|
||||
_ => this.props.onBlockTopic(this.props.topic), // onConfirm
|
||||
null, // "OK"
|
||||
true, // Show Reject button
|
||||
null // "Cancel"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Image view with progress bar and a cancel button.
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import FileProgress from './file-progress.jsx';
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
import SendMessage from './send-message.jsx';
|
||||
|
||||
import { bytesToHumanSize } from '../lib/strformat.js';
|
||||
|
||||
const messages = defineMessages({
|
||||
unrecognized_video_format: {
|
||||
id: 'unrecognized_video_format',
|
||||
defaultMessage: 'Format of this video is not recognized',
|
||||
description: 'Error message when uploaded video is invalid',
|
||||
}
|
||||
});
|
||||
|
||||
class VideoPreview extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.videoRef = React.createRef();
|
||||
|
||||
this.handleSendVideo = this.handleSendVideo.bind(this);
|
||||
}
|
||||
|
||||
handleSendVideo(caption) {
|
||||
this.props.onClose();
|
||||
const params = {
|
||||
width: this.videoRef.current.videoWidth,
|
||||
height: this.videoRef.current.videoHeight,
|
||||
duration: (this.videoRef.current.duration * 1000) | 0,
|
||||
mime: this.props.content.mime,
|
||||
name: this.props.content.filename
|
||||
}
|
||||
|
||||
if (params.width == 0 || params.height == 0) {
|
||||
this.props.onError(this.props.intl.formatMessage(messages.unrecognized_video_format), 'err');
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture screen from a video.
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = params.width;
|
||||
canvas.height = params.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(this.videoRef.current, 0, 0, canvas.width, canvas.height);
|
||||
ctx.canvas.toBlob(
|
||||
preview => this.props.onSendMessage(caption, this.props.content.blob, preview, params),
|
||||
'image/jpeg', 0.75
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.props.content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const width = this.props.content.width || '-';
|
||||
const height = this.props.content.height || '-';
|
||||
|
||||
const controlist = this.props.onSendMessage ? 'nodownload' : '';
|
||||
const autoPlay = !this.props.onSendMessage;
|
||||
|
||||
return (
|
||||
<div id="image-preview">
|
||||
<div id="image-preview-caption-panel">
|
||||
<span>{this.props.content.filename}</span>
|
||||
<a href="#" onClick={e => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a>
|
||||
</div>
|
||||
<div id="image-preview-container">
|
||||
<video
|
||||
className="image-preview"
|
||||
controls controlsList={controlist}
|
||||
disablePictureInPicture ref={this.videoRef}
|
||||
autoPlay={autoPlay}
|
||||
src={this.props.tinode.authorizeURL(this.props.content.url)}
|
||||
poster={this.props.content.preview}
|
||||
alt={this.props.content.filename} />
|
||||
</div>
|
||||
{this.props.onSendMessage ?
|
||||
<SendMessage
|
||||
messagePrompt="add_image_caption"
|
||||
acceptBlank={true}
|
||||
tinode={this.props.tinode}
|
||||
reply={this.props.reply}
|
||||
onCancelReply={this.props.onCancelReply}
|
||||
onSendMessage={this.handleSendVideo}
|
||||
onError={this.props.onError} />
|
||||
:
|
||||
<div id="image-preview-footer">
|
||||
<div>
|
||||
<div><b><FormattedMessage id="label_file_name" defaultMessage="File name:"
|
||||
description="Label for a file name" /></b></div>
|
||||
<div><span title={this.props.content.filename}>{this.props.content.filename}</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div><b><FormattedMessage id="label_content_type" defaultMessage="Content type:"
|
||||
description="Label for file content type (mime)" /></b></div>
|
||||
<div>{this.props.content.type}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div><b><FormattedMessage id="label_size" defaultMessage="Size:"
|
||||
description="Label for file size" /></b></div>
|
||||
<div>{width} × {height} px; {bytesToHumanSize(this.props.content.size)}</div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default injectIntl(VideoPreview);
|
||||
@@ -52,7 +52,7 @@ export default class VisiblePassword extends React.PureComponent {
|
||||
handleEditingFinished(e) {
|
||||
if (e) {
|
||||
let currentTarget = e.currentTarget;
|
||||
setTimeout(() => {
|
||||
setTimeout(_ => {
|
||||
if (!currentTarget.contains(document.activeElement)) {
|
||||
if (this.props.onFinished) {
|
||||
this.props.onFinished(this.state.value);
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1485
-997
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,284 @@
|
||||
/******/ (() => { // webpackBootstrap
|
||||
/******/ "use strict";
|
||||
/******/ var __webpack_modules__ = ({});
|
||||
/************************************************************************/
|
||||
/******/ // The module cache
|
||||
/******/ var __webpack_module_cache__ = {};
|
||||
/******/
|
||||
/******/ // The require function
|
||||
/******/ function __webpack_require__(moduleId) {
|
||||
/******/ // Check if module is in cache
|
||||
/******/ var cachedModule = __webpack_module_cache__[moduleId];
|
||||
/******/ if (cachedModule !== undefined) {
|
||||
/******/ return cachedModule.exports;
|
||||
/******/ }
|
||||
/******/ // Create a new module (and put it into the cache)
|
||||
/******/ var module = __webpack_module_cache__[moduleId] = {
|
||||
/******/ // no module.id needed
|
||||
/******/ // no module.loaded needed
|
||||
/******/ exports: {}
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // Execute the module function
|
||||
/******/ __webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);
|
||||
/******/
|
||||
/******/ // Return the exports of the module
|
||||
/******/ return module.exports;
|
||||
/******/ }
|
||||
/******/
|
||||
/******/ // expose the modules object (__webpack_modules__)
|
||||
/******/ __webpack_require__.m = __webpack_modules__;
|
||||
/******/
|
||||
/************************************************************************/
|
||||
/******/ /* webpack/runtime/chunk loaded */
|
||||
/******/ (() => {
|
||||
/******/ var deferred = [];
|
||||
/******/ __webpack_require__.O = (result, chunkIds, fn, priority) => {
|
||||
/******/ if(chunkIds) {
|
||||
/******/ priority = priority || 0;
|
||||
/******/ for(var i = deferred.length; i > 0 && deferred[i - 1][2] > priority; i--) deferred[i] = deferred[i - 1];
|
||||
/******/ deferred[i] = [chunkIds, fn, priority];
|
||||
/******/ return;
|
||||
/******/ }
|
||||
/******/ var notFulfilled = Infinity;
|
||||
/******/ for (var i = 0; i < deferred.length; i++) {
|
||||
/******/ var [chunkIds, fn, priority] = deferred[i];
|
||||
/******/ var fulfilled = true;
|
||||
/******/ for (var j = 0; j < chunkIds.length; j++) {
|
||||
/******/ if ((priority & 1 === 0 || notFulfilled >= priority) && Object.keys(__webpack_require__.O).every((key) => (__webpack_require__.O[key](chunkIds[j])))) {
|
||||
/******/ chunkIds.splice(j--, 1);
|
||||
/******/ } else {
|
||||
/******/ fulfilled = false;
|
||||
/******/ if(priority < notFulfilled) notFulfilled = priority;
|
||||
/******/ }
|
||||
/******/ }
|
||||
/******/ if(fulfilled) {
|
||||
/******/ deferred.splice(i--, 1)
|
||||
/******/ var r = fn();
|
||||
/******/ if (r !== undefined) result = r;
|
||||
/******/ }
|
||||
/******/ }
|
||||
/******/ return result;
|
||||
/******/ };
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/compat get default export */
|
||||
/******/ (() => {
|
||||
/******/ // getDefaultExport function for compatibility with non-harmony modules
|
||||
/******/ __webpack_require__.n = (module) => {
|
||||
/******/ var getter = module && module.__esModule ?
|
||||
/******/ () => (module['default']) :
|
||||
/******/ () => (module);
|
||||
/******/ __webpack_require__.d(getter, { a: getter });
|
||||
/******/ return getter;
|
||||
/******/ };
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/define property getters */
|
||||
/******/ (() => {
|
||||
/******/ // define getter functions for harmony exports
|
||||
/******/ __webpack_require__.d = (exports, definition) => {
|
||||
/******/ for(var key in definition) {
|
||||
/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
|
||||
/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
|
||||
/******/ }
|
||||
/******/ }
|
||||
/******/ };
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/ensure chunk */
|
||||
/******/ (() => {
|
||||
/******/ __webpack_require__.f = {};
|
||||
/******/ // This file contains only the entry chunk.
|
||||
/******/ // The chunk loading function for additional chunks
|
||||
/******/ __webpack_require__.e = (chunkId) => {
|
||||
/******/ return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
|
||||
/******/ __webpack_require__.f[key](chunkId, promises);
|
||||
/******/ return promises;
|
||||
/******/ }, []));
|
||||
/******/ };
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/get javascript chunk filename */
|
||||
/******/ (() => {
|
||||
/******/ // This function allow to reference async chunks
|
||||
/******/ __webpack_require__.u = (chunkId) => {
|
||||
/******/ // return url for filenames based on template
|
||||
/******/ return "" + chunkId + ".dev.js";
|
||||
/******/ };
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/global */
|
||||
/******/ (() => {
|
||||
/******/ __webpack_require__.g = (function() {
|
||||
/******/ if (typeof globalThis === 'object') return globalThis;
|
||||
/******/ try {
|
||||
/******/ return this || new Function('return this')();
|
||||
/******/ } catch (e) {
|
||||
/******/ if (typeof window === 'object') return window;
|
||||
/******/ }
|
||||
/******/ })();
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/hasOwnProperty shorthand */
|
||||
/******/ (() => {
|
||||
/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/load script */
|
||||
/******/ (() => {
|
||||
/******/ var inProgress = {};
|
||||
/******/ var dataWebpackPrefix = "tinode-webapp:";
|
||||
/******/ // loadScript function to load a script via script tag
|
||||
/******/ __webpack_require__.l = (url, done, key, chunkId) => {
|
||||
/******/ if(inProgress[url]) { inProgress[url].push(done); return; }
|
||||
/******/ var script, needAttach;
|
||||
/******/ if(key !== undefined) {
|
||||
/******/ var scripts = document.getElementsByTagName("script");
|
||||
/******/ for(var i = 0; i < scripts.length; i++) {
|
||||
/******/ var s = scripts[i];
|
||||
/******/ if(s.getAttribute("src") == url || s.getAttribute("data-webpack") == dataWebpackPrefix + key) { script = s; break; }
|
||||
/******/ }
|
||||
/******/ }
|
||||
/******/ if(!script) {
|
||||
/******/ needAttach = true;
|
||||
/******/ script = document.createElement('script');
|
||||
/******/
|
||||
/******/ script.charset = 'utf-8';
|
||||
/******/ script.timeout = 120;
|
||||
/******/ if (__webpack_require__.nc) {
|
||||
/******/ script.setAttribute("nonce", __webpack_require__.nc);
|
||||
/******/ }
|
||||
/******/ script.setAttribute("data-webpack", dataWebpackPrefix + key);
|
||||
/******/ script.src = url;
|
||||
/******/ }
|
||||
/******/ inProgress[url] = [done];
|
||||
/******/ var onScriptComplete = (prev, event) => {
|
||||
/******/ // avoid mem leaks in IE.
|
||||
/******/ script.onerror = script.onload = null;
|
||||
/******/ clearTimeout(timeout);
|
||||
/******/ var doneFns = inProgress[url];
|
||||
/******/ delete inProgress[url];
|
||||
/******/ script.parentNode && script.parentNode.removeChild(script);
|
||||
/******/ doneFns && doneFns.forEach((fn) => (fn(event)));
|
||||
/******/ if(prev) return prev(event);
|
||||
/******/ };
|
||||
/******/ var timeout = setTimeout(onScriptComplete.bind(null, undefined, { type: 'timeout', target: script }), 120000);
|
||||
/******/ script.onerror = onScriptComplete.bind(null, script.onerror);
|
||||
/******/ script.onload = onScriptComplete.bind(null, script.onload);
|
||||
/******/ needAttach && document.head.appendChild(script);
|
||||
/******/ };
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/make namespace object */
|
||||
/******/ (() => {
|
||||
/******/ // define __esModule on exports
|
||||
/******/ __webpack_require__.r = (exports) => {
|
||||
/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
|
||||
/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
||||
/******/ }
|
||||
/******/ Object.defineProperty(exports, '__esModule', { value: true });
|
||||
/******/ };
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/publicPath */
|
||||
/******/ (() => {
|
||||
/******/ __webpack_require__.p = "/umd/";
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/jsonp chunk loading */
|
||||
/******/ (() => {
|
||||
/******/ // no baseURI
|
||||
/******/
|
||||
/******/ // object to store loaded and loading chunks
|
||||
/******/ // undefined = chunk not loaded, null = chunk preloaded/prefetched
|
||||
/******/ // [resolve, reject, Promise] = chunk loading, 0 = chunk loaded
|
||||
/******/ var installedChunks = {
|
||||
/******/ "runtime": 0
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ __webpack_require__.f.j = (chunkId, promises) => {
|
||||
/******/ // JSONP chunk loading for javascript
|
||||
/******/ var installedChunkData = __webpack_require__.o(installedChunks, chunkId) ? installedChunks[chunkId] : undefined;
|
||||
/******/ if(installedChunkData !== 0) { // 0 means "already installed".
|
||||
/******/
|
||||
/******/ // a Promise means "currently loading".
|
||||
/******/ if(installedChunkData) {
|
||||
/******/ promises.push(installedChunkData[2]);
|
||||
/******/ } else {
|
||||
/******/ if("runtime" != chunkId) {
|
||||
/******/ // setup Promise in chunk cache
|
||||
/******/ var promise = new Promise((resolve, reject) => (installedChunkData = installedChunks[chunkId] = [resolve, reject]));
|
||||
/******/ promises.push(installedChunkData[2] = promise);
|
||||
/******/
|
||||
/******/ // start chunk loading
|
||||
/******/ var url = __webpack_require__.p + __webpack_require__.u(chunkId);
|
||||
/******/ // create error before stack unwound to get useful stacktrace later
|
||||
/******/ var error = new Error();
|
||||
/******/ var loadingEnded = (event) => {
|
||||
/******/ if(__webpack_require__.o(installedChunks, chunkId)) {
|
||||
/******/ installedChunkData = installedChunks[chunkId];
|
||||
/******/ if(installedChunkData !== 0) installedChunks[chunkId] = undefined;
|
||||
/******/ if(installedChunkData) {
|
||||
/******/ var errorType = event && (event.type === 'load' ? 'missing' : event.type);
|
||||
/******/ var realSrc = event && event.target && event.target.src;
|
||||
/******/ error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
|
||||
/******/ error.name = 'ChunkLoadError';
|
||||
/******/ error.type = errorType;
|
||||
/******/ error.request = realSrc;
|
||||
/******/ installedChunkData[1](error);
|
||||
/******/ }
|
||||
/******/ }
|
||||
/******/ };
|
||||
/******/ __webpack_require__.l(url, loadingEnded, "chunk-" + chunkId, chunkId);
|
||||
/******/ } else installedChunks[chunkId] = 0;
|
||||
/******/ }
|
||||
/******/ }
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // no prefetching
|
||||
/******/
|
||||
/******/ // no preloaded
|
||||
/******/
|
||||
/******/ // no HMR
|
||||
/******/
|
||||
/******/ // no HMR manifest
|
||||
/******/
|
||||
/******/ __webpack_require__.O.j = (chunkId) => (installedChunks[chunkId] === 0);
|
||||
/******/
|
||||
/******/ // install a JSONP callback for chunk loading
|
||||
/******/ var webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
|
||||
/******/ var [chunkIds, moreModules, runtime] = data;
|
||||
/******/ // add "moreModules" to the modules object,
|
||||
/******/ // then flag all "chunkIds" as loaded and fire callback
|
||||
/******/ var moduleId, chunkId, i = 0;
|
||||
/******/ if(chunkIds.some((id) => (installedChunks[id] !== 0))) {
|
||||
/******/ for(moduleId in moreModules) {
|
||||
/******/ if(__webpack_require__.o(moreModules, moduleId)) {
|
||||
/******/ __webpack_require__.m[moduleId] = moreModules[moduleId];
|
||||
/******/ }
|
||||
/******/ }
|
||||
/******/ if(runtime) var result = runtime(__webpack_require__);
|
||||
/******/ }
|
||||
/******/ if(parentChunkLoadingFunction) parentChunkLoadingFunction(data);
|
||||
/******/ for(;i < chunkIds.length; i++) {
|
||||
/******/ chunkId = chunkIds[i];
|
||||
/******/ if(__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {
|
||||
/******/ installedChunks[chunkId][0]();
|
||||
/******/ }
|
||||
/******/ installedChunks[chunkId] = 0;
|
||||
/******/ }
|
||||
/******/ return __webpack_require__.O(result);
|
||||
/******/ }
|
||||
/******/
|
||||
/******/ var chunkLoadingGlobal = globalThis["webpackChunktinode_webapp"] = globalThis["webpackChunktinode_webapp"] || [];
|
||||
/******/ chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
|
||||
/******/ chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));
|
||||
/******/ })();
|
||||
/******/
|
||||
/************************************************************************/
|
||||
/******/
|
||||
/******/
|
||||
/******/ })()
|
||||
;
|
||||
//# sourceMappingURL=runtime.dev.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
(()=>{"use strict";var e,r,t,o={},n={};function a(e){var r=n[e];if(void 0!==r)return r.exports;var t=n[e]={exports:{}};return o[e].call(t.exports,t,t.exports,a),t.exports}a.m=o,e=[],a.O=(r,t,o,n)=>{if(!t){var i=1/0;for(s=0;s<e.length;s++){for(var[t,o,n]=e[s],l=!0,u=0;u<t.length;u++)(!1&n||i>=n)&&Object.keys(a.O).every((e=>a.O[e](t[u])))?t.splice(u--,1):(l=!1,n<i&&(i=n));if(l){e.splice(s--,1);var d=o();void 0!==d&&(r=d)}}return r}n=n||0;for(var s=e.length;s>0&&e[s-1][2]>n;s--)e[s]=e[s-1];e[s]=[t,o,n]},a.n=e=>{var r=e&&e.__esModule?()=>e.default:()=>e;return a.d(r,{a:r}),r},a.d=(e,r)=>{for(var t in r)a.o(r,t)&&!a.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:r[t]})},a.f={},a.e=e=>Promise.all(Object.keys(a.f).reduce(((r,t)=>(a.f[t](e,r),r)),[])),a.u=e=>e+".prod.js",a.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),a.o=(e,r)=>Object.prototype.hasOwnProperty.call(e,r),r={},t="tinode-webapp:",a.l=(e,o,n,i)=>{if(r[e])r[e].push(o);else{var l,u;if(void 0!==n)for(var d=document.getElementsByTagName("script"),s=0;s<d.length;s++){var p=d[s];if(p.getAttribute("src")==e||p.getAttribute("data-webpack")==t+n){l=p;break}}l||(u=!0,(l=document.createElement("script")).charset="utf-8",l.timeout=120,a.nc&&l.setAttribute("nonce",a.nc),l.setAttribute("data-webpack",t+n),l.src=e),r[e]=[o];var c=(t,o)=>{l.onerror=l.onload=null,clearTimeout(f);var n=r[e];if(delete r[e],l.parentNode&&l.parentNode.removeChild(l),n&&n.forEach((e=>e(o))),t)return t(o)},f=setTimeout(c.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=c.bind(null,l.onerror),l.onload=c.bind(null,l.onload),u&&document.head.appendChild(l)}},a.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},a.p="/umd/",(()=>{var e={666:0};a.f.j=(r,t)=>{var o=a.o(e,r)?e[r]:void 0;if(0!==o)if(o)t.push(o[2]);else if(666!=r){var n=new Promise(((t,n)=>o=e[r]=[t,n]));t.push(o[2]=n);var i=a.p+a.u(r),l=new Error;a.l(i,(t=>{if(a.o(e,r)&&(0!==(o=e[r])&&(e[r]=void 0),o)){var n=t&&("load"===t.type?"missing":t.type),i=t&&t.target&&t.target.src;l.message="Loading chunk "+r+" failed.\n("+n+": "+i+")",l.name="ChunkLoadError",l.type=n,l.request=i,o[1](l)}}),"chunk-"+r,r)}else e[r]=0},a.O.j=r=>0===e[r];var r=(r,t)=>{var o,n,[i,l,u]=t,d=0;if(i.some((r=>0!==e[r]))){for(o in l)a.o(l,o)&&(a.m[o]=l[o]);if(u)var s=u(a)}for(r&&r(t);d<i.length;d++)n=i[d],a.o(e,n)&&e[n]&&e[n][0](),e[n]=0;return a.O(s)},t=globalThis.webpackChunktinode_webapp=globalThis.webpackChunktinode_webapp||[];t.forEach(r.bind(null,0)),t.push=r.bind(null,t.push.bind(t))})()})();
|
||||
//# sourceMappingURL=runtime.prod.js.map
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,365 @@
|
||||
"use strict";
|
||||
(globalThis["webpackChunktinode_webapp"] = globalThis["webpackChunktinode_webapp"] || []).push([["src_widgets_audio-recorder_jsx"],{
|
||||
|
||||
/***/ "./src/widgets/audio-recorder.jsx":
|
||||
/*!****************************************!*\
|
||||
!*** ./src/widgets/audio-recorder.jsx ***!
|
||||
\****************************************/
|
||||
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
|
||||
|
||||
__webpack_require__.r(__webpack_exports__);
|
||||
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
||||
/* harmony export */ "default": () => (__WEBPACK_DEFAULT_EXPORT__)
|
||||
/* harmony export */ });
|
||||
/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! react */ "react");
|
||||
/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
|
||||
/* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! react-intl */ "react-intl");
|
||||
/* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react_intl__WEBPACK_IMPORTED_MODULE_1__);
|
||||
/* harmony import */ var _audio_player_jsx__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./audio-player.jsx */ "./src/widgets/audio-player.jsx");
|
||||
/* harmony import */ var webm_duration_fix__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! webm-duration-fix */ "./node_modules/webm-duration-fix/lib/index.js");
|
||||
/* harmony import */ var webm_duration_fix__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(webm_duration_fix__WEBPACK_IMPORTED_MODULE_3__);
|
||||
/* harmony import */ var _lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ../lib/blob-helpers.js */ "./src/lib/blob-helpers.js");
|
||||
/* harmony import */ var _lib_strformat__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ../lib/strformat */ "./src/lib/strformat.js");
|
||||
/* harmony import */ var _config_js__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ../config.js */ "./src/config.js");
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const BUFFER_SIZE = 256;
|
||||
const CANVAS_UPSCALING = 2.0;
|
||||
const LINE_WIDTH = 3 * CANVAS_UPSCALING;
|
||||
const SPACING = 2 * CANVAS_UPSCALING;
|
||||
const MILLIS_PER_BAR = 100;
|
||||
const BAR_COLOR = '#BBBD';
|
||||
const BAR_SCALE = 64.0;
|
||||
const VISUALIZATION_BARS = 96;
|
||||
const MAX_SAMPLES_PER_BAR = 10;
|
||||
const AUDIO_MIME_TYPE = 'audio/webm';
|
||||
const messages = (0,react_intl__WEBPACK_IMPORTED_MODULE_1__.defineMessages)({
|
||||
icon_title_delete: {
|
||||
id: "icon_title_delete",
|
||||
defaultMessage: [{
|
||||
"type": 0,
|
||||
"value": "Delete recording"
|
||||
}]
|
||||
},
|
||||
icon_title_pause: {
|
||||
id: "icon_title_pause",
|
||||
defaultMessage: [{
|
||||
"type": 0,
|
||||
"value": "Pause playback"
|
||||
}]
|
||||
},
|
||||
icon_title_resume: {
|
||||
id: "icon_title_resume",
|
||||
defaultMessage: [{
|
||||
"type": 0,
|
||||
"value": "Resume playback"
|
||||
}]
|
||||
},
|
||||
icon_title_send: {
|
||||
id: "icon_title_send",
|
||||
defaultMessage: [{
|
||||
"type": 0,
|
||||
"value": "Send message"
|
||||
}]
|
||||
}
|
||||
});
|
||||
class AudioRecorder extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComponent) {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
enabled: true,
|
||||
audioRecord: null,
|
||||
recording: true,
|
||||
paused: false,
|
||||
duration: '0:00',
|
||||
blobUrl: null,
|
||||
preview: null
|
||||
};
|
||||
this.visualize = this.visualize.bind(this);
|
||||
this.initMediaRecording = this.initMediaRecording.bind(this);
|
||||
this.initCanvas = this.initCanvas.bind(this);
|
||||
this.getRecording = this.getRecording.bind(this);
|
||||
this.cleanUp = this.cleanUp.bind(this);
|
||||
this.handleResume = this.handleResume.bind(this);
|
||||
this.handlePause = this.handlePause.bind(this);
|
||||
this.handleDelete = this.handleDelete.bind(this);
|
||||
this.handleDone = this.handleDone.bind(this);
|
||||
this.durationMillis = 0;
|
||||
this.startedOn = null;
|
||||
this.viewBuffer = [];
|
||||
this.canvasRef = react__WEBPACK_IMPORTED_MODULE_0___default().createRef();
|
||||
this.recordingTimestamp = 0;
|
||||
}
|
||||
componentDidMount() {
|
||||
this.stream = null;
|
||||
this.mediaRecorder = null;
|
||||
this.audioContext = null;
|
||||
this.audioInput = null;
|
||||
this.analyser = null;
|
||||
this.audioChunks = [];
|
||||
try {
|
||||
navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
video: false
|
||||
}).then(this.initMediaRecording, this.props.onError);
|
||||
} catch (err) {
|
||||
this.props.onError(err);
|
||||
}
|
||||
}
|
||||
componentWillUnmount() {
|
||||
this.startedOn = null;
|
||||
if (this.stream) {
|
||||
this.cleanUp();
|
||||
}
|
||||
}
|
||||
visualize() {
|
||||
this.initCanvas();
|
||||
const pcmData = new Uint8Array(this.analyser.frequencyBinCount);
|
||||
const width = this.canvasWidth;
|
||||
const height = this.canvasHeight;
|
||||
const viewLength = width / (LINE_WIDTH + SPACING) | 0;
|
||||
const viewDuration = MILLIS_PER_BAR * viewLength;
|
||||
this.canvasContext.lineWidth = LINE_WIDTH;
|
||||
this.canvasContext.strokeStyle = BAR_COLOR;
|
||||
let prevBarCount = 0;
|
||||
let volume = 0.0;
|
||||
let countPerBar = 0;
|
||||
const drawFrame = _ => {
|
||||
if (!this.startedOn) {
|
||||
return;
|
||||
}
|
||||
window.requestAnimationFrame(drawFrame);
|
||||
const duration = this.durationMillis + (Date.now() - this.startedOn);
|
||||
this.setState({
|
||||
duration: (0,_lib_strformat__WEBPACK_IMPORTED_MODULE_5__.secondsToTime)(duration / 1000)
|
||||
});
|
||||
if (duration > _config_js__WEBPACK_IMPORTED_MODULE_6__.MAX_DURATION) {
|
||||
this.startedOn = null;
|
||||
this.mediaRecorder.pause();
|
||||
this.durationMillis += Date.now() - this.startedOn;
|
||||
this.setState({
|
||||
enabled: false,
|
||||
recording: false,
|
||||
duration: (0,_lib_strformat__WEBPACK_IMPORTED_MODULE_5__.secondsToTime)(this.durationMillis / 1000)
|
||||
});
|
||||
}
|
||||
this.analyser.getByteTimeDomainData(pcmData);
|
||||
let amp = 0.0;
|
||||
for (const amplitude of pcmData) {
|
||||
amp += (amplitude - 127) ** 2;
|
||||
}
|
||||
volume += Math.sqrt(amp / pcmData.length);
|
||||
countPerBar++;
|
||||
let barCount = duration / MILLIS_PER_BAR | 0;
|
||||
const dx = viewDuration > duration ? 0 : (duration - MILLIS_PER_BAR * barCount) / MILLIS_PER_BAR * (LINE_WIDTH + SPACING);
|
||||
if (prevBarCount != barCount) {
|
||||
prevBarCount = barCount;
|
||||
this.viewBuffer.push(volume / countPerBar);
|
||||
volume = 0.0;
|
||||
countPerBar = 0;
|
||||
if (this.viewBuffer.length > viewLength) {
|
||||
this.viewBuffer.shift();
|
||||
}
|
||||
}
|
||||
this.canvasContext.clearRect(0, 0, width, height);
|
||||
this.canvasContext.beginPath();
|
||||
for (let i = 0; i < this.viewBuffer.length; i++) {
|
||||
let x = i * (LINE_WIDTH + SPACING) - dx;
|
||||
let y = Math.min(this.viewBuffer[i] / BAR_SCALE, 0.9) * height;
|
||||
this.canvasContext.moveTo(x, (height - y) * 0.5);
|
||||
this.canvasContext.lineTo(x, height * 0.5 + y * 0.5);
|
||||
}
|
||||
this.canvasContext.stroke();
|
||||
const now = new Date().getTime();
|
||||
if (now - this.recordingTimestamp > _config_js__WEBPACK_IMPORTED_MODULE_6__.KEYPRESS_DELAY) {
|
||||
this.props.onRecordingProgress();
|
||||
this.recordingTimestamp = now;
|
||||
}
|
||||
};
|
||||
drawFrame();
|
||||
}
|
||||
handlePause(e) {
|
||||
e.preventDefault();
|
||||
this.mediaRecorder.pause();
|
||||
this.mediaRecorder.requestData();
|
||||
this.durationMillis += Date.now() - this.startedOn;
|
||||
this.startedOn = null;
|
||||
this.setState({
|
||||
recording: false
|
||||
});
|
||||
}
|
||||
handleResume(e) {
|
||||
e.preventDefault();
|
||||
if (this.state.enabled) {
|
||||
this.startedOn = Date.now();
|
||||
this.mediaRecorder.resume();
|
||||
this.setState({
|
||||
recording: true
|
||||
}, this.visualize);
|
||||
}
|
||||
}
|
||||
handleDelete(e) {
|
||||
e.preventDefault();
|
||||
this.durationMillis = 0;
|
||||
this.startedOn = null;
|
||||
this.mediaRecorder.stop();
|
||||
this.cleanUp();
|
||||
this.setState({
|
||||
recording: false
|
||||
});
|
||||
}
|
||||
handleDone(e) {
|
||||
e.preventDefault();
|
||||
this.setState({
|
||||
recording: false
|
||||
});
|
||||
if (this.startedOn) {
|
||||
this.durationMillis += Date.now() - this.startedOn;
|
||||
this.startedOn = null;
|
||||
}
|
||||
if (this.mediaRecorder) {
|
||||
this.mediaRecorder.stop();
|
||||
}
|
||||
}
|
||||
initCanvas() {
|
||||
this.canvasRef.current.width = this.canvasRef.current.offsetWidth * CANVAS_UPSCALING;
|
||||
this.canvasRef.current.height = this.canvasRef.current.offsetHeight * CANVAS_UPSCALING;
|
||||
this.canvasContext = this.canvasRef.current.getContext('2d');
|
||||
this.canvasContext.lineCap = 'round';
|
||||
this.canvasContext.translate(0.5, 0.5);
|
||||
this.canvasWidth = this.canvasRef.current.width;
|
||||
this.canvasHeight = this.canvasRef.current.height;
|
||||
}
|
||||
initMediaRecording(stream) {
|
||||
this.stream = stream;
|
||||
this.mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: AUDIO_MIME_TYPE,
|
||||
audioBitsPerSecond: 24_000
|
||||
});
|
||||
this.audioContext = new AudioContext();
|
||||
this.audioInput = this.audioContext.createMediaStreamSource(stream);
|
||||
this.analyser = this.audioContext.createAnalyser();
|
||||
this.analyser.fftSize = BUFFER_SIZE;
|
||||
this.audioInput.connect(this.analyser);
|
||||
this.mediaRecorder.onstop = _ => {
|
||||
if (this.durationMillis > _config_js__WEBPACK_IMPORTED_MODULE_6__.MIN_DURATION) {
|
||||
this.getRecording(this.mediaRecorder.mimeType).then(result => this.props.onFinished(result.url, result.preview, this.durationMillis));
|
||||
} else {
|
||||
this.props.onDeleted();
|
||||
}
|
||||
this.cleanUp();
|
||||
};
|
||||
this.mediaRecorder.ondataavailable = e => {
|
||||
if (e.data.size > 0) {
|
||||
this.audioChunks.push(e.data);
|
||||
}
|
||||
if (this.mediaRecorder.state != 'inactive') {
|
||||
this.getRecording(this.mediaRecorder.mimeType).then(result => {
|
||||
this.setState({
|
||||
blobUrl: result.url,
|
||||
preview: result.preview
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
this.durationMillis = 0;
|
||||
this.startedOn = Date.now();
|
||||
this.mediaRecorder.start();
|
||||
this.visualize();
|
||||
this.props.onRecordingProgress();
|
||||
this.recordingTimestamp = this.startedOn;
|
||||
}
|
||||
getRecording(mimeType) {
|
||||
mimeType = mimeType || AUDIO_MIME_TYPE;
|
||||
let blob = new Blob(this.audioChunks, {
|
||||
type: mimeType
|
||||
});
|
||||
return webm_duration_fix__WEBPACK_IMPORTED_MODULE_3___default()(blob, mimeType).then(fixedBlob => {
|
||||
blob = fixedBlob;
|
||||
return fixedBlob.arrayBuffer();
|
||||
}).then(arrayBuff => this.audioContext.decodeAudioData(arrayBuff)).then(decoded => this.createPreview(decoded)).then(preview => ({
|
||||
url: window.URL.createObjectURL(blob),
|
||||
preview: (0,_lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_4__.intArrayToBase64)(preview)
|
||||
}));
|
||||
}
|
||||
createPreview(audio) {
|
||||
const data = audio.getChannelData(0);
|
||||
const viewLength = Math.min(data.length, VISUALIZATION_BARS);
|
||||
const totalSPB = data.length / viewLength | 0;
|
||||
const samplingRate = Math.max(1, totalSPB / MAX_SAMPLES_PER_BAR | 0);
|
||||
let buffer = [];
|
||||
let max = -1;
|
||||
for (let i = 0; i < viewLength; i++) {
|
||||
let amplitude = 0;
|
||||
let count = 0;
|
||||
for (let j = 0; j < totalSPB; j += samplingRate) {
|
||||
amplitude += data[totalSPB * i + j] ** 2;
|
||||
count++;
|
||||
}
|
||||
const val = Math.sqrt(amplitude / count);
|
||||
buffer.push(val);
|
||||
max = Math.max(max, val);
|
||||
}
|
||||
if (max > 0) {
|
||||
buffer = buffer.map(a => 100 * a / max | 0);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
cleanUp() {
|
||||
this.audioInput.disconnect();
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
render() {
|
||||
const {
|
||||
formatMessage
|
||||
} = this.props.intl;
|
||||
const resumeClass = 'material-icons ' + (this.state.enabled ? 'red' : 'gray');
|
||||
return react__WEBPACK_IMPORTED_MODULE_0___default().createElement("div", {
|
||||
className: "audio"
|
||||
}, react__WEBPACK_IMPORTED_MODULE_0___default().createElement("a", {
|
||||
href: "#",
|
||||
onClick: this.handleDelete,
|
||||
title: formatMessage(messages.icon_title_delete)
|
||||
}, react__WEBPACK_IMPORTED_MODULE_0___default().createElement("i", {
|
||||
className: "material-icons gray"
|
||||
}, "delete_outline")), this.state.recording ? react__WEBPACK_IMPORTED_MODULE_0___default().createElement("canvas", {
|
||||
ref: this.canvasRef
|
||||
}) : react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_audio_player_jsx__WEBPACK_IMPORTED_MODULE_2__["default"], {
|
||||
src: this.state.blobUrl,
|
||||
preview: this.state.preview,
|
||||
duration: this.durationMillis,
|
||||
short: true
|
||||
}), react__WEBPACK_IMPORTED_MODULE_0___default().createElement("div", {
|
||||
className: "duration"
|
||||
}, this.state.duration), this.state.recording ? react__WEBPACK_IMPORTED_MODULE_0___default().createElement("a", {
|
||||
href: "#",
|
||||
onClick: this.handlePause,
|
||||
title: formatMessage(messages.icon_title_pause)
|
||||
}, react__WEBPACK_IMPORTED_MODULE_0___default().createElement("i", {
|
||||
className: "material-icons"
|
||||
}, "pause_circle_outline")) : react__WEBPACK_IMPORTED_MODULE_0___default().createElement("a", {
|
||||
href: "#",
|
||||
onClick: this.handleResume,
|
||||
title: formatMessage(messages.icon_title_resume)
|
||||
}, react__WEBPACK_IMPORTED_MODULE_0___default().createElement("i", {
|
||||
className: resumeClass
|
||||
}, "radio_button_checked")), react__WEBPACK_IMPORTED_MODULE_0___default().createElement("a", {
|
||||
href: "#",
|
||||
onClick: this.handleDone,
|
||||
title: formatMessage(messages.icon_title_send)
|
||||
}, react__WEBPACK_IMPORTED_MODULE_0___default().createElement("i", {
|
||||
className: "material-icons"
|
||||
}, "send")));
|
||||
}
|
||||
}
|
||||
/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = ((0,react_intl__WEBPACK_IMPORTED_MODULE_1__.injectIntl)(AudioRecorder));
|
||||
|
||||
/***/ })
|
||||
|
||||
}]);
|
||||
//# sourceMappingURL=src_widgets_audio-recorder_jsx.dev.js.map
|
||||
File diff suppressed because one or more lines are too long
+237
-174
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user