Compare commits
19
Commits
02e8bd2f74
...
14b38af8da
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14b38af8da | ||
|
|
3ae00646c7 | ||
|
|
43fde20643 | ||
|
|
6527c067c6 | ||
|
|
2f374e52ca | ||
|
|
b1acfecd4f | ||
|
|
5448f10b1a | ||
|
|
3b78971a39 | ||
|
|
11436a80ee | ||
|
|
3567f323c7 | ||
|
|
6f05fd102f | ||
|
|
4897d63029 | ||
|
|
5e92189b97 | ||
|
|
30bf77d4ba | ||
|
|
b2772924bd | ||
|
|
6c1c8cf336 | ||
|
|
f91d8a979d | ||
|
|
17b91c4bb8 | ||
|
|
a3a4fe7505 |
+100
-81
@@ -68,12 +68,20 @@ label.large {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
label.invalid {
|
||||
color: #D32F2F;
|
||||
}
|
||||
|
||||
img.avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
img.avatar.self {
|
||||
padding: 7%;
|
||||
}
|
||||
|
||||
/* Input fields and buttons */
|
||||
|
||||
button {
|
||||
@@ -238,22 +246,23 @@ textarea::placeholder {
|
||||
font-weight: lighter;
|
||||
}
|
||||
|
||||
input[type="text"].with-visibility,
|
||||
input[type="password"].with-visibility {
|
||||
input[type="text"].with-icon-right,
|
||||
input[type="password"].with-icon-right {
|
||||
margin-right:-2rem;
|
||||
padding-right:2rem;
|
||||
}
|
||||
|
||||
input[type="text"].with-icon-left,
|
||||
input[type="password"].with-icon-left {
|
||||
margin-left:-1.45rem;
|
||||
padding-left:1.75rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: none;
|
||||
padding-top: 0.45rem;
|
||||
}
|
||||
|
||||
textarea.inplace-edit {
|
||||
height: unset;
|
||||
font-size: unset;
|
||||
}
|
||||
|
||||
/* END of input and buttons */
|
||||
|
||||
/* BEGIN scrollbar styling. */
|
||||
@@ -630,6 +639,7 @@ div.trusted-badge {
|
||||
}
|
||||
|
||||
#info-view {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 0;
|
||||
@@ -675,7 +685,7 @@ div.trusted-badge {
|
||||
}
|
||||
|
||||
/* BEGIN InPlaceEdit styles */
|
||||
.in-place-edit {
|
||||
span.in-place-edit, div.in-place-edit {
|
||||
display: inline-block;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
@@ -687,9 +697,12 @@ div.trusted-badge {
|
||||
}
|
||||
|
||||
.in-place-edit.short {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 1.1rem;
|
||||
height: 2.5rem;
|
||||
line-height: 2.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.in-place-edit.disabled {
|
||||
@@ -707,11 +720,29 @@ div.trusted-badge {
|
||||
}
|
||||
|
||||
.in-place-edit.short span {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.in-place-edit .material-icons {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.material-icons.invalid {
|
||||
color: #c66;
|
||||
}
|
||||
|
||||
textarea.in-place-edit {
|
||||
height: unset;
|
||||
font-size: unset;
|
||||
}
|
||||
|
||||
input.in-place-edit.invalid {
|
||||
color: #c66;
|
||||
}
|
||||
|
||||
/* END of InPlaceEdit styles */
|
||||
|
||||
table.permission-editor {
|
||||
@@ -824,6 +855,7 @@ table.permission-editor td.checkbox {
|
||||
.bot-form button:hover,
|
||||
.bot-form button:active {
|
||||
outline: 0;
|
||||
color: #1565C0;
|
||||
background-color: #e3f2fd;
|
||||
}
|
||||
|
||||
@@ -3084,7 +3116,7 @@ ul.tabbar li.active > a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
margin-right: 1rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.pinned {
|
||||
@@ -3128,8 +3160,9 @@ ul.tabbar li.active > a {
|
||||
background-color: #eeee;
|
||||
justify-content: space-evenly;
|
||||
height: 100%;
|
||||
padding: 0.1rem;
|
||||
padding: 0.2rem;
|
||||
margin-left: 0.35rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pinned-scroll .dot {
|
||||
@@ -3137,6 +3170,7 @@ ul.tabbar li.active > a {
|
||||
height: 0.3rem;
|
||||
border-radius: 50%;
|
||||
background-color: #999;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pinned-scroll .adot {
|
||||
@@ -3144,14 +3178,9 @@ ul.tabbar li.active > a {
|
||||
height: 0.45rem;
|
||||
border-radius: 50%;
|
||||
background-color: #039be5;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#pinned-wrapper .menuTrigger.upper {
|
||||
top: 0.25rem;
|
||||
}
|
||||
#pinned-wrapper .menuTrigger.lower {
|
||||
bottom: 0.25rem;
|
||||
}
|
||||
#pinned-wrapper:hover .menuTrigger {
|
||||
opacity: 0.9;
|
||||
}
|
||||
@@ -3159,6 +3188,23 @@ ul.tabbar li.active > a {
|
||||
color: #757575;
|
||||
}
|
||||
|
||||
.pinned-menu {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #f8f8f8;
|
||||
border-radius: 0 0.25rem 0.25rem 0;
|
||||
justify-content: space-evenly;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.pinned-menu .menuTrigger {
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/*
|
||||
Drag-and-drop area.
|
||||
*/
|
||||
@@ -3229,78 +3275,51 @@ ul.phone-country-selector span.country {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Video conferencing. */
|
||||
.call-party-speaking {
|
||||
border: 2.5px solid #2196f3;
|
||||
}
|
||||
|
||||
.carousel-item .caller-name.inactive {
|
||||
position: absolute;
|
||||
bottom: 0.25rem;
|
||||
right: 0.25rem;
|
||||
max-width: 6.5rem;
|
||||
}
|
||||
|
||||
.carousel-item .muted {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 0.25rem;
|
||||
}
|
||||
|
||||
.carousel-item .avatar-box {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
div.toast {
|
||||
visibility: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
min-width: 10rem;
|
||||
padding: 0.5rem 1rem;
|
||||
height: 3rem;
|
||||
z-index: 3;
|
||||
font-weight: lighter;
|
||||
color: #eee;
|
||||
background-color: #333;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 1px 2px 0.3rem gray;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
/* Center the avatar in parent view. */
|
||||
transform: translate(-50%, -50%);
|
||||
translate: -50%;
|
||||
}
|
||||
|
||||
#carousel-wrapper {
|
||||
width: 100%;
|
||||
max-width: 54rem;
|
||||
position: relative;
|
||||
align-self: center;
|
||||
padding-top: 0.5rem;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
div.toast.show {
|
||||
visibility: visible;
|
||||
animation: fadein 0.5s, fadeout 0.5s 2.5s;
|
||||
}
|
||||
|
||||
#carousel {
|
||||
overflow-x: scroll;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none; /* Hide scrollbar in Firefox */
|
||||
}
|
||||
#carousel::-webkit-scrollbar {
|
||||
display: none; /* Hide scrollbar in Safari and Chrome */
|
||||
@keyframes fadein {
|
||||
from {
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
bottom: 2rem;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
#carousel-content {
|
||||
display: grid;
|
||||
grid-gap: 1rem;
|
||||
grid-auto-flow: column;
|
||||
}
|
||||
|
||||
#carousel-prev {
|
||||
top: 50%;
|
||||
left: 0;
|
||||
transform: translate(50%, -50%);
|
||||
}
|
||||
|
||||
#carousel-next {
|
||||
top: 50%;
|
||||
right: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.carousel-item {
|
||||
position: relative;
|
||||
width: 8rem;
|
||||
height: 8rem;
|
||||
@keyframes fadeout {
|
||||
from {
|
||||
bottom: 2rem;
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-3
@@ -35,9 +35,7 @@
|
||||
<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@6/react-intl.iife.js"></script>
|
||||
<!-- LiveKit for group video calls -->
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/livekit-client@1/dist/livekit-client.umd.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@7/react-intl.iife.js"></script>
|
||||
<!-- Displaying QR code with ID of the topic -->
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.js"></script>
|
||||
</head>
|
||||
|
||||
+3
-4
@@ -24,10 +24,9 @@
|
||||
<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://polyfill-fastly.io/v3/polyfill.min.js?features=Intl.Segmenter"></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/livekit-client@1.10.0/dist/livekit-client.umd.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@6.4.4/react-intl.iife.min.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react@18.3.1/umd/react.production.min.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@7.1.6/react-intl.iife.min.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+4489
-981
File diff suppressed because it is too large
Load Diff
+14
-13
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tinode-webapp",
|
||||
"description": "Tinode messenger for the web",
|
||||
"version": "0.23.0-rc2",
|
||||
"version": "0.24.0-rc1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/tinode/webapp.git"
|
||||
@@ -18,7 +18,8 @@
|
||||
"i18n:obsolete": "node scripts/i18n.js obsolete",
|
||||
"vers": "node scripts/gen-version.js",
|
||||
"extract": "formatjs extract \"src/**/*.{js,jsx}\" --out-file src/i18n/ex/base-en.json --id-interpolation-pattern '[sha512:contenthash:base64:6]'",
|
||||
"compile": "formatjs compile"
|
||||
"compile": "formatjs compile",
|
||||
"test": "jest"
|
||||
},
|
||||
"browserslist": "> 0.5%, last 2 versions, Firefox ESR, not dead",
|
||||
"files": [
|
||||
@@ -66,14 +67,13 @@
|
||||
"author": "Tinode Authors <info@tinode.co>",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"firebase": "^10.1.0",
|
||||
"firebase": "^11.0.0",
|
||||
"libphonenumber-js": "^1.10.40",
|
||||
"qrcodejs": "^1.0.0",
|
||||
"livekit-client": "^1.10.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-intl": "^6.4.4",
|
||||
"tinode-sdk": "^0.23.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-intl": "^7.0.0",
|
||||
"tinode-sdk": "^0.24.0-rc1",
|
||||
"webm-duration-fix": "^1.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -84,12 +84,13 @@
|
||||
"babel-loader": "^9.1.3",
|
||||
"babel-plugin-formatjs": "^10.5.3",
|
||||
"browserslist": "^4.21.10",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"cssnano": "^6.0.1",
|
||||
"glob": "^10.3.3",
|
||||
"copy-webpack-plugin": "^12.0.0",
|
||||
"cssnano": "^7.0.6",
|
||||
"glob": "^11.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"postcss": "^8.4.27",
|
||||
"postcss-cli": "^10.1.0",
|
||||
"postcss-cli": "^11.0.0",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^5.1.4"
|
||||
"webpack-cli": "^6.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+10
-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.21');
|
||||
export const APP_NAME = 'TinodeWeb/' + (PACKAGE_VERSION || '0.24');
|
||||
|
||||
// API key. Use https://github.com/tinode/chat/tree/master/keygen to generate your own
|
||||
export const API_KEY = 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K';
|
||||
@@ -139,3 +139,12 @@ export const QRCODE_SIZE = 128;
|
||||
export const WAKE_UP_TIMEOUT = 80000;
|
||||
// Timer interval between checks if computer woke up from sleep (1 sec).
|
||||
export const WAKE_UP_TICK = 1000;
|
||||
|
||||
// Swipe distance to register as a gesture, in pixels.
|
||||
export const MIN_SWIPE_DISTANCE = REM_SIZE * 3;
|
||||
|
||||
// Self-topic avatar as an SVG icon encoded as data URI
|
||||
export const SELF_AVATAR_URI = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48c3ZnIHZpZXdCb3g9IjAgMCAxNyAxNyIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMy41NjIgMXYxNS40NTlsNC42ODYtMy4yNyA0Ljc1MiAzLjI2di0xNS40NDloLTkuNDM4ek0xMiAxNC41NTFsLTMuNzU2LTIuNTc4LTMuNjgxIDIuNTY4di0xMi41NDFoNy40Mzd2MTIuNTUxeiIgZmlsbD0iIzU1NTU1NSIgLz48L3N2Zz4=' // eslint-disable-line max-len
|
||||
|
||||
// Toast display duration, milliseconds.
|
||||
export const TOAST_DURATION = 3_000;
|
||||
|
||||
@@ -7,5 +7,3 @@ export const CALL_STATE_IN_PROGRESS = 3;
|
||||
// Video call message headers representing call state.
|
||||
export const CALL_HEAD_STARTED = 'started';
|
||||
|
||||
// Gap between video conference carousel items.
|
||||
export const VC_CAROUSEL_ITEM_GAP = 16;
|
||||
|
||||
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
+77
-14
@@ -1648,13 +1648,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_conference": {
|
||||
"translation": "Telefonkonferenz",
|
||||
"defaultMessage": "Conference call",
|
||||
"description": "Conference call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_success": {
|
||||
"translation": "Passwort erfolgreich zurückgesetzt",
|
||||
"defaultMessage": "Password reset successfully",
|
||||
@@ -1662,18 +1655,88 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"join_conference": {
|
||||
"translation": "+ Mitmachen",
|
||||
"defaultMessage": "+ Join",
|
||||
"description": "Call to action to join a conference call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"scan_qr_code": {
|
||||
"translation": "QR-Code scannen",
|
||||
"defaultMessage": "Scan QR Code",
|
||||
"description": "Title for scanning QR code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_comment": {
|
||||
"translation": "Notizen, Nachrichten, Links, Dateien für die Nachwelt gespeichert",
|
||||
"defaultMessage": "Notes, messages, links, files saved for posterity",
|
||||
"description": "Comment for self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_name": {
|
||||
"translation": "Gespeicherte Nachrichten",
|
||||
"defaultMessage": "Saved messages",
|
||||
"description": "Name of self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_not_found": {
|
||||
"translation": "Nachricht nicht gefunden",
|
||||
"defaultMessage": "message not found",
|
||||
"description": "Pinned message is not found",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_deleted": {
|
||||
"translation": "Nachricht gelöscht",
|
||||
"defaultMessage": "message deleted",
|
||||
"description": "Pinned message is deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_editing_placeholder": {
|
||||
"translation": "Alias (optional)",
|
||||
"defaultMessage": "Alias (optional)",
|
||||
"description": "Placeholder for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias": {
|
||||
"translation": "Alias:",
|
||||
"defaultMessage": "Alias:",
|
||||
"description": "Label for user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_already_taken": {
|
||||
"translation": "(bereits vergeben)",
|
||||
"defaultMessage": "(already taken)",
|
||||
"description": "Error message for alias already taken",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_invalid": {
|
||||
"translation": "(ungültig)",
|
||||
"defaultMessage": "(invalid)",
|
||||
"description": "Error message for invalid alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias_edit": {
|
||||
"translation": "Alias",
|
||||
"defaultMessage": "Alias",
|
||||
"description": "Label for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"select_country": {
|
||||
"translation": "Land auswählen",
|
||||
"defaultMessage": "Select country",
|
||||
"description": "Placeholder for the country selector",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"text_copied": {
|
||||
"translation": "In die Zwischenablage kopiert",
|
||||
"defaultMessage": "Copied to clipboard",
|
||||
"description": "Notification that text has been copied to clipboard",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+77
-14
@@ -1648,13 +1648,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_conference": {
|
||||
"translation": "Conference call",
|
||||
"defaultMessage": "Conference call",
|
||||
"description": "Conference call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_success": {
|
||||
"translation": "Password reset successfully",
|
||||
"defaultMessage": "Password reset successfully",
|
||||
@@ -1662,18 +1655,88 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"join_conference": {
|
||||
"translation": "+ Join",
|
||||
"defaultMessage": "+ Join",
|
||||
"description": "Call to action to join a conference call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"scan_qr_code": {
|
||||
"translation": "Scan QR Code",
|
||||
"defaultMessage": "Scan QR Code",
|
||||
"description": "Title for scanning QR code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_comment": {
|
||||
"translation": "Notes, messages, links, files saved for posterity",
|
||||
"defaultMessage": "Notes, messages, links, files saved for posterity",
|
||||
"description": "Comment for self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_name": {
|
||||
"translation": "Saved messages",
|
||||
"defaultMessage": "Saved messages",
|
||||
"description": "Name of self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_not_found": {
|
||||
"translation": "message not found",
|
||||
"defaultMessage": "message not found",
|
||||
"description": "Pinned message is not found",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_deleted": {
|
||||
"translation": "message deleted",
|
||||
"defaultMessage": "message deleted",
|
||||
"description": "Pinned message is deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_editing_placeholder": {
|
||||
"translation": "Alias (optional)",
|
||||
"defaultMessage": "Alias (optional)",
|
||||
"description": "Placeholder for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias": {
|
||||
"translation": "Alias:",
|
||||
"defaultMessage": "Alias:",
|
||||
"description": "Label for user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_already_taken": {
|
||||
"translation": "(already taken)",
|
||||
"defaultMessage": "(already taken)",
|
||||
"description": "Error message for alias already taken",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_invalid": {
|
||||
"translation": "(invalid)",
|
||||
"defaultMessage": "(invalid)",
|
||||
"description": "Error message for invalid alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias_edit": {
|
||||
"translation": "Alias",
|
||||
"defaultMessage": "Alias",
|
||||
"description": "Label for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"select_country": {
|
||||
"translation": "Select country",
|
||||
"defaultMessage": "Select country",
|
||||
"description": "Placeholder for the country selector",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"text_copied": {
|
||||
"translation": "Copied to clipboard",
|
||||
"defaultMessage": "Copied to clipboard",
|
||||
"description": "Notification that text has been copied to clipboard",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+77
-14
@@ -1648,13 +1648,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_conference": {
|
||||
"translation": "Conferencia",
|
||||
"defaultMessage": "Conference call",
|
||||
"description": "Conference call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_success": {
|
||||
"translation": "Restablecimiento de contraseña con éxito",
|
||||
"defaultMessage": "Password reset successfully",
|
||||
@@ -1662,18 +1655,88 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"join_conference": {
|
||||
"translation": "+ Unirse",
|
||||
"defaultMessage": "+ Join",
|
||||
"description": "Call to action to join a conference call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"scan_qr_code": {
|
||||
"translation": "Escanear código QR",
|
||||
"defaultMessage": "Scan QR Code",
|
||||
"description": "Title for scanning QR code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_comment": {
|
||||
"translation": "Notas, mensajes, enlaces, archivos guardados para la posteridad",
|
||||
"defaultMessage": "Notes, messages, links, files saved for posterity",
|
||||
"description": "Comment for self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_name": {
|
||||
"translation": "Mensajes guardados",
|
||||
"defaultMessage": "Saved messages",
|
||||
"description": "Name of self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_not_found": {
|
||||
"translation": "Mensaje no encontrado",
|
||||
"defaultMessage": "message not found",
|
||||
"description": "Pinned message is not found",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_deleted": {
|
||||
"translation": "Mensaje eliminado",
|
||||
"defaultMessage": "message deleted",
|
||||
"description": "Pinned message is deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_editing_placeholder": {
|
||||
"translation": "Alias (opcional)",
|
||||
"defaultMessage": "Alias (optional)",
|
||||
"description": "Placeholder for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias": {
|
||||
"translation": "Alias:",
|
||||
"defaultMessage": "Alias:",
|
||||
"description": "Label for user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_already_taken": {
|
||||
"translation": "(ya tomado)",
|
||||
"defaultMessage": "(already taken)",
|
||||
"description": "Error message for alias already taken",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_invalid": {
|
||||
"translation": "(inválido)",
|
||||
"defaultMessage": "(invalid)",
|
||||
"description": "Error message for invalid alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias_edit": {
|
||||
"translation": "Alias",
|
||||
"defaultMessage": "Alias",
|
||||
"description": "Label for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"select_country": {
|
||||
"translation": "Seleccionar país",
|
||||
"defaultMessage": "Select country",
|
||||
"description": "Placeholder for the country selector",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"text_copied": {
|
||||
"translation": "Copiado al portapapeles",
|
||||
"defaultMessage": "Copied to clipboard",
|
||||
"description": "Notification that text has been copied to clipboard",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,18 @@
|
||||
"defaultMessage": "Report Conversation",
|
||||
"description": "Flat button [Report Group]"
|
||||
},
|
||||
"alias_already_taken": {
|
||||
"defaultMessage": "(already taken)",
|
||||
"description": "Error message for alias already taken"
|
||||
},
|
||||
"alias_editing_placeholder": {
|
||||
"defaultMessage": "Alias (optional)",
|
||||
"description": "Placeholder for editing user or topic alias"
|
||||
},
|
||||
"alias_invalid": {
|
||||
"defaultMessage": "(invalid)",
|
||||
"description": "Error message for invalid alias"
|
||||
},
|
||||
"already_in_call": {
|
||||
"defaultMessage": "You already in an ongoing call!",
|
||||
"description": "Error message when the user tried to accept a new call without finishing pervious one"
|
||||
@@ -143,10 +155,6 @@
|
||||
"defaultMessage": "missed",
|
||||
"description": "Label for missed incoming call"
|
||||
},
|
||||
"calls_conference": {
|
||||
"defaultMessage": "Conference call",
|
||||
"description": "Conference call label"
|
||||
},
|
||||
"calls_incoming": {
|
||||
"defaultMessage": "Incoming call",
|
||||
"description": "Incoming call label"
|
||||
@@ -374,9 +382,13 @@
|
||||
"defaultMessage": "Invalid security token",
|
||||
"description": "Error message when resetting password."
|
||||
},
|
||||
"join_conference": {
|
||||
"defaultMessage": "+ Join",
|
||||
"description": "Call to action to join a conference call"
|
||||
"label_alias": {
|
||||
"defaultMessage": "Alias:",
|
||||
"description": "Label for user or topic alias"
|
||||
},
|
||||
"label_alias_edit": {
|
||||
"defaultMessage": "Alias",
|
||||
"description": "Label for editing user or topic alias"
|
||||
},
|
||||
"label_client": {
|
||||
"defaultMessage": "Client:",
|
||||
@@ -612,10 +624,18 @@
|
||||
"defaultMessage": "Video call",
|
||||
"description": "Start video call"
|
||||
},
|
||||
"message_deleted": {
|
||||
"defaultMessage": "message deleted",
|
||||
"description": "Pinned message is deleted"
|
||||
},
|
||||
"message_edited_marker": {
|
||||
"defaultMessage": ", edited",
|
||||
"description": "Marker indicating that the message was edited"
|
||||
},
|
||||
"message_not_found": {
|
||||
"defaultMessage": "message not found",
|
||||
"description": "Pinned message is not found"
|
||||
},
|
||||
"message_sending": {
|
||||
"defaultMessage": "sending...",
|
||||
"description": "Message being sent, in place of time stamp"
|
||||
@@ -824,6 +844,18 @@
|
||||
"defaultMessage": "List like alice@example.com, +17025550003...",
|
||||
"description": "Placeholder in contacts search field"
|
||||
},
|
||||
"select_country": {
|
||||
"defaultMessage": "Select country",
|
||||
"description": "Placeholder for the country selector"
|
||||
},
|
||||
"self_topic_comment": {
|
||||
"defaultMessage": "Notes, messages, links, files saved for posterity",
|
||||
"description": "Comment for self topic for UI"
|
||||
},
|
||||
"self_topic_name": {
|
||||
"defaultMessage": "Saved messages",
|
||||
"description": "Name of self topic for UI"
|
||||
},
|
||||
"sidepanel_title_acc_notifications": {
|
||||
"defaultMessage": "Notifications",
|
||||
"description": "Sidepanel title for AccNotificationsView."
|
||||
@@ -892,6 +924,10 @@
|
||||
"defaultMessage": "No tags defined. Add some.",
|
||||
"description": ""
|
||||
},
|
||||
"text_copied": {
|
||||
"defaultMessage": "Copied to clipboard",
|
||||
"description": "Notification that text has been copied to clipboard"
|
||||
},
|
||||
"title_all_contacts": {
|
||||
"defaultMessage": "All Contacts",
|
||||
"description": "Section title [All Contacts]"
|
||||
|
||||
+77
-14
@@ -1648,13 +1648,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_conference": {
|
||||
"translation": "Conférence téléphonique",
|
||||
"defaultMessage": "Conference call",
|
||||
"description": "Conference call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_success": {
|
||||
"translation": "Mot de passe réinitialisé avec succès",
|
||||
"defaultMessage": "Password reset successfully",
|
||||
@@ -1662,18 +1655,88 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"join_conference": {
|
||||
"translation": "+ Rejoindre",
|
||||
"defaultMessage": "+ Join",
|
||||
"description": "Call to action to join a conference call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"scan_qr_code": {
|
||||
"translation": "Scanner le code QR",
|
||||
"defaultMessage": "Scan QR Code",
|
||||
"description": "Title for scanning QR code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_comment": {
|
||||
"translation": "Notes, messages, liens, fichiers sauvegardés",
|
||||
"defaultMessage": "Notes, messages, links, files saved for posterity",
|
||||
"description": "Comment for self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_name": {
|
||||
"translation": "Messages sauvegardés",
|
||||
"defaultMessage": "Saved messages",
|
||||
"description": "Name of self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_not_found": {
|
||||
"translation": "message introuvable",
|
||||
"defaultMessage": "message not found",
|
||||
"description": "Pinned message is not found",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_deleted": {
|
||||
"translation": "message supprimé",
|
||||
"defaultMessage": "message deleted",
|
||||
"description": "Pinned message is deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_editing_placeholder": {
|
||||
"translation": "Alias (facultatif)",
|
||||
"defaultMessage": "Alias (optional)",
|
||||
"description": "Placeholder for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias": {
|
||||
"translation": "Alias:",
|
||||
"defaultMessage": "Alias:",
|
||||
"description": "Label for user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_already_taken": {
|
||||
"translation": "(déjà utilisé)",
|
||||
"defaultMessage": "(already taken)",
|
||||
"description": "Error message for alias already taken",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_invalid": {
|
||||
"translation": "(invalide)",
|
||||
"defaultMessage": "(invalid)",
|
||||
"description": "Error message for invalid alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias_edit": {
|
||||
"translation": "Alias",
|
||||
"defaultMessage": "Alias",
|
||||
"description": "Label for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"select_country": {
|
||||
"translation": "Sélectionner un pays",
|
||||
"defaultMessage": "Select country",
|
||||
"description": "Placeholder for the country selector",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"text_copied": {
|
||||
"translation": "Copié dans le presse-papiers",
|
||||
"defaultMessage": "Copied to clipboard",
|
||||
"description": "Notification that text has been copied to clipboard",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+77
-14
@@ -1648,13 +1648,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_conference": {
|
||||
"translation": "전화 회의",
|
||||
"defaultMessage": "Conference call",
|
||||
"description": "Conference call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_success": {
|
||||
"translation": "비밀번호 재설정 성공",
|
||||
"defaultMessage": "Password reset successfully",
|
||||
@@ -1662,18 +1655,88 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"join_conference": {
|
||||
"translation": "+ 통화에 참여",
|
||||
"defaultMessage": "+ Join",
|
||||
"description": "Call to action to join a conference call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"scan_qr_code": {
|
||||
"translation": "QR 코드 스캔",
|
||||
"defaultMessage": "Scan QR Code",
|
||||
"description": "Title for scanning QR code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_comment": {
|
||||
"translation": "후세를 위해 저장된 메모, 메시지, 링크, 파일",
|
||||
"defaultMessage": "Notes, messages, links, files saved for posterity",
|
||||
"description": "Comment for self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_name": {
|
||||
"translation": "저장된 메시지",
|
||||
"defaultMessage": "Saved messages",
|
||||
"description": "Name of self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_not_found": {
|
||||
"translation": "메시지를 찾을 수 없음",
|
||||
"defaultMessage": "message not found",
|
||||
"description": "Pinned message is not found",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_deleted": {
|
||||
"translation": "메시지 삭제됨",
|
||||
"defaultMessage": "message deleted",
|
||||
"description": "Pinned message is deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_editing_placeholder": {
|
||||
"translation": "별칭(선택 사항)",
|
||||
"defaultMessage": "Alias (optional)",
|
||||
"description": "Placeholder for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias": {
|
||||
"translation": "별칭:",
|
||||
"defaultMessage": "Alias:",
|
||||
"description": "Label for user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_already_taken": {
|
||||
"translation": "(이미 취함)",
|
||||
"defaultMessage": "(already taken)",
|
||||
"description": "Error message for alias already taken",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_invalid": {
|
||||
"translation": "(잘못된)",
|
||||
"defaultMessage": "(invalid)",
|
||||
"description": "Error message for invalid alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias_edit": {
|
||||
"translation": "별칭",
|
||||
"defaultMessage": "Alias",
|
||||
"description": "Label for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"select_country": {
|
||||
"translation": "국가 선택",
|
||||
"defaultMessage": "Select country",
|
||||
"description": "Placeholder for the country selector",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"text_copied": {
|
||||
"translation": "클립보드에 복사됨",
|
||||
"defaultMessage": "Copied to clipboard",
|
||||
"description": "Notification that text has been copied to clipboard",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+77
-14
@@ -1648,13 +1648,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_conference": {
|
||||
"translation": "Apel de conferinta",
|
||||
"defaultMessage": "Conference call",
|
||||
"description": "Conference call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_success": {
|
||||
"translation": "Resetarea parolei cu succes",
|
||||
"defaultMessage": "Password reset successfully",
|
||||
@@ -1662,18 +1655,88 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"join_conference": {
|
||||
"translation": "+ Participa",
|
||||
"defaultMessage": "+ Join",
|
||||
"description": "Call to action to join a conference call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"scan_qr_code": {
|
||||
"translation": "Scanați codul QR",
|
||||
"defaultMessage": "Scan QR Code",
|
||||
"description": "Title for scanning QR code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_comment": {
|
||||
"translation": "Note, mesaje, linkuri, fișiere salvate pentru posteritate",
|
||||
"defaultMessage": "Notes, messages, links, files saved for posterity",
|
||||
"description": "Comment for self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_name": {
|
||||
"translation": "Mesaje salvate",
|
||||
"defaultMessage": "Saved messages",
|
||||
"description": "Name of self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_not_found": {
|
||||
"translation": "mesajul nu a fost găsit",
|
||||
"defaultMessage": "message not found",
|
||||
"description": "Pinned message is not found",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_deleted": {
|
||||
"translation": "mesaj șters",
|
||||
"defaultMessage": "message deleted",
|
||||
"description": "Pinned message is deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_editing_placeholder": {
|
||||
"translation": "Alias (opțional)",
|
||||
"defaultMessage": "Alias (optional)",
|
||||
"description": "Placeholder for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias": {
|
||||
"translation": "Alias:",
|
||||
"defaultMessage": "Alias:",
|
||||
"description": "Label for user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_already_taken": {
|
||||
"translation": "(deja luat)",
|
||||
"defaultMessage": "(already taken)",
|
||||
"description": "Error message for alias already taken",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_invalid": {
|
||||
"translation": "(nevalid)",
|
||||
"defaultMessage": "(invalid)",
|
||||
"description": "Error message for invalid alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias_edit": {
|
||||
"translation": "Alias",
|
||||
"defaultMessage": "Alias",
|
||||
"description": "Label for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"select_country": {
|
||||
"translation": "Selectați țara",
|
||||
"defaultMessage": "Select country",
|
||||
"description": "Placeholder for the country selector",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"text_copied": {
|
||||
"translation": "Copiat în clipboard",
|
||||
"defaultMessage": "Copied to clipboard",
|
||||
"description": "Notification that text has been copied to clipboard",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+77
-14
@@ -1648,13 +1648,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_conference": {
|
||||
"translation": "Групповой звонок",
|
||||
"defaultMessage": "Conference call",
|
||||
"description": "Conference call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_success": {
|
||||
"translation": "Пароль успешно изменен",
|
||||
"defaultMessage": "Password reset successfully",
|
||||
@@ -1662,18 +1655,88 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"join_conference": {
|
||||
"translation": "+ Присоединиться",
|
||||
"defaultMessage": "+ Join",
|
||||
"description": "Call to action to join a conference call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"scan_qr_code": {
|
||||
"translation": "Отсканируйте QR код",
|
||||
"defaultMessage": "Scan QR Code",
|
||||
"description": "Title for scanning QR code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_comment": {
|
||||
"translation": "Заметки, сообщения, ссылки, файлы, сохраненные для потомков",
|
||||
"defaultMessage": "Notes, messages, links, files saved for posterity",
|
||||
"description": "Comment for self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_name": {
|
||||
"translation": "Сохраненные сообщения",
|
||||
"defaultMessage": "Saved messages",
|
||||
"description": "Name of self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_not_found": {
|
||||
"translation": "сообщение не найдено",
|
||||
"defaultMessage": "message not found",
|
||||
"description": "Pinned message is not found",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_deleted": {
|
||||
"translation": "сообщение удалено",
|
||||
"defaultMessage": "message deleted",
|
||||
"description": "Pinned message is deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_editing_placeholder": {
|
||||
"translation": "Псевдоним (необязательно)",
|
||||
"defaultMessage": "Alias (optional)",
|
||||
"description": "Placeholder for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias": {
|
||||
"translation": "Псевдоним:",
|
||||
"defaultMessage": "Alias:",
|
||||
"description": "Label for user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_already_taken": {
|
||||
"translation": "(уже занято)",
|
||||
"defaultMessage": "(already taken)",
|
||||
"description": "Error message for alias already taken",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_invalid": {
|
||||
"translation": "(некорректно)",
|
||||
"defaultMessage": "(invalid)",
|
||||
"description": "Error message for invalid alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias_edit": {
|
||||
"translation": "Псевдоним",
|
||||
"defaultMessage": "Alias",
|
||||
"description": "Label for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"select_country": {
|
||||
"translation": "Выберите страну",
|
||||
"defaultMessage": "Select country",
|
||||
"description": "Placeholder for the country selector",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"text_copied": {
|
||||
"translation": "Скопировано в буфер обмена",
|
||||
"defaultMessage": "Copied to clipboard",
|
||||
"description": "Notification that text has been copied to clipboard",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+81
-18
@@ -1648,32 +1648,95 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_conference": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Conference call",
|
||||
"description": "Conference call label",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"join_conference": {
|
||||
"translation": "",
|
||||
"defaultMessage": "+ Join",
|
||||
"description": "Call to action to join a conference call",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"pin_message": {
|
||||
"translation": "",
|
||||
"translation": "พิน",
|
||||
"defaultMessage": "Pin",
|
||||
"description": "Menu item [Pin] for pinning message to the top.",
|
||||
"missing": true,
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unpin_message": {
|
||||
"translation": "",
|
||||
"translation": "ปลดหมุด",
|
||||
"defaultMessage": "Unpin",
|
||||
"description": "Menu item [Unpin] for un-pinning the message.",
|
||||
"missing": true,
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_comment": {
|
||||
"translation": "บันทึก ข้อความ ลิงก์ ไฟล์ที่บันทึกไว้เพื่ออนาคต",
|
||||
"defaultMessage": "Notes, messages, links, files saved for posterity",
|
||||
"description": "Comment for self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_name": {
|
||||
"translation": "ข้อความที่บันทึกไว้",
|
||||
"defaultMessage": "Saved messages",
|
||||
"description": "Name of self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_not_found": {
|
||||
"translation": "ไม่พบข้อความ",
|
||||
"defaultMessage": "message not found",
|
||||
"description": "Pinned message is not found",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_deleted": {
|
||||
"translation": "ข้อความถูกลบ",
|
||||
"defaultMessage": "message deleted",
|
||||
"description": "Pinned message is deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_editing_placeholder": {
|
||||
"translation": "นามแฝง (ไม่บังคับ)",
|
||||
"defaultMessage": "Alias (optional)",
|
||||
"description": "Placeholder for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias": {
|
||||
"translation": "นามแฝง:",
|
||||
"defaultMessage": "Alias:",
|
||||
"description": "Label for user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_already_taken": {
|
||||
"translation": "(ใช้ไปแล้ว)",
|
||||
"defaultMessage": "(already taken)",
|
||||
"description": "Error message for alias already taken",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_invalid": {
|
||||
"translation": "(ไม่ถูกต้อง)",
|
||||
"defaultMessage": "(invalid)",
|
||||
"description": "Error message for invalid alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias_edit": {
|
||||
"translation": "นามแฝง",
|
||||
"defaultMessage": "Alias",
|
||||
"description": "Label for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"select_country": {
|
||||
"translation": "เลือกประเทศ",
|
||||
"defaultMessage": "Select country",
|
||||
"description": "Placeholder for the country selector",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"text_copied": {
|
||||
"translation": "คัดลอกไปยังคลิปบอร์ดแล้ว",
|
||||
"defaultMessage": "Copied to clipboard",
|
||||
"description": "Notification that text has been copied to clipboard",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+81
-18
@@ -1648,32 +1648,95 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_conference": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Conference call",
|
||||
"description": "Conference call label",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"join_conference": {
|
||||
"translation": "",
|
||||
"defaultMessage": "+ Join",
|
||||
"description": "Call to action to join a conference call",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
},
|
||||
"pin_message": {
|
||||
"translation": "",
|
||||
"translation": "закріпити",
|
||||
"defaultMessage": "Pin",
|
||||
"description": "Menu item [Pin] for pinning message to the top.",
|
||||
"missing": true,
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unpin_message": {
|
||||
"translation": "",
|
||||
"translation": "відкріпити",
|
||||
"defaultMessage": "Unpin",
|
||||
"description": "Menu item [Unpin] for un-pinning the message.",
|
||||
"missing": true,
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_comment": {
|
||||
"translation": "Нотатки, повідомлення, посилання, файли збережені для нащадків",
|
||||
"defaultMessage": "Notes, messages, links, files saved for posterity",
|
||||
"description": "Comment for self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_name": {
|
||||
"translation": "Збережені повідомлення",
|
||||
"defaultMessage": "Saved messages",
|
||||
"description": "Name of self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_not_found": {
|
||||
"translation": "повідомлення не знайдено",
|
||||
"defaultMessage": "message not found",
|
||||
"description": "Pinned message is not found",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_deleted": {
|
||||
"translation": "повідомлення видалено",
|
||||
"defaultMessage": "message deleted",
|
||||
"description": "Pinned message is deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_editing_placeholder": {
|
||||
"translation": "Псевдонім (необов'язково)",
|
||||
"defaultMessage": "Alias (optional)",
|
||||
"description": "Placeholder for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias": {
|
||||
"translation": "Псевдонім:",
|
||||
"defaultMessage": "Alias:",
|
||||
"description": "Label for user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_already_taken": {
|
||||
"translation": "(вже зайнято)",
|
||||
"defaultMessage": "(already taken)",
|
||||
"description": "Error message for alias already taken",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_invalid": {
|
||||
"translation": "(недійсний)",
|
||||
"defaultMessage": "(invalid)",
|
||||
"description": "Error message for invalid alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias_edit": {
|
||||
"translation": "Псевдонім",
|
||||
"defaultMessage": "Alias",
|
||||
"description": "Label for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"select_country": {
|
||||
"translation": "Виберіть країну",
|
||||
"defaultMessage": "Select country",
|
||||
"description": "Placeholder for the country selector",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"text_copied": {
|
||||
"translation": "Скопійовано в буфер обміну",
|
||||
"defaultMessage": "Copied to clipboard",
|
||||
"description": "Notification that text has been copied to clipboard",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+77
-14
@@ -1648,13 +1648,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_conference": {
|
||||
"translation": "電話會議",
|
||||
"defaultMessage": "Conference call",
|
||||
"description": "Conference call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_success": {
|
||||
"translation": "密碼重置成功",
|
||||
"defaultMessage": "Password reset successfully",
|
||||
@@ -1662,18 +1655,88 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"join_conference": {
|
||||
"translation": "+ 加入通話",
|
||||
"defaultMessage": "+ Join",
|
||||
"description": "Call to action to join a conference call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"scan_qr_code": {
|
||||
"translation": "掃描二維碼",
|
||||
"defaultMessage": "Scan QR Code",
|
||||
"description": "Title for scanning QR code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_comment": {
|
||||
"translation": "為後代所儲存的註解、訊息、連結、檔案",
|
||||
"defaultMessage": "Notes, messages, links, files saved for posterity",
|
||||
"description": "Comment for self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_name": {
|
||||
"translation": "已儲存的訊息",
|
||||
"defaultMessage": "Saved messages",
|
||||
"description": "Name of self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_not_found": {
|
||||
"translation": "未找到訊息",
|
||||
"defaultMessage": "message not found",
|
||||
"description": "Pinned message is not found",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_deleted": {
|
||||
"translation": "訊息已刪除",
|
||||
"defaultMessage": "message deleted",
|
||||
"description": "Pinned message is deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_editing_placeholder": {
|
||||
"translation": "別名(可選)",
|
||||
"defaultMessage": "Alias (optional)",
|
||||
"description": "Placeholder for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias": {
|
||||
"translation": "別名:",
|
||||
"defaultMessage": "Alias:",
|
||||
"description": "Label for user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_already_taken": {
|
||||
"translation": "(已採取)",
|
||||
"defaultMessage": "(already taken)",
|
||||
"description": "Error message for alias already taken",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_invalid": {
|
||||
"translation": "(無效)",
|
||||
"defaultMessage": "(invalid)",
|
||||
"description": "Error message for invalid alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias_edit": {
|
||||
"translation": "別名",
|
||||
"defaultMessage": "Alias",
|
||||
"description": "Label for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"select_country": {
|
||||
"translation": "選擇國家",
|
||||
"defaultMessage": "Select country",
|
||||
"description": "Placeholder for the country selector",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"text_copied": {
|
||||
"translation": "已複製到剪貼簿",
|
||||
"defaultMessage": "Copied to clipboard",
|
||||
"description": "Notification that text has been copied to clipboard",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+77
-14
@@ -1648,13 +1648,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_conference": {
|
||||
"translation": "电话会议",
|
||||
"defaultMessage": "Conference call",
|
||||
"description": "Conference call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_success": {
|
||||
"translation": "密码重置成功",
|
||||
"defaultMessage": "Password reset successfully",
|
||||
@@ -1662,18 +1655,88 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"join_conference": {
|
||||
"translation": "+ 加入通话",
|
||||
"defaultMessage": "+ Join",
|
||||
"description": "Call to action to join a conference call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"scan_qr_code": {
|
||||
"translation": "扫描二维码",
|
||||
"defaultMessage": "Scan QR Code",
|
||||
"description": "Title for scanning QR code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_comment": {
|
||||
"translation": "为后人保存的注释、消息、链接、文件",
|
||||
"defaultMessage": "Notes, messages, links, files saved for posterity",
|
||||
"description": "Comment for self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_name": {
|
||||
"translation": "已保存的消息",
|
||||
"defaultMessage": "Saved messages",
|
||||
"description": "Name of self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_not_found": {
|
||||
"translation": "未找到消息",
|
||||
"defaultMessage": "message not found",
|
||||
"description": "Pinned message is not found",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_deleted": {
|
||||
"translation": "消息已删除",
|
||||
"defaultMessage": "message deleted",
|
||||
"description": "Pinned message is deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_editing_placeholder": {
|
||||
"translation": "别名(可选)",
|
||||
"defaultMessage": "Alias (optional)",
|
||||
"description": "Placeholder for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias": {
|
||||
"translation": "别名:",
|
||||
"defaultMessage": "Alias:",
|
||||
"description": "Label for user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_already_taken": {
|
||||
"translation": "(已占用)",
|
||||
"defaultMessage": "(already taken)",
|
||||
"description": "Error message for alias already taken",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_invalid": {
|
||||
"translation": "(无效)",
|
||||
"defaultMessage": "(invalid)",
|
||||
"description": "Error message for invalid alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias_edit": {
|
||||
"translation": "别名",
|
||||
"defaultMessage": "Alias",
|
||||
"description": "Label for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"select_country": {
|
||||
"translation": "选择国家",
|
||||
"defaultMessage": "Select country",
|
||||
"description": "Placeholder for the country selector",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"text_copied": {
|
||||
"translation": "已复制到剪贴板",
|
||||
"defaultMessage": "Copied to clipboard",
|
||||
"description": "Notification that text has been copied to clipboard",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,8 +143,6 @@ export function fullFormatter(style, data, values, key, stack) {
|
||||
attr.callState = data.state;
|
||||
attr.incoming = data.incoming;
|
||||
attr.duration = data.duration;
|
||||
attr.vc = data.vc;
|
||||
attr.onCallJoin = this.onCallJoin;
|
||||
}
|
||||
break;
|
||||
case 'VD':
|
||||
@@ -210,7 +208,7 @@ function handleImageData(el, data, attr) {
|
||||
attr.src = null;
|
||||
}
|
||||
} else {
|
||||
// Use custom element instead of <img>.
|
||||
// Use custom element instead of <img> or <video>.
|
||||
el = UploadingImage;
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -89,7 +89,7 @@ export function arrayEqual(a, b) {
|
||||
export function asPhone(val) {
|
||||
val = val.trim();
|
||||
if (/^(?:\+?(\d{1,3}))?[- (.]*(\d{3})[- ).]*(\d{3})[- .]*(\d{2})[- .]*(\d{2})?$/.test(val)) {
|
||||
return val.replace(/[- ().]*/, '');
|
||||
return val.replaceAll(/[- ().]*/g, '');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -97,7 +97,7 @@ export function asPhone(val) {
|
||||
// Checks (loosely) if the given string is an email. If so returns the email.
|
||||
export function asEmail(val) {
|
||||
val = val.trim();
|
||||
if (/^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/.test(val)) {
|
||||
if (/^[a-z0-9_.+-]+@[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(val)) {
|
||||
return val;
|
||||
}
|
||||
return null;
|
||||
@@ -164,7 +164,7 @@ export function sanitizeUrlForMime(url, mimeMajor) {
|
||||
}
|
||||
|
||||
// Is this a data: URL of the appropriate mime type?
|
||||
const re = new RegExp(`data:${mimeMajor}\/[a-z0-9.-]+;base64,`, 'i');
|
||||
const re = new RegExp(`data:${mimeMajor}\/[-+.a-z0-9]+;base64,`, 'i');
|
||||
if (re.test(url.trim())) {
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
arrayEqual,
|
||||
asEmail,
|
||||
asPhone,
|
||||
isUrlRelative,
|
||||
sanitizeUrl,
|
||||
sanitizeUrlForMime,
|
||||
urlAsAttachment
|
||||
} from './utils';
|
||||
|
||||
test('asPhone', () => {
|
||||
expect(asPhone('123-456-7890')).toBe('1234567890');
|
||||
expect(asPhone('(123) 456-7890')).toBe('1234567890');
|
||||
expect(asPhone('+1 (123) 456-7890')).toBe('+11234567890');
|
||||
expect(asPhone('123.456.7890')).toBe('1234567890');
|
||||
expect(asPhone('123 456 7890')).toBe('1234567890');
|
||||
expect(asPhone('123-456-78901')).toBe('12345678901');
|
||||
expect(asPhone('')).toBeNull();
|
||||
expect(asPhone('abc-def-ghij')).toBeNull();
|
||||
expect(asPhone('123-456-789')).toBeNull();
|
||||
expect(asPhone('123-4i6-78901')).toBeNull();
|
||||
});
|
||||
|
||||
test('arrayEqual', () => {
|
||||
expect(arrayEqual([1, 2, 3], [3, 2, 1])).toBe(true);
|
||||
expect(arrayEqual([1, 2, 3], [4, 5, 6])).toBe(false);
|
||||
expect(arrayEqual([1, 2, 3], [1, 2, 3, 4])).toBe(false);
|
||||
expect(arrayEqual([1, 2, 3], [1, 2])).toBe(false);
|
||||
expect(arrayEqual([], [])).toBe(true);
|
||||
expect(arrayEqual([1], [1])).toBe(true);
|
||||
expect(arrayEqual([1], [2])).toBe(false);
|
||||
expect(arrayEqual(null, [1, 2, 3])).toBe(false);
|
||||
expect(arrayEqual([1, 2, 3], null)).toBe(false);
|
||||
expect(arrayEqual(null, null)).toBe(true);
|
||||
expect(arrayEqual(undefined, undefined)).toBe(true);
|
||||
expect(arrayEqual([1, 2, 3], undefined)).toBe(false);
|
||||
expect(arrayEqual([1, 2, 3], [1, 2, '3'])).toBe(false);
|
||||
});
|
||||
|
||||
test('asEmail', () => {
|
||||
expect(asEmail('test@example.com')).toBe('test@example.com');
|
||||
expect(asEmail('test@mail.example.com')).toBe('test@mail.example.com');
|
||||
expect(asEmail('test@e-mail.example.com')).toBe('test@e-mail.example.com');
|
||||
expect(asEmail(' test@example.com ')).toBe('test@example.com');
|
||||
expect(asEmail('test.email+alex@leetcode.com')).toBe('test.email+alex@leetcode.com');
|
||||
expect(asEmail('test.email@sub.example.com')).toBe('test.email@sub.example.com');
|
||||
expect(asEmail('test.email@sub.example.co.uk')).toBe('test.email@sub.example.co.uk');
|
||||
expect(asEmail('invalid-email')).toBeNull();
|
||||
expect(asEmail('invalid-email@')).toBeNull();
|
||||
expect(asEmail('invalid-email@example')).toBeNull();
|
||||
expect(asEmail('invalid-email@example.')).toBeNull();
|
||||
expect(asEmail('invalid-email@example..com')).toBeNull();
|
||||
expect(asEmail('invalid-email@.example.com')).toBeNull();
|
||||
expect(asEmail('invalid-email@example.com.')).toBeNull();
|
||||
expect(asEmail('invalid-email@@example.com.')).toBeNull();
|
||||
expect(asEmail('invalid/email@example.com.')).toBeNull();
|
||||
});
|
||||
|
||||
test('isUrlRelative', () => {
|
||||
expect(isUrlRelative('example.html')).toBe(true);
|
||||
expect(isUrlRelative('https:example.com')).toBe(false);
|
||||
expect(isUrlRelative('http:/example.com')).toBe(false);
|
||||
expect(isUrlRelative(' \n https://example.com')).toBe(false);
|
||||
expect(isUrlRelative('//example.com')).toBe(false);
|
||||
expect(isUrlRelative('\\example.com')).toBe(true);
|
||||
expect(isUrlRelative('example.com')).toBe(true);
|
||||
expect(isUrlRelative('/example')).toBe(true);
|
||||
expect(isUrlRelative('ftp://example.com')).toBe(false);
|
||||
expect(isUrlRelative('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA')).toBe(false);
|
||||
});
|
||||
|
||||
test('sanitizeUrl', () => {
|
||||
// Test with valid URLs
|
||||
expect(sanitizeUrl('http://example.com')).toBe('http://example.com');
|
||||
expect(sanitizeUrl('https://example.com')).toBe('https://example.com');
|
||||
expect(sanitizeUrl('blob:http://example.com')).toBe('blob:http://example.com');
|
||||
expect(sanitizeUrl('example.html')).toBe('example.html');
|
||||
|
||||
// Test with invalid URLs
|
||||
expect(sanitizeUrl('ftp://example.com')).toBeNull();
|
||||
expect(sanitizeUrl('javascript:alert(1)')).toBeNull();
|
||||
expect(sanitizeUrl('data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==')).toBeNull();
|
||||
|
||||
// Test with allowed schemes
|
||||
expect(sanitizeUrl('ftp://example.com', ['ftp'])).toBe('ftp://example.com');
|
||||
expect(sanitizeUrl('ftps://example.com', ['ftp', 'ftps'])).toBe('ftps://example.com');
|
||||
|
||||
// Test with non-string input
|
||||
expect(sanitizeUrl(null)).toBe(null);
|
||||
expect(sanitizeUrl(123)).toBe(123);
|
||||
|
||||
// Test with control characters and whitespace
|
||||
expect(sanitizeUrl(' \n https://example.com')).toBe('https://example.com');
|
||||
expect(sanitizeUrl('http:\\\\example.com')).toBe('http://example.com');
|
||||
});
|
||||
|
||||
test('sanitizeUrlForMime', () => {
|
||||
expect(sanitizeUrlForMime('data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==', 'image')).toBeNull();
|
||||
|
||||
// Test with empty and null URLs
|
||||
expect(sanitizeUrlForMime('', 'image')).toBeNull();
|
||||
expect(sanitizeUrlForMime(null, 'image')).toBeNull();
|
||||
|
||||
// Test with valid data URL but wrong mime type
|
||||
expect(sanitizeUrlForMime('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA', 'text')).toBeNull();
|
||||
});
|
||||
|
||||
test('urlAsAttachment', () => {
|
||||
// Test with a simple URL
|
||||
expect(urlAsAttachment('http://example.com')).toBe('http://example.com?asatt=1');
|
||||
|
||||
// Test with a URL that already has a query parameter
|
||||
expect(urlAsAttachment('http://example.com?param=value')).toBe('http://example.com?param=value&asatt=1');
|
||||
|
||||
// Test with a URL that has a fragment
|
||||
expect(urlAsAttachment('http://example.com#fragment')).toBe('http://example.com?asatt=1#fragment');
|
||||
|
||||
// Test with a URL that has both query parameters and a fragment
|
||||
expect(urlAsAttachment('http://example.com?param=value#fragment')).toBe('http://example.com?param=value&asatt=1#fragment');
|
||||
|
||||
// Test with a data URL
|
||||
expect(urlAsAttachment('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA')).toBe('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA');
|
||||
|
||||
// Test with a blob URL
|
||||
expect(urlAsAttachment('blob:http://example.com/550e8400-e29b-41d4-a716-446655440000')).toBe('blob:http://example.com/550e8400-e29b-41d4-a716-446655440000');
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
// This is a generated file. Don't edit.
|
||||
|
||||
export const PACKAGE_VERSION = "0.23.0-rc2";
|
||||
export const PACKAGE_VERSION = "0.24.0-rc1";
|
||||
|
||||
@@ -24,7 +24,8 @@ export default class AccountSettingsView extends React.Component {
|
||||
description: _clip(me.public ? me.public.note : undefined, MAX_TOPIC_DESCRIPTION_LENGTH),
|
||||
avatar: makeImageUrl(me.public ? me.public.photo : null),
|
||||
credentials: me.getCredentials() || [],
|
||||
credEdit: undefined
|
||||
credEdit: undefined,
|
||||
alias: ((me.tags() || []).find(tag => tag.startsWith('alias:')) || '').substring(6),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,6 +124,12 @@ export default class AccountSettingsView extends React.Component {
|
||||
description="Label for user address (ID)" /></label>
|
||||
<tt>{this.props.myUserId}</tt>
|
||||
</div>
|
||||
{this.state.alias ?
|
||||
<div className="group">
|
||||
<label className="small"><FormattedMessage id="label_alias" defaultMessage="Alias:"
|
||||
description="Label for user or topic alias" /></label>
|
||||
<tt>@{this.state.alias}</tt>
|
||||
</div> : null}
|
||||
<div className="group">
|
||||
<BadgeList trustedBadges={this.props.trustedBadges} />
|
||||
</div>
|
||||
|
||||
+109
-45
@@ -16,7 +16,8 @@ import ShowQRCode from '../widgets/show-qrcode.jsx';
|
||||
import TopicCommonView from './topic-common-view.jsx';
|
||||
import TopicSecurity from '../widgets/topic-security.jsx';
|
||||
|
||||
import { MAX_TITLE_LENGTH, MAX_TOPIC_DESCRIPTION_LENGTH, NO_ACCESS_MODE } from '../config.js';
|
||||
import { MAX_TITLE_LENGTH, MAX_TOPIC_DESCRIPTION_LENGTH,
|
||||
NO_ACCESS_MODE, TOAST_DURATION } from '../config.js';
|
||||
|
||||
import { makeImageUrl } from '../lib/blob-helpers.js';
|
||||
import { theCard, clipStr } from '../lib/utils.js';
|
||||
@@ -82,6 +83,21 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Scan QR Code',
|
||||
description: 'Title for scanning QR code'
|
||||
},
|
||||
text_copied: {
|
||||
id: 'text_copied',
|
||||
defaultMessage: 'Copied to clipboard',
|
||||
description: 'Notification that text has been copied to clipboard'
|
||||
},
|
||||
self_topic_name: {
|
||||
id: 'self_topic_name',
|
||||
defaultMessage: 'Saved messages',
|
||||
description: 'Name of self topic for UI',
|
||||
},
|
||||
self_topic_comment: {
|
||||
id: 'self_topic_comment',
|
||||
defaultMessage: 'Notes, messages, links, files saved for posterity',
|
||||
description: 'Comment for self topic for UI',
|
||||
},
|
||||
});
|
||||
|
||||
class InfoView extends React.Component {
|
||||
@@ -97,6 +113,7 @@ class InfoView extends React.Component {
|
||||
muted: false,
|
||||
address: null,
|
||||
groupTopic: undefined,
|
||||
isSelf: false,
|
||||
channel: undefined,
|
||||
fullName: undefined,
|
||||
description: undefined,
|
||||
@@ -126,8 +143,8 @@ class InfoView extends React.Component {
|
||||
this.handleUnarchive = this.handleUnarchive.bind(this);
|
||||
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
|
||||
this.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this);
|
||||
this.handleCopyID = this.handleCopyID.bind(this);
|
||||
this.handleShowQRCode = this.handleShowQRCode.bind(this);
|
||||
this.handleCopyToClipboard = this.handleCopyToClipboard.bind(this);
|
||||
this.handleShowAddMembers = this.handleShowAddMembers.bind(this);
|
||||
this.handleMemberUpdateRequest = this.handleMemberUpdateRequest.bind(this);
|
||||
this.handleMemberSelected = this.handleMemberSelected.bind(this);
|
||||
@@ -203,6 +220,8 @@ class InfoView extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
const isSelf = topic.isSelfType();
|
||||
|
||||
this.setState({
|
||||
owner: acs && acs.isOwner(),
|
||||
admin: acs && acs.isAdmin(),
|
||||
@@ -210,14 +229,19 @@ class InfoView extends React.Component {
|
||||
deleter: acs && acs.isDeleter(),
|
||||
muted: acs && acs.isMuted(),
|
||||
|
||||
fullName: clipStr(topic.public && topic.public.fn, MAX_TITLE_LENGTH),
|
||||
description: clipStr(topic.public && topic.public.note, MAX_TOPIC_DESCRIPTION_LENGTH),
|
||||
fullName: isSelf ?
|
||||
props.intl.formatMessage(messages.self_topic_name) :
|
||||
clipStr(topic.public && topic.public.fn, MAX_TITLE_LENGTH),
|
||||
description: isSelf ?
|
||||
props.intl.formatMessage(messages.self_topic_comment) :
|
||||
clipStr(topic.public && topic.public.note, MAX_TOPIC_DESCRIPTION_LENGTH),
|
||||
avatar: makeImageUrl(topic.public ? topic.public.photo : null),
|
||||
trustedBadges: badges,
|
||||
private: clipStr(topic.private && topic.private.comment, MAX_TITLE_LENGTH),
|
||||
archived: topic.isArchived(),
|
||||
address: topic.name,
|
||||
groupTopic: topic.isGroupType(),
|
||||
isSelf: isSelf,
|
||||
channel: topic.isChannelType() || topic.chan,
|
||||
access: acs ? acs.getMode() : undefined,
|
||||
modeGiven: acs ? acs.getGiven() : undefined,
|
||||
@@ -376,16 +400,20 @@ class InfoView extends React.Component {
|
||||
this.props.onNavigate(`perm/${which}`);
|
||||
}
|
||||
|
||||
handleCopyID(e) {
|
||||
e.preventDefault();
|
||||
navigator.clipboard.writeText(this.props.myUserId);
|
||||
}
|
||||
|
||||
handleShowQRCode(e) {
|
||||
e.preventDefault();
|
||||
this.props.onNavigate('qrcode');
|
||||
}
|
||||
|
||||
handleCopyToClipboard(e, text) {
|
||||
e.preventDefault();
|
||||
navigator.clipboard.writeText(text).then(_ => {
|
||||
this.setState({toast: this.props.intl.formatMessage(messages.text_copied)});
|
||||
setTimeout(_ => { this.setState({toast: ''}); }, TOAST_DURATION);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
handleShowAddMembers(e) {
|
||||
e.preventDefault();
|
||||
this.props.onInitFind();
|
||||
@@ -455,8 +483,12 @@ class InfoView extends React.Component {
|
||||
const panelTitle = formatMessage((view == 'perm' ? messages['perm_' + args[0]] : messages[view])
|
||||
|| messages['info']);
|
||||
|
||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||
const alias = topic && topic.alias();
|
||||
|
||||
return (
|
||||
<div id="info-view">
|
||||
<div className={`toast${this.state.toast ? ' show' : ''}`}>{this.state.toast}</div>
|
||||
<div className="caption-panel" id="info-caption-panel">
|
||||
<div className="panel-title" id="info-title">{panelTitle}</div>
|
||||
<div>
|
||||
@@ -534,15 +566,18 @@ class InfoView extends React.Component {
|
||||
:
|
||||
view == 'qrcode' ?
|
||||
<ShowQRCode
|
||||
uri={Tinode.URI_TOPIC_ID_PREFIX + this.props.tinode.myUserId}
|
||||
uri={Tinode.URI_TOPIC_ID_PREFIX + this.props.topic}
|
||||
onCancel={this.handleBackNavigate} />
|
||||
:
|
||||
<div id="info-view-content" className="scrollable-panel">
|
||||
<div className="panel-form-column">
|
||||
<a href="#" className="flat-button float-right" onClick={(e) => {e.preventDefault(); this.props.onNavigate('general');}}>
|
||||
<i className="material-icons">edit</i>
|
||||
<FormattedMessage id="button_edit" defaultMessage="Edit" description="Call to action [Edit]" />
|
||||
</a>
|
||||
{!this.state.isSelf ?
|
||||
<a href="#" className="flat-button float-right" onClick={e => {e.preventDefault(); this.props.onNavigate('general');}}>
|
||||
<i className="material-icons">edit</i>
|
||||
<FormattedMessage id="button_edit" defaultMessage="Edit" description="Call to action [Edit]" />
|
||||
</a>
|
||||
: null
|
||||
}
|
||||
<center>
|
||||
<AvatarUpload
|
||||
tinode={this.props.tinode}
|
||||
@@ -569,24 +604,47 @@ class InfoView extends React.Component {
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
<div className="panel-form-row">
|
||||
<div>
|
||||
<label className="small"><FormattedMessage id="label_user_id" defaultMessage="ID:"
|
||||
description="Label for user address (ID)" /></label>
|
||||
<tt>{this.state.address}</tt>
|
||||
{!this.state.isSelf ?
|
||||
<>
|
||||
<div className="panel-form-row">
|
||||
<div>
|
||||
<label className="small"><FormattedMessage id="label_user_id" defaultMessage="ID:"
|
||||
description="Label for user address (ID)" /></label>
|
||||
<tt>{this.state.address}</tt>
|
||||
</div>
|
||||
<div style={{marginLeft: 'auto'}}>
|
||||
<a href="#" onClick={e => {this.handleCopyToClipboard(e, this.state.address);}}>
|
||||
<i className="material-icons">content_copy</i>
|
||||
</a>
|
||||
<a href="#" onClick={this.handleShowQRCode}>
|
||||
<i className="material-icons">qr_code</i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{marginLeft: 'auto'}}>
|
||||
<a href="#" onClick={this.handleCopyID}>
|
||||
<i className="material-icons">content_copy</i>
|
||||
</a>
|
||||
<a href="#" onClick={this.handleShowQRCode}>
|
||||
<i className="material-icons">qr_code</i>
|
||||
</a>
|
||||
{alias ?
|
||||
<div className="panel-form-row">
|
||||
<div>
|
||||
<label className="small"><FormattedMessage id="label_alias" defaultMessage="Alias:"
|
||||
description="Label for user or topic alias" /></label>
|
||||
<tt>{alias}</tt>
|
||||
</div>
|
||||
<div style={{marginLeft: 'auto'}}>
|
||||
<a href="#" onClick={e => {this.handleCopyToClipboard(e, alias);}}>
|
||||
<i className="material-icons">content_copy</i>
|
||||
</a>
|
||||
<i className="material-icons" style={{opacity: 0}}>qr_code</i>
|
||||
</div>
|
||||
</div>
|
||||
: null}
|
||||
</>
|
||||
: null
|
||||
}
|
||||
{this.state.trustedBadges.length > 0 ?
|
||||
<div className="group">
|
||||
<BadgeList trustedBadges={this.state.trustedBadges} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="group">
|
||||
<BadgeList trustedBadges={this.state.trustedBadges} />
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
{this.state.description ?
|
||||
<div className="group">
|
||||
<label className="small">
|
||||
@@ -596,14 +654,17 @@ class InfoView extends React.Component {
|
||||
<div>{this.state.description}</div>
|
||||
</div> : null}
|
||||
</div>
|
||||
<div className="hr" />
|
||||
<div className="panel-form-row">
|
||||
<label>
|
||||
<FormattedMessage id="label_muting_topic" defaultMessage="Muted:"
|
||||
description="Label for Muting/unmuting the topic" />
|
||||
</label>
|
||||
<CheckBox name="P" checked={this.state.muted} onChange={this.handleMuted} />
|
||||
</div>
|
||||
{!this.state.isSelf ?
|
||||
<><div className="hr" />
|
||||
<div className="panel-form-row">
|
||||
<label>
|
||||
<FormattedMessage id="label_muting_topic" defaultMessage="Muted:"
|
||||
description="Label for Muting/unmuting the topic" />
|
||||
</label>
|
||||
<CheckBox name="P" checked={this.state.muted} onChange={this.handleMuted} />
|
||||
</div></>
|
||||
: null
|
||||
}
|
||||
{this.state.archived ?
|
||||
<div className="panel-form-row">
|
||||
<label>
|
||||
@@ -615,13 +676,16 @@ class InfoView extends React.Component {
|
||||
:
|
||||
null
|
||||
}
|
||||
<div className="hr" />
|
||||
<div className="panel-form-row">
|
||||
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onNavigate('security');}}>
|
||||
<i className="material-icons">security</i> <FormattedMessage id="button_security"
|
||||
defaultMessage="Security" description="Navigaton button for security panel." />
|
||||
</a>
|
||||
</div>
|
||||
{!this.state.isSelf ?
|
||||
<><div className="hr" />
|
||||
<div className="panel-form-row">
|
||||
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onNavigate('security');}}>
|
||||
<i className="material-icons">security</i> <FormattedMessage id="button_security"
|
||||
defaultMessage="Security" description="Navigaton button for security panel." />
|
||||
</a>
|
||||
</div></>
|
||||
: null
|
||||
}
|
||||
{this.state.groupTopic && this.state.sharer ?
|
||||
<>
|
||||
<div className="hr" />
|
||||
|
||||
+111
-68
@@ -19,7 +19,6 @@ import LogoView from './logo-view.jsx';
|
||||
import MetaMessage from '../widgets/meta-message.jsx';
|
||||
import PinnedMessages from '../widgets/pinned-messages.jsx';
|
||||
import SendMessage from '../widgets/send-message.jsx';
|
||||
import VCPanel from '../widgets/vc-panel.jsx';
|
||||
import VideoPreview from '../widgets/video-preview.jsx';
|
||||
|
||||
import { DEFAULT_P2P_ACCESS_MODE, EDIT_PREVIEW_LENGTH, IMAGE_PREVIEW_DIM, IMMEDIATE_P2P_SUBSCRIPTION,
|
||||
@@ -77,7 +76,12 @@ const messages = defineMessages({
|
||||
id: 'drag_file',
|
||||
defaultMessage: 'Drag file here',
|
||||
description: 'Prompt on the file drag-n-drop overlay banner'
|
||||
}
|
||||
},
|
||||
self_topic_name: {
|
||||
id: 'self_topic_name',
|
||||
defaultMessage: 'Saved messages',
|
||||
description: 'Name of self topic for UI'
|
||||
},
|
||||
});
|
||||
|
||||
// Checks if the access permissions are granted but not yet accepted.
|
||||
@@ -328,6 +332,7 @@ class MessagesView extends React.Component {
|
||||
showGoToLastButton: false,
|
||||
dragging: false,
|
||||
pins: [],
|
||||
pinsLoaded: false,
|
||||
selectedPin: 0,
|
||||
subsVersion: 0
|
||||
};
|
||||
@@ -378,7 +383,12 @@ class MessagesView extends React.Component {
|
||||
onlineSubs: subs
|
||||
});
|
||||
|
||||
if (topic.public) {
|
||||
if (topic.isSelfType()) {
|
||||
Object.assign(nextState, {
|
||||
title: nextProps.intl.formatMessage(messages.self_topic_name),
|
||||
avatar: true
|
||||
});
|
||||
} else if (topic.public) {
|
||||
Object.assign(nextState, {
|
||||
title: topic.public.fn,
|
||||
avatar: makeImageUrl(topic.public.photo)
|
||||
@@ -405,7 +415,8 @@ class MessagesView extends React.Component {
|
||||
maxSeqId: topic.maxMsgSeq(),
|
||||
latestClearId: topic.maxClearId(),
|
||||
channel: topic.isChannelType(),
|
||||
pins: (topic.aux('pins') || []).slice()
|
||||
pins: (topic.aux('pins') || []).slice(),
|
||||
pinsLoaded: false
|
||||
});
|
||||
|
||||
if (nextProps.callTopic == topic.name && shouldPresentCallPanel(nextProps.callState)) {
|
||||
@@ -422,7 +433,8 @@ class MessagesView extends React.Component {
|
||||
avatar: null,
|
||||
peerMessagingDisabled: false,
|
||||
channel: false,
|
||||
pins: []
|
||||
pins: [],
|
||||
pinsLoaded: false
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -628,7 +640,12 @@ class MessagesView extends React.Component {
|
||||
}
|
||||
|
||||
handleDescChange(desc) {
|
||||
if (desc.public) {
|
||||
if (Tinode.isSelfTopicName(this.props.topic)) {
|
||||
this.setState({
|
||||
title: this.props.intl.formatMessage(messages.self_topic_name),
|
||||
avatar: true
|
||||
});
|
||||
} else if (desc.public) {
|
||||
this.setState({
|
||||
title: desc.public.fn,
|
||||
avatar: makeImageUrl(desc.public.photo)
|
||||
@@ -652,8 +669,8 @@ class MessagesView extends React.Component {
|
||||
}
|
||||
|
||||
postReadNotification(seq) {
|
||||
// Ignore notifications if the app is invisible.
|
||||
if (!this.props.applicationVisible) {
|
||||
// Ignore notifications if the app is invisible or topic is not yet available.
|
||||
if (!this.props.applicationVisible || !this.state.topic) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -680,7 +697,9 @@ class MessagesView extends React.Component {
|
||||
if (n.sendAt <= now) {
|
||||
// Remove expired notification from queue.
|
||||
this.readNotificationQueue.shift();
|
||||
seq = Math.max(seq, n.seq);
|
||||
if (n.seq == 0 || Tinode.isServerAssignedSeq(n.seq)) {
|
||||
seq = Math.max(seq, n.seq);
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
@@ -690,7 +709,11 @@ class MessagesView extends React.Component {
|
||||
if (seq >= 0) {
|
||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||
if (topic) {
|
||||
topic.noteRead(seq);
|
||||
try {
|
||||
topic.noteRead(seq);
|
||||
} catch (err) {
|
||||
console.error("Failed to send read notification", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, NOTIFICATION_EXEC_INTERVAL);
|
||||
@@ -779,14 +802,27 @@ class MessagesView extends React.Component {
|
||||
|
||||
handleAllMessagesReceived(count) {
|
||||
this.setState({fetchingMessages: false});
|
||||
if (count > 0) {
|
||||
// 0 means "latest".
|
||||
this.postReadNotification(0);
|
||||
if (!count) {
|
||||
// All messages received. Nothing left to receive.
|
||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||
if (topic) {
|
||||
// Send read notification for the absolute last message.
|
||||
this.postReadNotification(topic.seq);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Load pinned message too (if any);
|
||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||
topic.getPinnedMessages();
|
||||
if (!this.state.pinsLoaded) {
|
||||
// This needs to be guarded to avoid an infinite loop.
|
||||
// The loop may happen if some pinned messages are deleted.
|
||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||
this.setState({pinsLoaded: true}, _ => {
|
||||
topic.getPinnedMessages();
|
||||
});
|
||||
} else {
|
||||
this.setState({pinsLoaded: false});
|
||||
}
|
||||
}
|
||||
|
||||
handleAuxUpdate(aux) {
|
||||
@@ -883,12 +919,35 @@ class MessagesView extends React.Component {
|
||||
const menuItems = messageSpecificMenuItems || [];
|
||||
const topic = this.props.tinode.getTopic(params.topicName);
|
||||
if (topic) {
|
||||
if (!topic.isChannelType()) {
|
||||
menuItems.push('message_delete');
|
||||
}
|
||||
const acs = topic.getAccessMode();
|
||||
if (acs && acs.isDeleter()) {
|
||||
menuItems.push('message_delete_hard');
|
||||
// Self -> (1) always hard-delete with [Delete] title.
|
||||
// P2P -> (1) hard-delete if allowed and not too old, (2) always soft-delete.
|
||||
// Group -> (1) hard-delete if allowed and not too old or owner, (2) always soft-delete.
|
||||
if (topic.isSelfType()) {
|
||||
// Hard-delete with plain [Delete] title.
|
||||
menuItems.push('message_delete_generic');
|
||||
} else {
|
||||
if (!topic.isChannelType()) {
|
||||
// P2P or normal group: always allow to soft-delete.
|
||||
menuItems.push('message_delete');
|
||||
}
|
||||
|
||||
const acs = topic.getAccessMode();
|
||||
if (acs && acs.isDeleter()) {
|
||||
// Owner can always hard-delete regardless of age.
|
||||
let canDelete = acs.isOwner();
|
||||
if (!canDelete) {
|
||||
// Not owner: check message age if configured on the server.
|
||||
const maxDelAge = this.props.tinode.getServerParam(Tinode.MSG_DELETE_AGE, 0) | 0;
|
||||
// Make sure the message is not too old. This check is here just to make the UI
|
||||
// more user-friendly. The server will enforce the limit regardless of this.
|
||||
canDelete = maxDelAge == 0 || (maxDelAge > 0 && params.timestamp ?
|
||||
params.timestamp.getTime() > (new Date().getTime() - maxDelAge * 1000) : false);
|
||||
}
|
||||
if (canDelete) {
|
||||
// [Delete for All] title.
|
||||
menuItems.push('message_delete_hard');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.props.showContextMenu(params, menuItems);
|
||||
@@ -1454,44 +1513,23 @@ class MessagesView extends React.Component {
|
||||
);
|
||||
} else if (this.state.rtcPanel) {
|
||||
// P2P call.
|
||||
if (Tinode.isP2PTopicName(this.state.rtcPanel)) {
|
||||
component2 = (
|
||||
<CallPanel
|
||||
topic={this.state.topic}
|
||||
seq={this.props.callSeq}
|
||||
callState={this.props.callState}
|
||||
callAudioOnly={this.props.callAudioOnly}
|
||||
tinode={this.props.tinode}
|
||||
title={this.state.title}
|
||||
avatar={this.state.avatar || true}
|
||||
component2 = (
|
||||
<CallPanel
|
||||
topic={this.state.topic}
|
||||
seq={this.props.callSeq}
|
||||
callState={this.props.callState}
|
||||
callAudioOnly={this.props.callAudioOnly}
|
||||
tinode={this.props.tinode}
|
||||
title={this.state.title}
|
||||
avatar={this.state.avatar || true}
|
||||
|
||||
onError={this.props.onError}
|
||||
onHangup={this.handleCallHangup}
|
||||
onInvite={this.props.onCallInvite}
|
||||
onSendOffer={this.props.onCallSendOffer}
|
||||
onIceCandidate={this.props.onCallIceCandidate}
|
||||
onSendAnswer={this.props.onCallSendAnswer} />
|
||||
);
|
||||
} else {
|
||||
// Video conference or stream.
|
||||
component2 = (
|
||||
<VCPanel
|
||||
topic={this.state.topic}
|
||||
seq={this.props.callSeq}
|
||||
callState={this.props.callState}
|
||||
callAudioOnly={this.props.callAudioOnly}
|
||||
tinode={this.props.tinode}
|
||||
title={this.state.title}
|
||||
avatar={this.state.avatar || true}
|
||||
myUid={this.props.myUserId}
|
||||
viewportWidth={this.props.viewportWidth}
|
||||
|
||||
onError={this.props.onError}
|
||||
onHangup={this.handleCallHangup}
|
||||
onCreateCall={this.props.onCallInvite}
|
||||
onJoin={this.props.onVCJoin} />
|
||||
);
|
||||
}
|
||||
onError={this.props.onError}
|
||||
onHangup={this.handleCallHangup}
|
||||
onInvite={this.props.onCallInvite}
|
||||
onSendOffer={this.props.onCallSendOffer}
|
||||
onIceCandidate={this.props.onCallIceCandidate}
|
||||
onSendAnswer={this.props.onCallSendAnswer} />
|
||||
);
|
||||
} else {
|
||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||
const isChannel = topic.isChannelType();
|
||||
@@ -1623,7 +1661,7 @@ class MessagesView extends React.Component {
|
||||
}
|
||||
}
|
||||
const avatar = this.state.avatar || true;
|
||||
const online = this.state.deleted ? null :
|
||||
const online = this.state.deleted || topic.isSelfType() ? null :
|
||||
this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline';
|
||||
|
||||
const titleClass = 'panel-title' + (this.state.deleted ? ' deleted' : '');
|
||||
@@ -1708,9 +1746,10 @@ class MessagesView extends React.Component {
|
||||
<div id="topic-last-seen">{lastSeen}</div>
|
||||
</div>
|
||||
<div style={{marginLeft: 'auto'}}/>
|
||||
{!this.props.displayMobile ?
|
||||
{!this.props.displayMobile && this.state.pins.length > 0 ?
|
||||
<PinnedMessages
|
||||
tinode={this.props.tinode}
|
||||
pins={this.state.pins}
|
||||
messages={pinnedMessages}
|
||||
selected={this.state.selectedPin}
|
||||
isAdmin={this.state.isAdmin}
|
||||
@@ -1732,14 +1771,18 @@ class MessagesView extends React.Component {
|
||||
</div>
|
||||
{this.props.displayMobile ?
|
||||
<>
|
||||
<PinnedMessages
|
||||
tinode={this.props.tinode}
|
||||
messages={pinnedMessages}
|
||||
selected={this.state.selectedPin}
|
||||
isAdmin={this.state.isAdmin}
|
||||
setSelected={index => this.setState({selectedPin: index})}
|
||||
onSelected={this.handleQuoteClick}
|
||||
onCancel={this.handleUnpinMessage} />
|
||||
{this.state.pins.length > 0 ?
|
||||
<PinnedMessages
|
||||
tinode={this.props.tinode}
|
||||
pins={this.state.pins}
|
||||
messages={pinnedMessages}
|
||||
selected={this.state.selectedPin}
|
||||
isAdmin={this.state.isAdmin}
|
||||
setSelected={index => this.setState({selectedPin: index})}
|
||||
onSelected={this.handleQuoteClick}
|
||||
onCancel={this.handleUnpinMessage} />
|
||||
: null
|
||||
}
|
||||
<ErrorPanel
|
||||
level={this.props.errorLevel}
|
||||
text={this.props.errorText}
|
||||
|
||||
@@ -10,7 +10,7 @@ import NewTopicGroup from '../widgets/new-topic-group.jsx';
|
||||
import SearchContacts from '../widgets/search-contacts.jsx';
|
||||
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import { theCard } from '../lib/utils.js';
|
||||
import { asEmail, asPhone, theCard } from '../lib/utils.js';
|
||||
|
||||
const messages = defineMessages({
|
||||
search_for_contacts: {
|
||||
@@ -57,6 +57,24 @@ class NewTopicView extends React.Component {
|
||||
}
|
||||
|
||||
handleSearchContacts(query) {
|
||||
query = query.trim();
|
||||
if (!query) {
|
||||
query = Tinode.DEL_CHAR;
|
||||
} else if (!/[\s,:]/.test(query) && query != Tinode.DEL_CHAR) {
|
||||
// No colons, spaces or commas, not DEL char. Try as email or phone.
|
||||
// If not email or phone, treat as alias.
|
||||
const email = asEmail(query);
|
||||
if (email) {
|
||||
query = `email:${email}`;
|
||||
} else {
|
||||
const tel = asPhone(query);
|
||||
if (tel) {
|
||||
query = `tel:${tel}`;
|
||||
} else {
|
||||
query = `alias:${query}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.props.onSearchContacts(query);
|
||||
this.setState({searchQuery: Tinode.isNullValue(query) ? null : query});
|
||||
}
|
||||
@@ -125,6 +143,7 @@ class NewTopicView extends React.Component {
|
||||
contacts={this.props.searchResults}
|
||||
myUserId={this.props.myUserId}
|
||||
emptyListMessage={no_contacts_placeholder}
|
||||
showSelfTopic={!this.state.searchQuery}
|
||||
showOnline={false}
|
||||
showUnread={false}
|
||||
showContextMenu={false}
|
||||
|
||||
+18
-30
@@ -89,6 +89,11 @@ const messages = defineMessages({
|
||||
id: 'password_reset_success',
|
||||
defaultMessage: 'Password reset successfully',
|
||||
description: 'Notification message that the password was successfully reset.'
|
||||
},
|
||||
select_country: {
|
||||
id: 'select_country',
|
||||
defaultMessage: 'Select country',
|
||||
description: 'Placeholder for the country selector'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -181,7 +186,6 @@ class TinodeWeb extends React.Component {
|
||||
this.handleCallSendOffer = this.handleCallSendOffer.bind(this);
|
||||
this.handleCallIceCandidate = this.handleCallIceCandidate.bind(this);
|
||||
this.handleCallSendAnswer = this.handleCallSendAnswer.bind(this);
|
||||
this.handleVCCallJoin = this.handleVCCallJoin.bind(this);
|
||||
|
||||
this.handleCallAccept = this.handleCallAccept.bind(this);
|
||||
|
||||
@@ -950,7 +954,7 @@ class TinodeWeb extends React.Component {
|
||||
newState.ready = true;
|
||||
}
|
||||
|
||||
this.tinode.getMeTopic().contacts((c) => {
|
||||
this.tinode.getMeTopic().contacts(c => {
|
||||
if (!c.topic && !c.user) {
|
||||
// Contacts expect c.topic to be set.
|
||||
c.topic = c.name;
|
||||
@@ -1610,7 +1614,7 @@ class TinodeWeb extends React.Component {
|
||||
}
|
||||
|
||||
let muted = false, blocked = false, self_blocked = false, subscribed = false, deleter = false,
|
||||
archived = false, webrtc = false, writer = false;
|
||||
archived = false, webrtc = false, writer = false, p2p = false, self = false;
|
||||
if (topic) {
|
||||
subscribed = topic.isSubscribed();
|
||||
archived = topic.isArchived();
|
||||
@@ -1626,26 +1630,26 @@ class TinodeWeb extends React.Component {
|
||||
}
|
||||
|
||||
webrtc = writer && !!this.tinode.getServerParam('iceServers');
|
||||
const mediaServerAvailable = !!this.tinode.getServerParam('vcEndpoint');
|
||||
p2p = Tinode.isP2PTopicName(topicName);
|
||||
self = Tinode.isSelfTopicName(topicName);
|
||||
|
||||
return [
|
||||
subscribed ? {
|
||||
title: this.props.intl.formatMessage(messages.menu_item_info),
|
||||
handler: this.handleShowInfoView
|
||||
} : null,
|
||||
subscribed && Tinode.isP2PTopicName(topicName) && webrtc ? {
|
||||
subscribed && p2p && webrtc ? {
|
||||
title: this.props.intl.formatMessage(messages.menu_item_audio_call),
|
||||
handler: this.handleStartAudioCall
|
||||
} : null,
|
||||
subscribed && webrtc ? {
|
||||
subscribed && p2p && webrtc ? {
|
||||
title: this.props.intl.formatMessage(messages.menu_item_video_call),
|
||||
disabled: Tinode.isGroupTopicName(topicName) && !mediaServerAvailable,
|
||||
handler: this.handleStartVideoCall
|
||||
} : null,
|
||||
subscribed ? 'messages_clear' : null,
|
||||
subscribed && deleter ? 'messages_clear_hard' : null,
|
||||
muted ? (blocked ? null : 'topic_unmute') : 'topic_mute',
|
||||
self_blocked ? 'topic_unblock' : 'topic_block',
|
||||
subscribed && deleter && !self ? 'messages_clear_hard' : null,
|
||||
self ? null : (muted ? (blocked ? null : 'topic_unmute') : 'topic_mute'),
|
||||
self ? null : (self_blocked ? 'topic_unblock' : 'topic_block'),
|
||||
archived ? 'topic_restore' : 'topic_archive',
|
||||
'topic_delete'
|
||||
];
|
||||
@@ -1764,7 +1768,7 @@ class TinodeWeb extends React.Component {
|
||||
}
|
||||
|
||||
handleShowCountrySelector(code, dial, selectedCallback) {
|
||||
this.handleShowAlert("Select country",
|
||||
this.handleShowAlert(this.props.intl.formatMessage(messages.select_country),
|
||||
<Suspense fallback={<div><FormattedMessage id="loading_note" defaultMessage="Loading..."
|
||||
description="Message shown when component is loading"/></div>}>
|
||||
<PhoneCountrySelector
|
||||
@@ -1774,7 +1778,7 @@ class TinodeWeb extends React.Component {
|
||||
selectedCallback(c, d);
|
||||
}} />
|
||||
</Suspense>,
|
||||
null, null, _ => {}, "Cancel");
|
||||
null, null, _ => {}, null);
|
||||
}
|
||||
|
||||
handleStartVideoCall() {
|
||||
@@ -1797,12 +1801,7 @@ class TinodeWeb extends React.Component {
|
||||
switch (callState) {
|
||||
case CALL_STATE_OUTGOING_INITATED:
|
||||
const head = { webrtc: CALL_HEAD_STARTED, aonly: !!audioOnly };
|
||||
const msg = Drafty.videoCall(audioOnly);
|
||||
if (Tinode.isGroupTopicName(callTopic)) {
|
||||
// Video conference.
|
||||
head.vc = true;
|
||||
}
|
||||
return this.handleSendMessage(msg, undefined, undefined, head)
|
||||
return this.handleSendMessage(Drafty.videoCall(audioOnly), undefined, undefined, head)
|
||||
.then(ctrl => {
|
||||
if (ctrl.code < 200 || ctrl.code >= 300 || !ctrl.params || !ctrl.params.seq) {
|
||||
this.handleCallClose();
|
||||
@@ -1811,7 +1810,6 @@ class TinodeWeb extends React.Component {
|
||||
this.setState({callSeq: ctrl.params['seq']});
|
||||
return ctrl
|
||||
});
|
||||
break;
|
||||
case CALL_STATE_IN_PROGRESS:
|
||||
const topic = this.tinode.getTopic(callTopic);
|
||||
if (!topic) {
|
||||
@@ -1867,15 +1865,6 @@ class TinodeWeb extends React.Component {
|
||||
topic.videoCall('answer', callSeq, sdp);
|
||||
}
|
||||
|
||||
handleVCCallJoin(callTopic, callSeq) {
|
||||
const topic = this.tinode.getTopic(callTopic);
|
||||
if (!topic) {
|
||||
return;
|
||||
}
|
||||
|
||||
topic.videoCall('vc-join', callSeq);
|
||||
}
|
||||
|
||||
handleCallClose() {
|
||||
if (this.callTimeoutTimer) {
|
||||
clearTimeout(this.callTimeoutTimer);
|
||||
@@ -1896,7 +1885,7 @@ class TinodeWeb extends React.Component {
|
||||
this.handleTopicSelected(setCallTopic ? topicName : this.state.callTopic);
|
||||
const upd = {
|
||||
callState: CALL_STATE_IN_PROGRESS
|
||||
}
|
||||
};
|
||||
if (setCallTopic) {
|
||||
upd.callTopic = topicName;
|
||||
upd.callSeq = callSeq;
|
||||
@@ -2140,7 +2129,6 @@ class TinodeWeb extends React.Component {
|
||||
onCallSendOffer={this.handleCallSendOffer}
|
||||
onCallIceCandidate={this.handleCallIceCandidate}
|
||||
onCallSendAnswer={this.handleCallSendAnswer}
|
||||
onVCJoin={this.handleVCCallJoin}
|
||||
|
||||
errorText={this.state.errorText}
|
||||
errorLevel={this.state.errorLevel}
|
||||
|
||||
@@ -7,21 +7,12 @@ import { secondsToTime } from '../lib/strformat.js';
|
||||
|
||||
export default class CallMessage extends React.PureComponent {
|
||||
render() {
|
||||
const bigIcon = this.props.vc ?
|
||||
<div className="composed-material">
|
||||
<i className="material-icons gray second-small">group</i>
|
||||
<i className="material-icons gray big outline">call</i>
|
||||
</div>
|
||||
:
|
||||
<div><i className="material-icons big gray">call</i></div>;
|
||||
const isCallDropped = ['busy', 'declined', 'disconnected', 'missed'].includes(this.props.callState);
|
||||
const successClass = 'material-icons medium ' + (isCallDropped ? 'red' : 'green');
|
||||
const callIcon = this.props.incoming ?
|
||||
(isCallDropped ? 'call_missed' : 'call_received') :
|
||||
(isCallDropped ? 'call_missed_outgoing' : 'call_made');
|
||||
const title = this.props.vc ?
|
||||
<FormattedMessage id="calls_conference" defaultMessage="Conference call" description="Conference call label" /> :
|
||||
this.props.incoming ?
|
||||
const title = this.props.incoming ?
|
||||
<FormattedMessage id="calls_incoming" defaultMessage="Incoming call" description="Incoming call label" /> :
|
||||
<FormattedMessage id="calls_outgoing" defaultMessage="Outgoing call" description="Outgoing call label" />;
|
||||
let duration;
|
||||
@@ -45,17 +36,11 @@ export default class CallMessage extends React.PureComponent {
|
||||
} else {
|
||||
duration = <span>{secondsToTime(this.props.duration / 1000)}</span>;
|
||||
}
|
||||
const allowJoin = this.props.vc && this.props.callState == 'started';
|
||||
return <div className="call-message">
|
||||
<div><i className="material-icons big gray">{bigIcon}</i></div>
|
||||
<div><i className="material-icons big gray">call</i></div>
|
||||
<div className="flex-column narrow">
|
||||
<div>{title}</div>
|
||||
<div className="duration"><i className={successClass}>{callIcon}</i> {duration}</div>
|
||||
{allowJoin ?
|
||||
<a onClick={this.props.onCallJoin}>
|
||||
<FormattedMessage id="join_conference" defaultMessage="+ Join" description="Call to action to join a conference call" />
|
||||
</a>
|
||||
: null}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ class BaseChatMessage extends React.PureComponent {
|
||||
this.handleContextClick = this.handleContextClick.bind(this);
|
||||
this.handleCancelUpload = this.handleCancelUpload.bind(this);
|
||||
this.handleQuoteClick = this.handleQuoteClick.bind(this);
|
||||
this.handleCallJoin = this.handleCallJoin.bind(this);
|
||||
|
||||
this.formatterContext = {
|
||||
formatMessage: props.intl.formatMessage.bind(props.intl),
|
||||
@@ -39,8 +38,7 @@ class BaseChatMessage extends React.PureComponent {
|
||||
onImagePreview: this.handleExpandImage,
|
||||
onVideoPreview: this.handlePlayVideo,
|
||||
onFormButtonClick: this.handleFormButtonClick,
|
||||
onQuoteClick: this.handleQuoteClick,
|
||||
onCallJoin: this.handleCallJoin
|
||||
onQuoteClick: this.handleQuoteClick
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,6 +129,7 @@ class BaseChatMessage extends React.PureComponent {
|
||||
x: e.pageX,
|
||||
pickReply: this.props.pickReply,
|
||||
editMessage: this.props.editMessage,
|
||||
timestamp: this.props.timestamp,
|
||||
}, menuItems);
|
||||
}
|
||||
|
||||
@@ -151,10 +150,6 @@ class BaseChatMessage extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleCallJoin() {
|
||||
this.props.onAcceptCall(this.props.topic, true, this.props.seq);
|
||||
}
|
||||
|
||||
render() {
|
||||
const sideClass = this.props.sequence + ' ' + (this.props.response ? 'left' : 'right');
|
||||
const bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ? 'bubble tip' : 'bubble';
|
||||
|
||||
@@ -30,7 +30,28 @@ class ContactList extends React.Component {
|
||||
const { formatMessage } = this.props.intl;
|
||||
const showCheckmark = Array.isArray(this.props.topicSelected);
|
||||
const contactNodes = [];
|
||||
|
||||
// Count of contacts, excluding action items.
|
||||
let contactsCount = 0;
|
||||
if (this.props.showSelfTopic) {
|
||||
const selected = showCheckmark ?
|
||||
(this.props.topicSelected.indexOf('slf') > -1) :
|
||||
(this.props.topicSelected === 'slf');
|
||||
contactNodes.push(
|
||||
<Contact
|
||||
tinode={this.props.tinode}
|
||||
avatar={true}
|
||||
showMode={this.props.showMode}
|
||||
selected={selected}
|
||||
showCheckmark={showCheckmark}
|
||||
onSelected={this.props.onTopicSelected}
|
||||
showContextMenu={this.props.showContextMenu}
|
||||
index={contactNodes.length}
|
||||
item='slf'
|
||||
key='slf' />
|
||||
);
|
||||
contactsCount ++;
|
||||
}
|
||||
if (this.props.contacts && this.props.contacts.length > 0) {
|
||||
this.props.contacts.forEach(c => {
|
||||
if (c.action) {
|
||||
|
||||
+28
-6
@@ -1,6 +1,8 @@
|
||||
// A single topic or user.
|
||||
import React from 'react';
|
||||
import { FormattedMessage, injectIntl } from 'react-intl';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import { Tinode } from 'tinode-sdk';
|
||||
|
||||
import ContactBadges from './contact-badges.jsx';
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
@@ -11,6 +13,19 @@ import { Drafty } from 'tinode-sdk';
|
||||
import { previewFormatter } from '../lib/formatters.js';
|
||||
import { deliveryMarker } from '../lib/utils.js';
|
||||
|
||||
const messages = defineMessages({
|
||||
self_topic_name: {
|
||||
id: 'self_topic_name',
|
||||
defaultMessage: 'Saved messages',
|
||||
description: 'Name of self topic for UI'
|
||||
},
|
||||
self_topic_comment: {
|
||||
id: 'self_topic_comment',
|
||||
defaultMessage: 'Notes, messages, links, files saved for posterity',
|
||||
description: 'Comment for self topic for UI'
|
||||
}
|
||||
});
|
||||
|
||||
class Contact extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -36,9 +51,14 @@ class Contact extends React.Component {
|
||||
|
||||
render() {
|
||||
let title = this.props.title;
|
||||
const self = Tinode.isSelfTopicName(this.props.item);
|
||||
if (!title) {
|
||||
title = <i><FormattedMessage id="unnamed_topic" defaultMessage="Unnamed"
|
||||
description="Title shown when the topic has no name" /></i>;
|
||||
if (self) {
|
||||
title = this.props.intl.formatMessage(messages.self_topic_name);
|
||||
} else {
|
||||
title = <i><FormattedMessage id="unnamed_topic" defaultMessage="Unnamed"
|
||||
description="Title shown when the topic has no name" /></i>;
|
||||
}
|
||||
} else if (title.length > 30) {
|
||||
// FIXME: this is probably wrong for RTL languages.
|
||||
title = title.substring(0, 28) + '…';
|
||||
@@ -88,6 +108,8 @@ class Contact extends React.Component {
|
||||
(icon.color ? ' ' + icon.color : '')}>{icon.name}</i> : null;
|
||||
const titleClass = 'contact-title' + (this.props.deleted ? ' deleted' : '');
|
||||
|
||||
const comment = preview || this.props.comment ||
|
||||
(self ? this.props.intl.formatMessage(messages.self_topic_comment) : '\u00A0');
|
||||
return (
|
||||
<li className={!this.props.showCheckmark && this.props.selected ? 'selected' : null} onClick={this.handleClick}>
|
||||
<div className="avatar-box">
|
||||
@@ -98,7 +120,7 @@ class Contact extends React.Component {
|
||||
topic={this.props.item}
|
||||
deleted={this.props.deleted} />
|
||||
{this.props.deleted ? <i className="deleted material-icons">cancel</i> :
|
||||
this.props.showOnline ? <span className={online} /> :
|
||||
(!self && this.props.showOnline) ? <span className={online} /> :
|
||||
(this.props.showCheckmark && this.props.selected) ?
|
||||
<i className="checkmark material-icons">check_circle</i>
|
||||
: null}
|
||||
@@ -107,12 +129,12 @@ class Contact extends React.Component {
|
||||
<div><span className={titleClass}>{title}</span>
|
||||
{this.props.isGroup ? <i className="material-icons as-badge">group</i> : null}
|
||||
{this.props.isChannel ? <img src="/img/channel.png" className="channel" alt="channel" /> : null}
|
||||
<ContactBadges badges={icon_badges} />
|
||||
{!self ? <ContactBadges badges={icon_badges} /> : null}
|
||||
{!this.props.deleted ? <UnreadBadge count={this.props.unread} /> : null}
|
||||
</div>
|
||||
{this.props.showMode ?
|
||||
<span><ContactBadges badges={badges} /></span> :
|
||||
this.props.small ? null : <div className="contact-comment">{marker}<span>{preview || this.props.comment || '\u00A0'}</span></div>}
|
||||
this.props.small ? null : <div className="contact-comment">{marker}<span>{comment}</span></div>}
|
||||
</div>
|
||||
{this.props.showContextMenu ?
|
||||
<span className="menuTrigger">
|
||||
|
||||
@@ -189,6 +189,14 @@ class ContextMenu extends React.Component {
|
||||
return this.deleteMessages(false, true, params, errorHandler);
|
||||
}
|
||||
},
|
||||
// Hard-delete but with just 'Delete' title.
|
||||
'message_delete_generic': {
|
||||
id: 'message_delete_generic',
|
||||
title: formatMessage(messages.delete),
|
||||
handler: (params, errorHandler) => {
|
||||
return this.deleteMessages(false, true, params, errorHandler);
|
||||
}
|
||||
},
|
||||
'menu_item_send_retry': {
|
||||
id: 'menu_item_send_retry',
|
||||
title: formatMessage(messages.send_retry),
|
||||
|
||||
@@ -75,8 +75,10 @@ export default class Cropper extends React.Component {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.overlay.current.removeEventListener('mousedown', this.mouseDown);
|
||||
this.overlay.current.removeEventListener('touchstart', this.mouseDown);
|
||||
if (this.overlay.current) {
|
||||
this.overlay.current.removeEventListener('mousedown', this.mouseDown);
|
||||
this.overlay.current.removeEventListener('touchstart', this.mouseDown);
|
||||
}
|
||||
}
|
||||
|
||||
// Position all elements.
|
||||
|
||||
@@ -12,7 +12,8 @@ export default class InPlaceEdit extends React.Component {
|
||||
this.state = {
|
||||
active: props.active,
|
||||
initialValue: props.value || '',
|
||||
value: props.value || ''
|
||||
value: props.value || '',
|
||||
valid: true
|
||||
};
|
||||
|
||||
this.handeTextChange = this.handeTextChange.bind(this);
|
||||
@@ -36,6 +37,18 @@ export default class InPlaceEdit extends React.Component {
|
||||
|
||||
handeTextChange(e) {
|
||||
this.setState({value: e.target.value || ''});
|
||||
if (this.props.validator) {
|
||||
Promise.resolve(this.props.validator(e.target.value))
|
||||
.then(valid => {
|
||||
this.setState({valid: valid});
|
||||
})
|
||||
.catch(err => {
|
||||
// If it's interrupted, then the err is undefined. Ignore it.
|
||||
if (err) {
|
||||
this.setState({valid: false});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyDown(e) {
|
||||
@@ -66,7 +79,7 @@ export default class InPlaceEdit extends React.Component {
|
||||
return;
|
||||
}
|
||||
this.setState({active: false});
|
||||
if ((value || this.props.value) && (value !== this.props.value)) {
|
||||
if ((value || this.props.value) && (value !== this.props.value) && this.state.valid) {
|
||||
this.props.onFinished(value);
|
||||
}
|
||||
}
|
||||
@@ -91,7 +104,9 @@ export default class InPlaceEdit extends React.Component {
|
||||
}
|
||||
|
||||
return (<span className={spanClass} onClick={this.handleStartEditing}>
|
||||
{this.props.iconLeft && <i className="material-icons light-gray">{this.props.iconLeft}</i>}
|
||||
<span>{spanText}</span>
|
||||
{this.props.iconRight && <i className="material-icons light-gray">{this.props.iconRight}</i>}
|
||||
</span>);
|
||||
}
|
||||
|
||||
@@ -104,12 +119,18 @@ export default class InPlaceEdit extends React.Component {
|
||||
if (this.props.multiline > 1) {
|
||||
element = 'textarea';
|
||||
attr.rows = this.props.multiline;
|
||||
attr.className = 'inplace-edit';
|
||||
attr.className = 'in-place-edit';
|
||||
} else {
|
||||
element = 'input';
|
||||
attr.type = this.props.type || 'text';
|
||||
attr.className = 'in-place-edit' + (this.props.iconLeft ? ' with-icon-left'
|
||||
: this.props.iconRight ? ' with-icon-right' : '');
|
||||
if (this.props.maxLength) {
|
||||
attr.maxLength=this.props.maxLength;
|
||||
}
|
||||
}
|
||||
attr.value = this.state.value;
|
||||
attr.className += this.state.valid ? '' : ' invalid';
|
||||
attr.ref = this.selfRef;
|
||||
attr.onChange = this.handeTextChange;
|
||||
attr.onKeyDown = this.handleKeyDown;
|
||||
@@ -119,7 +140,19 @@ export default class InPlaceEdit extends React.Component {
|
||||
attr.required = this.props.required ? 'required' : '';
|
||||
attr.autoComplete = this.props.autoComplete;
|
||||
attr.autoFocus = true;
|
||||
if (this.props.spellCheck !== undefined) {
|
||||
attr.spellCheck = this.props.spellCheck ? 'true' : 'false';
|
||||
}
|
||||
|
||||
if (this.props.iconLeft || this.props.iconRight) {
|
||||
return (
|
||||
<>
|
||||
{this.props.iconLeft && <i className="material-icons light-gray">{this.props.iconLeft}</i>}
|
||||
{React.createElement(element, attr, null)}
|
||||
{this.props.iconRight && <i className="material-icons light-gray">{this.props.iconRight}</i>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return React.createElement(element, attr, null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,26 +5,33 @@ import { Tinode } from 'tinode-sdk';
|
||||
import { idToColorClass } from '../lib/strformat.js';
|
||||
import { sanitizeUrlForMime } from '../lib/utils.js';
|
||||
|
||||
import { SELF_AVATAR_URI } from '../config.js';
|
||||
|
||||
export default class LetterTile extends React.PureComponent {
|
||||
render() {
|
||||
let avatar;
|
||||
if (this.props.avatar === true) {
|
||||
const isGroup = Tinode.isGroupTopicName(this.props.topic);
|
||||
const iconColor = idToColorClass(this.props.topic, isGroup);
|
||||
if (this.props.topic && this.props.title && this.props.title.trim()) {
|
||||
const letter = this.props.title.trim().charAt(0);
|
||||
const className = 'lettertile ' + iconColor + (this.props.deleted ? ' disabled' : '');
|
||||
avatar = (<div className={className}><div>{letter}</div></div>)
|
||||
if (Tinode.isSelfTopicName(this.props.topic)) {
|
||||
avatar = <img className="avatar self" alt="avatar" src={SELF_AVATAR_URI} />
|
||||
} else {
|
||||
const className = 'material-icons ' + iconColor + (this.props.deleted ? ' disabled' : '');
|
||||
avatar = isGroup ? <i className={className}>group</i> : <i className={className}>person</i>;
|
||||
const isGroup = Tinode.isGroupTopicName(this.props.topic);
|
||||
const iconColor = idToColorClass(this.props.topic, isGroup);
|
||||
|
||||
if (this.props.topic && this.props.title && this.props.title.trim()) {
|
||||
const letter = this.props.title.trim().charAt(0);
|
||||
const className = 'lettertile ' + iconColor + (this.props.deleted ? ' disabled' : '');
|
||||
avatar = (<div className={className}><div>{letter}</div></div>)
|
||||
} else {
|
||||
const className = 'material-icons ' + iconColor + (this.props.deleted ? ' disabled' : '');
|
||||
avatar = isGroup ? <i className={className}>group</i> : <i className={className}>person</i>;
|
||||
}
|
||||
}
|
||||
} else if (this.props.avatar) {
|
||||
const url = this.props.tinode.authorizeURL(sanitizeUrlForMime(this.props.avatar, 'image'));
|
||||
// If avatar image is invalid, show a placeholder.
|
||||
const className = 'avatar' + (this.props.deleted ? ' deleted' : '');
|
||||
avatar = <img className={className} alt="avatar" src={url}
|
||||
onError={(e)=>{e.target.onerror = null; e.target.src="../img/broken_image.png"}} />;
|
||||
onError={e=>{e.target.onerror = null; e.target.src="../img/broken_image.png"}} />;
|
||||
} else {
|
||||
avatar = null;
|
||||
}
|
||||
|
||||
+110
-30
@@ -1,38 +1,73 @@
|
||||
// Send message form.
|
||||
import React from 'react';
|
||||
import { injectIntl } from 'react-intl';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { Drafty } from 'tinode-sdk';
|
||||
|
||||
import { previewFormatter } from '../lib/formatters.js';
|
||||
import { MIN_SWIPE_DISTANCE } from '../config.js';
|
||||
|
||||
const messages = defineMessages({
|
||||
message_not_found: {
|
||||
id: 'message_not_found',
|
||||
defaultMessage: 'message not found',
|
||||
description: 'Pinned message is not found'
|
||||
},
|
||||
message_deleted: {
|
||||
id: 'message_deleted',
|
||||
defaultMessage: 'message deleted',
|
||||
description: 'Pinned message is deleted'
|
||||
}
|
||||
});
|
||||
|
||||
class PinnedMessages extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// Starting location of the touch gesture.
|
||||
this.touchX = null;
|
||||
this.touchY = null;
|
||||
|
||||
this.touchSurface = React.createRef();
|
||||
|
||||
this.getSelectedIndex = this.getSelectedIndex.bind(this);
|
||||
this.handleCancel = this.handleCancel.bind(this);
|
||||
this.handleSelected = this.handleSelected.bind(this);
|
||||
this.handleMoveNext = this.handleMoveNext.bind(this);
|
||||
this.handleMovePrev = this.handleMovePrev.bind(this);
|
||||
this.handleTouchEventStart = this.handleTouchEventStart.bind(this);
|
||||
this.handleTouchEventEnd = this.handleTouchEventEnd.bind(this);
|
||||
this.handleTouchCancel = this.handleTouchCancel.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.touchSurface.current.addEventListener('touchstart', this.handleTouchEventStart, {passive: true});
|
||||
this.touchSurface.current.addEventListener('touchend', this.handleTouchEventEnd, {passive: true});
|
||||
this.touchSurface.current.addEventListener('touchcancel', this.handleTouchCancel, {passive: true});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.touchSurface.current.removeEventListener('touchstart', this.handleTouchEventStart);
|
||||
this.touchSurface.current.removeEventListener('touchend', this.handleTouchEventEnd);
|
||||
this.touchSurface.current.removeEventListener('touchcancel', this.handleTouchCancel);
|
||||
}
|
||||
getSelectedIndex() {
|
||||
const list = (this.props.messages || []);
|
||||
const list = (this.props.pins || []);
|
||||
return list.length - this.props.selected - 1;
|
||||
}
|
||||
|
||||
handleCancel(e) {
|
||||
e.preventDefault();
|
||||
this.props.onCancel(this.props.messages[this.getSelectedIndex()].seq);
|
||||
this.props.onCancel(this.props.pins[this.getSelectedIndex()]);
|
||||
}
|
||||
|
||||
handleSelected(e) {
|
||||
e.preventDefault();
|
||||
this.props.onSelected(this.props.messages[this.getSelectedIndex()].seq);
|
||||
this.props.onSelected(this.props.pins[this.getSelectedIndex()]);
|
||||
}
|
||||
|
||||
handleMoveNext(e) {
|
||||
e.preventDefault();
|
||||
handleMoveNext(e, isTouch) {
|
||||
// Don't call preventDefault for touch events.
|
||||
isTouch || e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const idx = Math.max(this.props.selected - 1, 0);
|
||||
if (idx != this.props.selected) {
|
||||
@@ -40,31 +75,71 @@ class PinnedMessages extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleMovePrev(e) {
|
||||
e.preventDefault();
|
||||
handleMovePrev(e, isTouch) {
|
||||
// Don't call preventDefault for touch events.
|
||||
isTouch || e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const idx = Math.min(this.props.selected + 1, (this.props.messages || []).length - 1);
|
||||
const idx = Math.min(this.props.selected + 1, (this.props.pins || []).length - 1);
|
||||
if (idx != this.props.selected) {
|
||||
this.props.setSelected(idx);
|
||||
}
|
||||
}
|
||||
|
||||
handleTouchEventStart(e) {
|
||||
if (e.touches.length == 1) {
|
||||
this.touchX = e.touches[0].clientX;
|
||||
this.touchY = e.touches[0].clientY;
|
||||
}
|
||||
}
|
||||
|
||||
handleTouchEventEnd(e) {
|
||||
if (this.touchX === null || e.changedTouches.length != 1) {
|
||||
this.touchX = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const dX = this.touchX - e.changedTouches[0].clientX;
|
||||
const dY = this.touchY - e.changedTouches[0].clientY;
|
||||
this.touchX = null;
|
||||
|
||||
if (Math.abs(dX) > Math.abs(dY) || Math.abs(dY) < MIN_SWIPE_DISTANCE) {
|
||||
// Horizontal swipe or too short vertical swipe.
|
||||
return;
|
||||
}
|
||||
|
||||
if (dY > 0) {
|
||||
this.handleMovePrev(e, true);
|
||||
} else {
|
||||
this.handleMoveNext(e, true);
|
||||
}
|
||||
}
|
||||
|
||||
handleTouchCancel() {
|
||||
this.touchX = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const selected = this.getSelectedIndex();
|
||||
let shown = (this.props.messages || [])[selected];
|
||||
shown = shown ? Drafty.format(shown.content, previewFormatter, {
|
||||
formatMessage: this.props.intl.formatMessage.bind(this.props.intl),
|
||||
authorizeURL: this.props.tinode.authorizeURL.bind(this.props.tinode)
|
||||
}) : null;
|
||||
let messageShown = (this.props.messages || [])[selected];
|
||||
messageShown = messageShown ?
|
||||
messageShown._deleted ?
|
||||
<i className="gray">{this.props.intl.formatMessage(messages.message_deleted)}</i>
|
||||
:
|
||||
Drafty.format(messageShown.content, previewFormatter, {
|
||||
formatMessage: this.props.intl.formatMessage.bind(this.props.intl),
|
||||
authorizeURL: this.props.tinode.authorizeURL.bind(this.props.tinode)
|
||||
})
|
||||
:
|
||||
<i className="gray">{this.props.intl.formatMessage(messages.message_not_found)}</i>;
|
||||
|
||||
const dots = [];
|
||||
this.props.messages.forEach(_ => {
|
||||
this.props.pins.forEach(seq => {
|
||||
const cn = dots.length == selected ? 'adot' : 'dot';
|
||||
dots.push(<div key={dots.length} className={cn} />);
|
||||
dots.push(<div key={seq} className={cn} />);
|
||||
});
|
||||
|
||||
return shown ?
|
||||
(<div id="pinned-wrapper">
|
||||
return (
|
||||
<div id="pinned-wrapper" ref={this.touchSurface}>
|
||||
{this.props.isAdmin ?
|
||||
<div className="cancel">
|
||||
<a href="#" onClick={this.handleCancel}><i className="material-icons gray">close</i></a>
|
||||
@@ -72,22 +147,27 @@ class PinnedMessages extends React.PureComponent {
|
||||
<div><i className="material-icons gray">push_pin</i></div>
|
||||
}
|
||||
<div className="pinned-scroll">{dots}</div>
|
||||
<div className="pinned" onClick={this.handleSelected}><p>{shown}</p></div>
|
||||
{selected > 0 ?
|
||||
<div className="pinned" onClick={this.handleSelected}><p>{messageShown}</p></div>
|
||||
<div className="pinned-menu">
|
||||
<span className="menuTrigger upper">
|
||||
<a href="#" onClick={this.handleMovePrev}>
|
||||
<i className="material-icons">expand_less</i>
|
||||
</a>
|
||||
{selected > 0 ?
|
||||
<a href="#" onClick={this.handleMovePrev}>
|
||||
<i className="material-icons">expand_less</i>
|
||||
</a>
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
: null}
|
||||
{this.props.selected > 0 ?
|
||||
<span className="menuTrigger lower">
|
||||
<a href="#" onClick={this.handleMoveNext}>
|
||||
<i className="material-icons">expand_more</i>
|
||||
</a>
|
||||
{this.props.selected > 0 ?
|
||||
<a href="#" onClick={this.handleMoveNext}>
|
||||
<i className="material-icons">expand_more</i>
|
||||
</a>
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
: null}
|
||||
</div>) : null;
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import ChipInput from './chip-input.jsx';
|
||||
|
||||
import { Tinode } from 'tinode-sdk';
|
||||
|
||||
import { MAX_TAG_COUNT, MAX_TAG_LENGTH, MIN_TAG_LENGTH } from '../config.js';
|
||||
import { arrayEqual } from '../lib/utils.js';
|
||||
|
||||
@@ -49,7 +51,7 @@ export default class TagManager extends React.Component {
|
||||
}
|
||||
|
||||
handleAddTag(tag) {
|
||||
const maxTagCount = this.props.tinode.getServerParam('maxTagCount', MAX_TAG_COUNT);
|
||||
const maxTagCount = this.props.tinode.getServerParam(Tinode.MAX_TAG_COUNT, MAX_TAG_COUNT);
|
||||
|
||||
if (tag.length > 0 && this.state.tags.length < maxTagCount) {
|
||||
const tags = this.state.tags.slice(0);
|
||||
@@ -87,8 +89,8 @@ export default class TagManager extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const minTagLength = this.props.tinode.getServerParam('minTagLength', MIN_TAG_LENGTH);
|
||||
const maxTagLength = this.props.tinode.getServerParam('maxTagLength', MAX_TAG_LENGTH);
|
||||
const minTagLength = this.props.tinode.getServerParam(Tinode.MIN_TAG_LENGTH, MIN_TAG_LENGTH);
|
||||
const maxTagLength = this.props.tinode.getServerParam(Tinode.MAX_TAG_LENGTH, MAX_TAG_LENGTH);
|
||||
|
||||
let tags = [];
|
||||
if (this.state.activated) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Widget for editing topic description.
|
||||
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
||||
|
||||
import { Tinode } from 'tinode-sdk';
|
||||
|
||||
@@ -15,25 +15,58 @@ import { AVATAR_SIZE, MAX_AVATAR_BYTES, MAX_EXTERN_ATTACHMENT_SIZE, MAX_TITLE_LE
|
||||
import { imageScaled, blobToBase64, makeImageUrl } from '../lib/blob-helpers.js';
|
||||
import { arrayEqual, theCard } from '../lib/utils.js';
|
||||
|
||||
export default class TopicDescEdit extends React.Component {
|
||||
const messages = defineMessages({
|
||||
alias_invalid: {
|
||||
id: 'alias_invalid',
|
||||
defaultMessage: '(invalid)',
|
||||
description: 'Error message for invalid alias'
|
||||
},
|
||||
alias_already_taken: {
|
||||
id: 'alias_already_taken',
|
||||
defaultMessage: '(already taken)',
|
||||
description: 'Error message for alias already taken'
|
||||
},
|
||||
self_topic_name: {
|
||||
id: 'self_topic_name',
|
||||
defaultMessage: 'Saved messages',
|
||||
description: 'Name of self topic for UI'
|
||||
},
|
||||
self_topic_comment: {
|
||||
id: 'self_topic_comment',
|
||||
defaultMessage: 'Notes, messages, links, files saved for posterity',
|
||||
description: 'Comment for self topic for UI'
|
||||
}
|
||||
});
|
||||
|
||||
const ALIAS_REGEX = /^[a-z0-9_\-]{4,24}$/;
|
||||
const ALIAS_AVAILABILITY_CHECK_DELAY = 1000; // milliseconds
|
||||
|
||||
class TopicDescEdit extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||
const acs = topic.getAccessMode();
|
||||
const isSelf = Tinode.isSelfTopicName(this.props.topic);
|
||||
this.state = {
|
||||
isMe: Tinode.isMeTopicName(this.props.topic),
|
||||
isSelf: isSelf,
|
||||
owner: acs && acs.isOwner(),
|
||||
fullName: topic.public ? topic.public.fn : undefined,
|
||||
fullName: topic.public ? topic.public.fn :
|
||||
isSelf ? this.props.intl.formatMessage(messages.self_topic_name) : null,
|
||||
private: topic.private ? topic.private.comment : null,
|
||||
description: topic.public ? topic.public.note : undefined,
|
||||
description: topic.public ? topic.public.note :
|
||||
isSelf ? this.props.intl.formatMessage(messages.self_topic_comment) : null,
|
||||
avatar: makeImageUrl(topic.public ? topic.public.photo : null),
|
||||
tags: topic.tags() || [],
|
||||
newAvatar: null,
|
||||
newAvatarMime: null
|
||||
newAvatarMime: null,
|
||||
aliasError: ''
|
||||
};
|
||||
|
||||
this.previousOnTags = null;
|
||||
this.aliasCheckTimer = null;
|
||||
this.aliasCheckPromise = null;
|
||||
|
||||
this.tnNewTags = this.tnNewTags.bind(this);
|
||||
this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this);
|
||||
@@ -42,8 +75,10 @@ export default class TopicDescEdit extends React.Component {
|
||||
this.handleAvatarCropCancel = this.handleAvatarCropCancel.bind(this);
|
||||
this.uploadAvatar = this.uploadAvatar.bind(this);
|
||||
this.handlePrivateUpdate = this.handlePrivateUpdate.bind(this);
|
||||
this.handleAliasUpdate = this.handleAliasUpdate.bind(this);
|
||||
this.handleDescriptionUpdate = this.handleDescriptionUpdate.bind(this);
|
||||
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
|
||||
this.validateAlias = this.validateAlias.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -141,6 +176,61 @@ export default class TopicDescEdit extends React.Component {
|
||||
this.setState({newAvatar: null, newAvatarMime: null});
|
||||
}
|
||||
|
||||
handleAliasUpdate(alias) {
|
||||
alias = alias.trim() || '';
|
||||
const tags = this.state.tags.filter(t => !t.startsWith('alias:'));
|
||||
if (alias) {
|
||||
tags.push('alias:' + alias.toLowerCase());
|
||||
}
|
||||
this.handleTagsUpdated(tags);
|
||||
}
|
||||
|
||||
validateAlias(alias) {
|
||||
// Reset pending delayed check.
|
||||
if (this.aliasCheckTimer) {
|
||||
this.aliasCheckPromise.reject(null);
|
||||
clearTimeout(this.aliasCheckTimer);
|
||||
this.aliasCheckTimer = null;
|
||||
}
|
||||
|
||||
if (!alias) {
|
||||
this.setState({aliasError: ''});
|
||||
return true;
|
||||
}
|
||||
|
||||
const valid = ALIAS_REGEX.test(alias);
|
||||
if (!valid) {
|
||||
this.setState({aliasError: this.props.intl.formatMessage(messages.alias_invalid)});
|
||||
return false;
|
||||
}
|
||||
this.setState({aliasError: ''});
|
||||
|
||||
// Setup a delayed check for alias availability.
|
||||
this.aliasCheckPromise = {};
|
||||
const validationPromise = new Promise((resolve, reject) => {
|
||||
this.aliasCheckPromise.resolve = resolve;
|
||||
this.aliasCheckPromise.reject = reject;
|
||||
});
|
||||
this.aliasCheckTimer = setTimeout(_ => {
|
||||
this.aliasCheckTimer = null;
|
||||
const fnd = this.props.tinode.getFndTopic();
|
||||
fnd.subscribe()
|
||||
.then(_ => fnd.setMeta({desc: {public: `alias:${alias}`}}))
|
||||
.then(_ => fnd.getMeta(fnd.startMetaQuery().withTags().build()))
|
||||
.then(meta => {
|
||||
const taken = Array.isArray(meta.tags);
|
||||
this.setState({aliasError: taken ?
|
||||
this.props.intl.formatMessage(messages.alias_already_taken) : ''});
|
||||
this.aliasCheckPromise.resolve(!taken)
|
||||
})
|
||||
.catch(err => {
|
||||
this.aliasCheckPromise.reject(err);
|
||||
this.setState({aliasError: err.message});
|
||||
});
|
||||
}, ALIAS_AVAILABILITY_CHECK_DELAY);
|
||||
return validationPromise;
|
||||
}
|
||||
|
||||
handleTagsUpdated(tags) {
|
||||
// Check if tags have actually changed.
|
||||
if (arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
|
||||
@@ -161,7 +251,12 @@ export default class TopicDescEdit extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
const editable = this.state.isMe || this.state.owner;
|
||||
const editable = this.state.isMe || (this.state.owner && !this.state.isSelf);
|
||||
let alias = this.state.tags && this.state.tags.find(t => t.startsWith('alias:'));
|
||||
if (alias) {
|
||||
// Remove 'alias:' prefix.
|
||||
alias = alias.substring(6);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -208,6 +303,7 @@ export default class TopicDescEdit extends React.Component {
|
||||
readOnly={!editable}
|
||||
value={this.state.fullName}
|
||||
required={true}
|
||||
maxLength={32}
|
||||
onFinished={this.handleFullNameUpdate} />
|
||||
}</FormattedMessage>
|
||||
</div>
|
||||
@@ -231,6 +327,30 @@ export default class TopicDescEdit extends React.Component {
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
{editable || alias ?
|
||||
<div className="group">
|
||||
<div><label className={'small ' + (this.state.aliasError ? 'invalid' : '')}>
|
||||
<FormattedMessage id="label_alias_edit" defaultMessage="Alias"
|
||||
description="Label for editing user or topic alias" /> {this.state.aliasError}
|
||||
</label></div>
|
||||
<div>
|
||||
<FormattedMessage id="alias_editing_placeholder"
|
||||
defaultMessage="Alias (optional)"
|
||||
description="Placeholder for editing user or topic alias">{
|
||||
(private_placeholder) => <InPlaceEdit
|
||||
placeholder={private_placeholder}
|
||||
readOnly={!editable}
|
||||
value={alias}
|
||||
validator={this.validateAlias}
|
||||
iconLeft='alternate_email'
|
||||
maxLength={24}
|
||||
spellCheck={false}
|
||||
onFinished={this.handleAliasUpdate} />
|
||||
}</FormattedMessage>
|
||||
</div>
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
{editable || this.state.description ?
|
||||
<div className="group">
|
||||
<div><label className="small">
|
||||
@@ -272,3 +392,5 @@ export default class TopicDescEdit extends React.Component {
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default injectIntl(TopicDescEdit);
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
// Video conference carousel item.
|
||||
import React from 'react';
|
||||
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
|
||||
/**
|
||||
* Props:
|
||||
* - cameraPub
|
||||
* - micPub
|
||||
* - isSpeaking
|
||||
* - tinode
|
||||
* - photo
|
||||
* - userId
|
||||
* - name
|
||||
*/
|
||||
export default class VCCarouselItem extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.handleVideoRefChange = this.handleVideoRefChange.bind(this);
|
||||
this.handleAudioRefChange = this.handleAudioRefChange.bind(this);
|
||||
|
||||
this.videoRef = null;
|
||||
this.audioRef = null;
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.videoRef && !this.videoRef.srcObject && this.props.cameraPub && this.props.cameraPub.videoTrack) {
|
||||
this.props.cameraPub.videoTrack.attach(this.videoRef);
|
||||
}
|
||||
if (this.audioRef && !this.audioRef.srcObject && this.props.micPub && this.props.micPub.audioTrack) {
|
||||
this.props.micPub.audioTrack.attach(this.audioRef);
|
||||
}
|
||||
}
|
||||
|
||||
handleVideoRefChange(node) {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.videoRef = node;
|
||||
|
||||
// Attach video track to video element.
|
||||
const cameraPub = this.props.cameraPub;
|
||||
const cameraEnabled = cameraPub && cameraPub.isSubscribed && !cameraPub.isMuted;
|
||||
if (cameraEnabled && cameraPub.videoTrack) {
|
||||
cameraPub.videoTrack.attach(node);
|
||||
}
|
||||
}
|
||||
|
||||
handleAudioRefChange(node) {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.audioRef = node;
|
||||
|
||||
// Attach audio track to audio element.
|
||||
const micPub = this.props.micPub;
|
||||
const micEnabled = micPub && micPub.isSubscribed && !micPub.isMuted;
|
||||
if (micEnabled && micPub.audioTrack) {
|
||||
micPub.audioTrack.attach(node);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let classes = 'call-party carousel-item';
|
||||
if (this.props.isSpeaking) {
|
||||
classes += ' call-party-speaking';
|
||||
}
|
||||
|
||||
const micPub = this.props.micPub;
|
||||
const cameraPub = this.props.cameraPub;
|
||||
return (
|
||||
<div className={classes}>
|
||||
<video ref={this.handleVideoRefChange}></video>
|
||||
<audio ref={this.handleAudioRefChange}></audio>
|
||||
{micPub && micPub.isMuted ?
|
||||
<i className="material-icons muted">mic_off</i>
|
||||
: null
|
||||
}
|
||||
{cameraPub && cameraPub.isMuted ?
|
||||
<div className="avatar-box">
|
||||
<LetterTile
|
||||
tinode={this.props.tinode}
|
||||
avatar={this.props.photo || true}
|
||||
topic={this.props.userId}
|
||||
title={this.props.name} />
|
||||
</div>
|
||||
: null}
|
||||
<div className="caller-name inactive">
|
||||
{this.props.name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Track } from 'livekit-client';
|
||||
|
||||
import VCCarouselItem from './vc-carousel-item.jsx';
|
||||
|
||||
import { VC_CAROUSEL_ITEM_GAP } from '../constants.js';
|
||||
|
||||
export default class VCCarousel extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
prevVisible: false,
|
||||
nextVisible: false
|
||||
};
|
||||
|
||||
this.handleScrollReference = this.handleScrollReference.bind(this);
|
||||
this.handleScrollEvent = this.handleScrollEvent.bind(this);
|
||||
this.handleScrollClick = this.handleScrollClick.bind(this);
|
||||
|
||||
this.carouselRef = null;
|
||||
this.contentRef = React.createRef();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.carouselRef && this.props.viewportWidth != prevProps.viewportWidth) {
|
||||
this.handleScrollEvent({target: this.carouselRef});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.carouselRef) {
|
||||
this.carouselRef.removeEventListener('scroll', this.handleScrollEvent);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't use React.createRef as the ref.current is not available in componentDidMount in this component.
|
||||
handleScrollReference(node) {
|
||||
if (node) {
|
||||
this.carouselRef = node;
|
||||
this.carouselRef.addEventListener('scroll', this.handleScrollEvent);
|
||||
this.handleScrollEvent({target: this.carouselRef});
|
||||
}
|
||||
}
|
||||
|
||||
// Get older messages and show/hide [go to latest message] button.
|
||||
handleScrollEvent(event) {
|
||||
this.setState({
|
||||
// Show [prev] / [next] as appropriate.
|
||||
prevVisible: event.target.scrollLeft > VC_CAROUSEL_ITEM_GAP,
|
||||
nextVisible: event.target.scrollWidth - event.target.scrollLeft - event.target.offsetWidth > VC_CAROUSEL_ITEM_GAP
|
||||
});
|
||||
}
|
||||
|
||||
handleScrollClick(next) {
|
||||
// this.carouselRef.offsetWidth - is the width of the visible window.
|
||||
const val = this.carouselRef.offsetWidth + VC_CAROUSEL_ITEM_GAP;
|
||||
this.carouselRef.scrollBy(next ? val : -val, 0);
|
||||
}
|
||||
|
||||
render() {
|
||||
let peers = [];
|
||||
if (this.props.participants) {
|
||||
Object.entries(this.props.participants).forEach(([identity, party], i) => {
|
||||
const participant = party.participant;
|
||||
const cameraPub = participant.getTrack(Track.Source.Camera);
|
||||
const micPub = participant.getTrack(Track.Source.Microphone);
|
||||
|
||||
peers.push(<VCCarouselItem
|
||||
tinode={this.props.tinode}
|
||||
isSpeaking={participant.isSpeaking}
|
||||
cameraPub={cameraPub ? cameraPub : null}
|
||||
micPub={micPub ? micPub : null}
|
||||
name={party.name}
|
||||
userId={identity}
|
||||
photo={party.photo}
|
||||
key={i}
|
||||
/>);
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div id="carousel-wrapper">
|
||||
<div id="carousel" ref={this.handleScrollReference}>
|
||||
<div id="carousel-content" ref={this.contentRef}>
|
||||
{peers}
|
||||
</div>
|
||||
</div>
|
||||
{this.state.prevVisible ?
|
||||
<button id="carousel-prev" className="action-button" onClick={_ => this.handleScrollClick(false)}>
|
||||
<i className="material-icons">navigate_before</i>
|
||||
</button>
|
||||
:
|
||||
null}
|
||||
{this.state.nextVisible ?
|
||||
<button id="carousel-next" className="action-button" onClick={_ => this.handleScrollClick(true)}>
|
||||
<i className="material-icons">navigate_next</i>
|
||||
</button>
|
||||
:
|
||||
null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
// Video conference or broadcast: local and/or peer viewports and controls.
|
||||
import React from 'react';
|
||||
import { FormattedMessage, injectIntl } from 'react-intl';
|
||||
|
||||
import {
|
||||
LocalVideoTrack,
|
||||
MediaDeviceFailure,
|
||||
ParticipantEvent,
|
||||
RemoteParticipant,
|
||||
Room,
|
||||
RoomEvent,
|
||||
Track,
|
||||
VideoPresets
|
||||
} from 'livekit-client';
|
||||
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
import VCCarousel from './vc-carousel.jsx';
|
||||
import { makeImageUrl } from '../lib/blob-helpers.js';
|
||||
|
||||
import { MAX_PEER_TITLE_LENGTH } from '../config.js';
|
||||
import { CALL_STATE_OUTGOING_INITATED } from '../constants.js';
|
||||
|
||||
import { clipStr } from '../lib/utils.js'
|
||||
|
||||
class VCPanel extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
room: undefined,
|
||||
|
||||
participants: {},
|
||||
activeSpeaker: undefined,
|
||||
|
||||
previousOnInfo: undefined,
|
||||
};
|
||||
|
||||
this.isOutgoingCall = props.callState == CALL_STATE_OUTGOING_INITATED;
|
||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||
this.isBroadcast = topic && topic.chan;
|
||||
|
||||
this.localRef = React.createRef();
|
||||
this.remoteRef = React.createRef();
|
||||
|
||||
this.onInfo = this.onInfo.bind(this);
|
||||
this.start = this.start.bind(this);
|
||||
this.stop = this.stop.bind(this);
|
||||
|
||||
this.reportError = this.reportError.bind(this);
|
||||
|
||||
this.handleCloseClick = this.handleCloseClick.bind(this);
|
||||
this.handleToggleCameraClick = this.handleToggleCameraClick.bind(this);
|
||||
this.handleToggleMicClick = this.handleToggleMicClick.bind(this);
|
||||
|
||||
this.handleRemoteHangup = this.handleRemoteHangup.bind(this);
|
||||
|
||||
this.handleTrackSubscribed = this.handleTrackSubscribed.bind(this);
|
||||
this.handleTrackUnsubscribed = this.handleTrackUnsubscribed.bind(this);
|
||||
this.handleActiveSpeakerChange = this.handleActiveSpeakerChange.bind(this);
|
||||
this.handleDisconnect = this.handleDisconnect.bind(this);
|
||||
this.handleLocalTrackUnpublished = this.handleLocalTrackUnpublished.bind(this);
|
||||
this.handleLocalTrackPublished = this.handleLocalTrackPublished.bind(this);
|
||||
this.handleParticipantConnected = this.handleParticipantConnected.bind(this);
|
||||
this.handleParticipantDisconnected = this.handleParticipantDisconnected.bind(this);
|
||||
this.handleData = this.handleData.bind(this);
|
||||
this.handleDevicesChanged = this.handleDevicesChanged.bind(this);
|
||||
this.startRoom = this.startRoom.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||
this.previousOnInfo = topic.onInfo;
|
||||
topic.onInfo = this.onInfo;
|
||||
this.start();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||
if (topic) {
|
||||
topic.onInfo = this.previousOnInfo;
|
||||
}
|
||||
this.stop();
|
||||
}
|
||||
|
||||
onInfo(info) {
|
||||
if (info.what != 'call') {
|
||||
return;
|
||||
}
|
||||
switch (info.event) {
|
||||
case 'vc-token':
|
||||
const ts = info.payload;
|
||||
this.setState({token: ts.token});
|
||||
this.startRoom(ts.token);
|
||||
break;
|
||||
case 'hang-up':
|
||||
this.handleRemoteHangup(info);
|
||||
break;
|
||||
default:
|
||||
console.warn("Unknown call event", info.event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
handleParticipantConnected(participant) {
|
||||
if (!participant) return;
|
||||
if (participant instanceof RemoteParticipant) {
|
||||
participant
|
||||
.on(ParticipantEvent.TrackMuted, pub => {
|
||||
console.log('track was muted', pub.trackSid, participant.identity);
|
||||
this.forceUpdate(); // Redraw carousel.
|
||||
})
|
||||
.on(ParticipantEvent.TrackUnmuted, pub => {
|
||||
console.log('track was unmuted', pub.trackSid, participant.identity);
|
||||
this.forceUpdate(); // Redraw carousel.
|
||||
})
|
||||
.on(ParticipantEvent.IsSpeakingChanged, _ => {
|
||||
this.forceUpdate(); // Redraw carousel.
|
||||
})
|
||||
.on(ParticipantEvent.ConnectionQualityChanged, _ => {
|
||||
this.forceUpdate(); // Redraw carousel.
|
||||
});
|
||||
|
||||
const me = this.props.tinode.getMeTopic();
|
||||
const c = me.getContact(participant.identity);
|
||||
|
||||
const participants = Object.assign({}, this.state.participants);
|
||||
participants[participant.identity] = {
|
||||
name: c && c.public && c.public.fn ? c.public.fn : participant.identity,
|
||||
photo: makeImageUrl(c && c.public ? c.public.photo : null),
|
||||
participant: participant
|
||||
};
|
||||
this.setState({participants: participants});
|
||||
}
|
||||
}
|
||||
|
||||
handleParticipantDisconnected(participant) {
|
||||
const participants = Object.assign({}, this.state.participants);
|
||||
delete participants[participant.identity];
|
||||
this.setState({participants: participants});
|
||||
}
|
||||
|
||||
handleData(msg, participant) {
|
||||
console.log('Data message ', msg, participant);
|
||||
}
|
||||
|
||||
handleTrackSubscribed(track, publication, participant) {
|
||||
if (participant instanceof RemoteParticipant) {
|
||||
// Check if we're presenting a remote video stream.
|
||||
if (this.remoteRef.current && !(this.remoteRef.current.src != '' || this.remoteRef.srcObject)) {
|
||||
track.attach(this.remoteRef.current);
|
||||
}
|
||||
}
|
||||
// Redraw the carousel and remote view.
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
handleTrackUnsubscribed(track, publication, participant) {
|
||||
console.log('handleTrackUnsubscribed', track);
|
||||
// remove tracks from all attached elements
|
||||
track.detach();
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
handleLocalTrackPublished(pub) {
|
||||
console.log('Local track published', pub);
|
||||
const track = pub.track;
|
||||
if ((track instanceof LocalVideoTrack) && this.localRef.current) {
|
||||
const elem = this.localRef.current;
|
||||
track.attach(elem);
|
||||
// flip.
|
||||
elem.style.transform = 'scale(-1, 1)';
|
||||
}
|
||||
}
|
||||
|
||||
handleLocalTrackUnpublished(pub) {
|
||||
const track = pub.track;
|
||||
console.log('handleLocalTrackUnpublished', track);
|
||||
track.detach();
|
||||
// Redraw carousel.
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
handleActiveSpeakerChange(speakers) {
|
||||
console.log('handleActiveSpeakerChange', speakers);
|
||||
speakers.forEach(s => {
|
||||
if (s.isSpeaking) {
|
||||
console.log('Speaking participant ', s);
|
||||
|
||||
// Display speaker in remote peer view.
|
||||
const elem = this.remoteRef.current;
|
||||
const me = this.props.tinode.getMeTopic();
|
||||
const c = me.getContact(s.identity);
|
||||
const name = c && c.public && c.public.fn ? c.public.fn : undefined;
|
||||
this.setState({activeSpeaker: name});
|
||||
|
||||
const cameraPub = s.getTrack(Track.Source.Camera);
|
||||
const cameraEnabled = cameraPub && cameraPub.isSubscribed && !cameraPub.isMuted;
|
||||
if (cameraEnabled && cameraPub.videoTrack) {
|
||||
cameraPub.videoTrack.attach(elem);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleDevicesChanged() {
|
||||
console.log('devices changed');
|
||||
// TODO: implement.
|
||||
}
|
||||
|
||||
handleDisconnect() {
|
||||
console.log('disconnected from room');
|
||||
this.handleCloseClick();
|
||||
}
|
||||
|
||||
startRoom(token) {
|
||||
const roomOpts = {
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
publishDefaults: {
|
||||
simulcast: true,
|
||||
videoSimulcastLayers: [VideoPresets.h90, VideoPresets.h216],
|
||||
videoCodec: 'vp8'
|
||||
},
|
||||
videoCaptureDefaults: {
|
||||
resolution: VideoPresets.h720.resolution
|
||||
}
|
||||
};
|
||||
|
||||
const connectOpts = {
|
||||
autoSubscribe: true
|
||||
};
|
||||
|
||||
// creates a new room with options
|
||||
const room = new Room(roomOpts);
|
||||
|
||||
this.setState({room: room});
|
||||
|
||||
const startTime = Date.now();
|
||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||
const shouldPublish = this.isOutgoingCall || !this.isBroadcast;
|
||||
|
||||
room
|
||||
.on(RoomEvent.ParticipantConnected, this.handleParticipantConnected)
|
||||
.on(RoomEvent.ParticipantDisconnected, this.handleParticipantDisconnected)
|
||||
.on(RoomEvent.DataReceived, this.handleData)
|
||||
.on(RoomEvent.TrackSubscribed, this.handleTrackSubscribed)
|
||||
.on(RoomEvent.TrackUnsubscribed, this.handleTrackUnsubscribed)
|
||||
.on(RoomEvent.ActiveSpeakersChanged, this.handleActiveSpeakerChange)
|
||||
.on(RoomEvent.Disconnected, this.handleDisconnect)
|
||||
.on(RoomEvent.LocalTrackUnpublished, this.handleLocalTrackUnpublished)
|
||||
.on(RoomEvent.LocalTrackPublished, this.handleLocalTrackPublished)
|
||||
|
||||
.on(RoomEvent.RoomMetadataChanged, metadata => {
|
||||
console.log('VC room new metadata ', metadata);
|
||||
})
|
||||
.on(RoomEvent.MediaDevicesChanged, this.handleDevicesChanged)
|
||||
.on(RoomEvent.AudioPlaybackStatusChanged, e => {
|
||||
// TODO: handle it.
|
||||
console.log('Audio playback status change: ', e);
|
||||
})
|
||||
.on(RoomEvent.MediaDevicesError, e => {
|
||||
const failure = MediaDeviceFailure.getFailure(e);
|
||||
this.reportError(failure);
|
||||
})
|
||||
.on(RoomEvent.ConnectionQualityChanged, (quality, participant) => {
|
||||
const id = participant ? participant.identity : 'nil';
|
||||
console.log('Connection quality changed', id, ' -> ', quality);
|
||||
})
|
||||
.on(RoomEvent.SignalConnected, _ => {
|
||||
const signalConnectionTime = Date.now() - startTime;
|
||||
console.log(`signal connection established in ${signalConnectionTime}ms`);
|
||||
// speed up publishing by starting to publish before it's fully connected
|
||||
// publishing is accepted as soon as signal connection has established
|
||||
if (shouldPublish) {
|
||||
room.localParticipant.enableCameraAndMicrophone();
|
||||
console.log(`tracks published in ${Date.now() - startTime}ms`);
|
||||
}
|
||||
});
|
||||
|
||||
const vcEndpoint = this.props.tinode.getServerParam('vcEndpoint', null);
|
||||
room.connect(vcEndpoint, token, connectOpts).then(_ => {
|
||||
console.log('Connected to VC room', room.name, '. Access token', token);
|
||||
|
||||
room.participants.forEach((participant) => {
|
||||
this.handleParticipantConnected(participant);
|
||||
});
|
||||
this.handleParticipantConnected(room.localParticipant);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.state.localStream) {
|
||||
this.props.onError(this.props.intl.formatMessage(messages.already_in_call), 'info');
|
||||
return;
|
||||
}
|
||||
if (this.isOutgoingCall) {
|
||||
this.props.onCreateCall(this.props.topic, this.props.seq, this.props.callState, this.props.callAudioOnly)
|
||||
.then(ctrl => {
|
||||
const token = ctrl.params.token;
|
||||
const seq = ctrl.params.seq;
|
||||
this.setState({callSeq: seq, token: token});
|
||||
this.startRoom(token);
|
||||
});
|
||||
} else {
|
||||
this.props.onJoin(this.props.topic, this.props.seq);
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.state.room) {
|
||||
this.state.room.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
reportError(err) {
|
||||
this.props.onError(err.message, 'err');
|
||||
}
|
||||
|
||||
// Call disconnected by remote.
|
||||
handleRemoteHangup() {
|
||||
this.handleCloseClick();
|
||||
}
|
||||
|
||||
handleCloseClick() {
|
||||
this.stop();
|
||||
this.props.onHangup(this.props.topic, this.props.seq);
|
||||
}
|
||||
|
||||
handleToggleCameraClick() {
|
||||
if (!this.state.room) return;
|
||||
const enabled = this.state.room.localParticipant.isCameraEnabled;
|
||||
this.state.room.localParticipant.setCameraEnabled(!enabled)
|
||||
.then(_ => this.forceUpdate());
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
handleToggleMicClick() {
|
||||
if (!this.state.room) return;
|
||||
const enabled = this.state.room.localParticipant.isMicrophoneEnabled;
|
||||
this.state.room.localParticipant.setMicrophoneEnabled(!enabled)
|
||||
.then(_ => this.forceUpdate());
|
||||
|
||||
// Make sure we redraw the mute/unmute icons (e.g. mic -> mic_off).
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
render() {
|
||||
const isPublishing = this.isOutgoingCall || !this.isBroadcast;
|
||||
const disabled = !isPublishing || !this.state.room || !this.state.room.localParticipant;
|
||||
const audioIcon = !disabled && this.state.room.localParticipant.isMicrophoneEnabled ? 'mic' : 'mic_off';
|
||||
const videoIcon = !disabled && this.state.room.localParticipant.isCameraEnabled ? 'videocam' : 'videocam_off';
|
||||
|
||||
const peerTitle = this.state.activeSpeaker ? this.state.activeSpeaker : clipStr(this.props.title, MAX_PEER_TITLE_LENGTH);
|
||||
|
||||
const remoteActive = this.remoteRef.current && (this.remoteRef.current.src || this.remoteRef.current.srcObject)
|
||||
|
||||
return (
|
||||
<div id="video-container">
|
||||
{!this.isBroadcast && Object.keys(this.state.participants).length > 0 ?
|
||||
<VCCarousel
|
||||
viewportWidth={this.props.viewportWidth}
|
||||
tinode={this.props.tinode}
|
||||
participants={this.state.participants} /> :
|
||||
null
|
||||
}
|
||||
<div id="video-container-panel">
|
||||
{isPublishing ?
|
||||
<div className="call-party self" disabled={this.state.audioOnly}>
|
||||
<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> :
|
||||
null }
|
||||
<div className="call-party peer" disabled={!remoteActive}>
|
||||
<video ref={this.remoteRef} autoPlay playsInline />
|
||||
{remoteActive ?
|
||||
<div className="caller-name inactive">{peerTitle}</div>
|
||||
:
|
||||
<div className="caller-card">
|
||||
<div className="avatar-box">
|
||||
<LetterTile
|
||||
tinode={this.props.tinode}
|
||||
avatar={this.props.avatar}
|
||||
topic={this.props.topic}
|
||||
title={this.props.title} />
|
||||
</div>
|
||||
<div className="caller-name">{peerTitle}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className="controls">
|
||||
<button className="danger" onClick={this.handleCloseClick}>
|
||||
<i className="material-icons">call_end</i>
|
||||
</button>
|
||||
<button className="secondary" onClick={this.handleToggleCameraClick} disabled={disabled}>
|
||||
<i className="material-icons">{videoIcon}</i>
|
||||
</button>
|
||||
<button className="secondary" onClick={this.handleToggleMicClick} disabled={disabled}>
|
||||
<i className="material-icons">{audioIcon}</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default injectIntl(VCPanel);
|
||||
@@ -67,10 +67,11 @@ export default class VisiblePassword extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div tabIndex="-1" className="group-focus" onBlur={this.handleEditingFinished}>
|
||||
<input className="with-visibility"
|
||||
<input className="with-icon-right"
|
||||
type={this.state.visible ? 'text' : 'password'}
|
||||
value={this.state.value}
|
||||
placeholder={this.props.placeholder}
|
||||
maxLength={32}
|
||||
required={this.props.required ? 'required' : ''}
|
||||
autoFocus={this.props.autoFocus ? 'autoFocus' : ''}
|
||||
autoComplete={this.props.autoComplete}
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"316.prod.js","mappings":"8KASA,MAAMA,UAA6BC,IAAAA,cACjCC,WAAAA,CAAYC,GACVC,MAAMD,GAENE,KAAKC,UAAY,GACjB,MAAM,kBAAEC,GAAsBJ,EAAMK,KACpCC,EAAeC,SAAQC,IACPA,EAAGC,KAAKC,MAAM,KACtBH,SAAQI,IACZT,KAAKC,UAAUS,KAAK,CAClBH,KAAME,EAAKE,OACXC,KAAMN,EAAGM,KACTC,MAAMC,EAAAA,EAAAA,IAAUR,EAAGM,MACnBG,KAAMb,EAAkBI,EAAGM,KAAM,CAACI,KAAM,YACxC,GACF,IAGJhB,KAAKC,UAAUgB,MAAK,CAACC,EAAGC,IAAMD,EAAEH,KAAKK,cAAcD,EAAEJ,OACvD,CAEAM,iBAAAA,GACMrB,KAAKsB,aACPtB,KAAKsB,YAAYC,eAAe,CAACC,MAAO,SAAUC,OAAQ,WAE9D,CAEAC,MAAAA,GACE,MAAMzB,EAAY,GACZ0B,EAAW3B,KAAKF,MAAM6B,UAAY,KAYxC,OAXA3B,KAAKC,UAAUI,SAAQ,CAACuB,EAAGC,KACzB,MAAMC,EAASF,EAAEhB,MAAQe,EAAW,YAAc,GAClD1B,EAAUS,KAAKd,IAAAA,cAAA,MAAImC,UAAWD,EAAOE,IAAKH,EACxCI,IAAKA,IAAYL,EAAEhB,MAAQe,IAAW3B,KAAKsB,YAAcW,EAAG,EAC5DC,QAASC,GAAKnC,KAAKF,MAAMsC,SAASR,EAAEhB,KAAMgB,EAAErB,OAC5CX,IAAAA,cAAA,QAAMmC,UAAU,gBAAgBH,EAAEf,MAClCjB,IAAAA,cAAA,QAAMmC,UAAU,WAAU,IAAOH,EAAEb,MACnCnB,IAAAA,cAAA,QAAMmC,UAAU,aAAY,KAAQH,EAAErB,OAClC,IAINX,IAAAA,cAAA,OAAKmC,UAAU,mBAAmBD,MAAO,CAACO,OAAQ,UAChDzC,IAAAA,cAAA,MAAImC,UAAU,0BACX9B,GAIT,EAGF,WAAeqC,EAAAA,EAAAA,YAAW3C,E","sources":["webpack://tinode-webapp/./src/widgets/phone-country-selector.jsx"],"sourcesContent":["// Selector for country dialing code.\n\nimport React from 'react';\nimport { injectIntl } from 'react-intl';\n\nimport * as dcodes from '../dcodes.json';\n\nimport { flagEmoji } from '../lib/strformat';\n\nclass PhoneCountrySelector extends React.PureComponent {\n constructor(props) {\n super(props);\n\n this.countries = [];\n const { formatDisplayName } = props.intl;\n dcodes.default.forEach(dc => {\n const parts = dc.dial.split(',');\n parts.forEach(part => {\n this.countries.push({\n dial: part.trim(),\n code: dc.code,\n flag: flagEmoji(dc.code),\n name: formatDisplayName(dc.code, {type: 'region'})\n });\n });\n });\n\n this.countries.sort((a, b) => a.name.localeCompare(b.name));\n }\n\n componentDidMount() {\n if (this.selectedRef) {\n this.selectedRef.scrollIntoView({block: 'center', inline: 'nearest'});\n }\n }\n\n render() {\n const countries = [];\n const selected = this.props.selected || 'US';\n this.countries.forEach((c, idx) => {\n const style = (c.code == selected ? 'selected ' : '');\n countries.push(<li className={style} key={idx}\n ref={ref => {if (c.code == selected) {this.selectedRef = ref}}}\n onClick={_ => this.props.onSubmit(c.code, c.dial)}>\n <span className=\"country-flag\">{c.flag}</span>\n <span className=\"country\"> {c.name}</span>\n <span className=\"dial-code\"> +{c.dial}</span>\n </li>);\n });\n\n return (\n <div className=\"scrollable-panel\" style={{height: '30rem'}}>\n <ul className=\"phone-country-selector\">\n {countries}\n </ul>\n </div>\n );\n }\n}\n\nexport default injectIntl(PhoneCountrySelector);\n"],"names":["PhoneCountrySelector","React","constructor","props","super","this","countries","formatDisplayName","intl","dcodes","forEach","dc","dial","split","part","push","trim","code","flag","flagEmoji","name","type","sort","a","b","localeCompare","componentDidMount","selectedRef","scrollIntoView","block","inline","render","selected","c","idx","style","className","key","ref","onClick","_","onSubmit","height","injectIntl"],"sourceRoot":""}
|
||||
{"version":3,"file":"316.prod.js","mappings":"qkNASA,MAAMA,UAA6BC,IAAAA,cACjCC,WAAAA,CAAYC,GACVC,MAAMD,GAENE,KAAKC,UAAY,GACjB,MAAM,kBAAEC,GAAsBJ,EAAMK,KACpCC,EAAeC,SAAQC,IACPA,EAAGC,KAAKC,MAAM,KACtBH,SAAQI,IACZT,KAAKC,UAAUS,KAAK,CAClBH,KAAME,EAAKE,OACXC,KAAMN,EAAGM,KACTC,MAAMC,EAAAA,EAAAA,IAAUR,EAAGM,MACnBG,KAAMb,EAAkBI,EAAGM,KAAM,CAACI,KAAM,YACxC,GACF,IAGJhB,KAAKC,UAAUgB,MAAK,CAACC,EAAGC,IAAMD,EAAEH,KAAKK,cAAcD,EAAEJ,OACvD,CAEAM,iBAAAA,GACMrB,KAAKsB,aACPtB,KAAKsB,YAAYC,eAAe,CAACC,MAAO,SAAUC,OAAQ,WAE9D,CAEAC,MAAAA,GACE,MAAMzB,EAAY,GACZ0B,EAAW3B,KAAKF,MAAM6B,UAAY,KAYxC,OAXA3B,KAAKC,UAAUI,SAAQ,CAACuB,EAAGC,KACzB,MAAMC,EAASF,EAAEhB,MAAQe,EAAW,YAAc,GAClD1B,EAAUS,KAAKd,IAAAA,cAAA,MAAImC,UAAWD,EAAOE,IAAKH,EACxCI,IAAKA,IAAYL,EAAEhB,MAAQe,IAAW3B,KAAKsB,YAAcW,EAAG,EAC5DC,QAASC,GAAKnC,KAAKF,MAAMsC,SAASR,EAAEhB,KAAMgB,EAAErB,OAC5CX,IAAAA,cAAA,QAAMmC,UAAU,gBAAgBH,EAAEf,MAClCjB,IAAAA,cAAA,QAAMmC,UAAU,WAAU,IAAOH,EAAEb,MACnCnB,IAAAA,cAAA,QAAMmC,UAAU,aAAY,KAAQH,EAAErB,OAClC,IAINX,IAAAA,cAAA,OAAKmC,UAAU,mBAAmBD,MAAO,CAACO,OAAQ,UAChDzC,IAAAA,cAAA,MAAImC,UAAU,0BACX9B,GAIT,EAGF,WAAeqC,EAAAA,EAAAA,YAAW3C,E","sources":["webpack://tinode-webapp/./src/widgets/phone-country-selector.jsx"],"sourcesContent":["// Selector for country dialing code.\n\nimport React from 'react';\nimport { injectIntl } from 'react-intl';\n\nimport * as dcodes from '../dcodes.json';\n\nimport { flagEmoji } from '../lib/strformat';\n\nclass PhoneCountrySelector extends React.PureComponent {\n constructor(props) {\n super(props);\n\n this.countries = [];\n const { formatDisplayName } = props.intl;\n dcodes.default.forEach(dc => {\n const parts = dc.dial.split(',');\n parts.forEach(part => {\n this.countries.push({\n dial: part.trim(),\n code: dc.code,\n flag: flagEmoji(dc.code),\n name: formatDisplayName(dc.code, {type: 'region'})\n });\n });\n });\n\n this.countries.sort((a, b) => a.name.localeCompare(b.name));\n }\n\n componentDidMount() {\n if (this.selectedRef) {\n this.selectedRef.scrollIntoView({block: 'center', inline: 'nearest'});\n }\n }\n\n render() {\n const countries = [];\n const selected = this.props.selected || 'US';\n this.countries.forEach((c, idx) => {\n const style = (c.code == selected ? 'selected ' : '');\n countries.push(<li className={style} key={idx}\n ref={ref => {if (c.code == selected) {this.selectedRef = ref}}}\n onClick={_ => this.props.onSubmit(c.code, c.dial)}>\n <span className=\"country-flag\">{c.flag}</span>\n <span className=\"country\"> {c.name}</span>\n <span className=\"dial-code\"> +{c.dial}</span>\n </li>);\n });\n\n return (\n <div className=\"scrollable-panel\" style={{height: '30rem'}}>\n <ul className=\"phone-country-selector\">\n {countries}\n </ul>\n </div>\n );\n }\n}\n\nexport default injectIntl(PhoneCountrySelector);\n"],"names":["PhoneCountrySelector","React","constructor","props","super","this","countries","formatDisplayName","intl","dcodes","forEach","dc","dial","split","part","push","trim","code","flag","flagEmoji","name","type","sort","a","b","localeCompare","componentDidMount","selectedRef","scrollIntoView","block","inline","render","selected","c","idx","style","className","key","ref","onClick","_","onSubmit","height","injectIntl"],"sourceRoot":""}
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+7512
-7624
File diff suppressed because it is too large
Load Diff
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
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
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user