Compare commits

...
19 Commits
Author SHA1 Message Date
or-else 14b38af8da merge devel 2025-04-14 09:54:36 +03:00
or-else 3ae00646c7 rebuit with fixed sdk 2025-04-14 09:24:50 +03:00
or-else 43fde20643 fix unread count for topics with all messages deleted 2025-04-10 16:20:33 +03:00
or-else 6527c067c6 all translations done; 2025-03-12 21:11:31 +03:00
or-else 2f374e52ca translations: de, es, fr, ro, ru, uk 2025-03-12 20:23:33 +03:00
or-else b1acfecd4f fixing translations 2025-03-12 19:40:33 +03:00
or-else 5448f10b1a cosmetic alias-related bugs 2025-03-09 10:28:35 +03:00
or-else 3b78971a39 fix menu [delete for all] 2025-03-06 16:41:25 +03:00
or-else 11436a80ee support fro unique aliases, https://github.com/tinode/chat/issues/446 2025-03-04 13:05:51 +03:00
or-else 3567f323c7 add tests for lib/utils.js 2025-03-03 21:07:40 +03:00
or-else 6f05fd102f merge devel 2025-02-23 11:05:57 +03:00
or-else 4897d63029 bug fix: incorrect QR code in topic info 2025-02-23 11:05:00 +03:00
or-else 5e92189b97 ability to hard-delete p2p messages, debugged pinned messages 2025-02-22 18:55:53 +03:00
or-else 30bf77d4ba sending messages to self works 2025-02-16 22:06:02 +03:00
or-else b2772924bd more vc cleanup 2025-02-15 20:41:52 +03:00
or-else 6c1c8cf336 removed incomplete video conferences 2025-02-14 21:40:44 +03:00
or-else f91d8a979d implement touch swiping pinned messages 2025-02-14 17:38:49 +03:00
or-else 17b91c4bb8 prevent accidental clicks from tiggering scroll 2025-02-09 18:46:36 +03:00
or-else a3a4fe7505 dependencies updated, do not update react to 19 - buggy 2025-02-08 21:54:45 +03:00
110 changed files with 15184 additions and 10612 deletions
+97 -78
View File
@@ -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 */
@keyframes fadein {
from {
bottom: 0;
opacity: 0;
}
to {
bottom: 2rem;
opacity: 1;
}
#carousel::-webkit-scrollbar {
display: none; /* Hide scrollbar in Safari and Chrome */
}
#carousel-content {
display: grid;
grid-gap: 1rem;
grid-auto-flow: column;
@keyframes fadeout {
from {
bottom: 2rem;
opacity: 1;
}
#carousel-prev {
top: 50%;
left: 0;
transform: translate(50%, -50%);
to {
bottom: 0;
opacity: 0;
}
#carousel-next {
top: 50%;
right: 0;
transform: translate(-50%, -50%);
}
.carousel-item {
position: relative;
width: 8rem;
height: 8rem;
}
/*
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -3
View File
@@ -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
View File
@@ -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>
+4488 -980
View File
File diff suppressed because it is too large Load Diff
+14 -13
View File
@@ -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
View File
@@ -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;
-2
View File
@@ -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;
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+77 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
+43 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
+1 -3
View File
@@ -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
View File
@@ -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;
}
+127
View File
@@ -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
View File
@@ -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";
+8 -1
View File
@@ -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>&nbsp;
<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>&nbsp;
<tt>@{this.state.alias}</tt>
</div> : null}
<div className="group">
<BadgeList trustedBadges={this.props.trustedBadges} />
</div>
+80 -16
View File
@@ -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');}}>
{!this.state.isSelf ?
<a href="#" className="flat-button float-right" onClick={e => {e.preventDefault(); this.props.onNavigate('general');}}>
<i className="material-icons">edit</i>&nbsp;
<FormattedMessage id="button_edit" defaultMessage="Edit" description="Call to action [Edit]" />
</a>
: null
}
<center>
<AvatarUpload
tinode={this.props.tinode}
@@ -569,6 +604,8 @@ class InfoView extends React.Component {
</div>
: null
}
{!this.state.isSelf ?
<>
<div className="panel-form-row">
<div>
<label className="small"><FormattedMessage id="label_user_id" defaultMessage="ID:"
@@ -576,7 +613,7 @@ class InfoView extends React.Component {
<tt>{this.state.address}</tt>
</div>
<div style={{marginLeft: 'auto'}}>
&nbsp;<a href="#" onClick={this.handleCopyID}>
&nbsp;<a href="#" onClick={e => {this.handleCopyToClipboard(e, this.state.address);}}>
<i className="material-icons">content_copy</i>
</a>&nbsp;
&nbsp;<a href="#" onClick={this.handleShowQRCode}>
@@ -584,9 +621,30 @@ class InfoView extends React.Component {
</a>&nbsp;
</div>
</div>
{alias ?
<div className="panel-form-row">
<div>
<label className="small"><FormattedMessage id="label_alias" defaultMessage="Alias:"
description="Label for user or topic alias" /></label>&nbsp;
<tt>{alias}</tt>
</div>
<div style={{marginLeft: 'auto'}}>
&nbsp;<a href="#" onClick={e => {this.handleCopyToClipboard(e, alias);}}>
<i className="material-icons">content_copy</i>
</a>&nbsp;
&nbsp;<i className="material-icons" style={{opacity: 0}}>qr_code</i>&nbsp;
</div>
</div>
: null}
</>
: null
}
{this.state.trustedBadges.length > 0 ?
<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" />
{!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>
</div></>
: null
}
{this.state.archived ?
<div className="panel-form-row">
<label>
@@ -615,13 +676,16 @@ class InfoView extends React.Component {
:
null
}
<div className="hr" />
{!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>&nbsp;<FormattedMessage id="button_security"
defaultMessage="Security" description="Navigaton button for security panel." />
</a>
</div>
</div></>
: null
}
{this.state.groupTopic && this.state.sharer ?
<>
<div className="hr" />
+77 -34
View File
@@ -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();
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) {
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);
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,14 +919,37 @@ class MessagesView extends React.Component {
const menuItems = messageSpecificMenuItems || [];
const topic = this.props.tinode.getTopic(params.topicName);
if (topic) {
// 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,7 +1513,6 @@ class MessagesView extends React.Component {
);
} else if (this.state.rtcPanel) {
// P2P call.
if (Tinode.isP2PTopicName(this.state.rtcPanel)) {
component2 = (
<CallPanel
topic={this.state.topic}
@@ -1472,26 +1530,6 @@ class MessagesView extends React.Component {
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} />
);
}
} 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 ?
<>
{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}
+20 -1
View File
@@ -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
View File
@@ -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}
+2 -17
View File
@@ -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>;
}
+2 -7
View File
@@ -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';
+21
View File
@@ -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) {
+26 -4
View File
@@ -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) {
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">
+8
View File
@@ -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),
+2
View File
@@ -75,9 +75,11 @@ export default class Cropper extends React.Component {
}
componentWillUnmount() {
if (this.overlay.current) {
this.overlay.current.removeEventListener('mousedown', this.mouseDown);
this.overlay.current.removeEventListener('touchstart', this.mouseDown);
}
}
// Position all elements.
positionAll(panX, panY, zoom) {
+36 -3
View File
@@ -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);
}
};
+8 -1
View File
@@ -5,12 +5,18 @@ 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) {
if (Tinode.isSelfTopicName(this.props.topic)) {
avatar = <img className="avatar self" alt="avatar" src={SELF_AVATAR_URI} />
} else {
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' : '');
@@ -19,12 +25,13 @@ export default class LetterTile extends React.PureComponent {
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;
}
+102 -22
View File
@@ -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, {
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)
}) : null;
})
:
<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">
{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">
{this.props.selected > 0 ?
<a href="#" onClick={this.handleMoveNext}>
<i className="material-icons">expand_more</i>
</a>
: null
}
</span>
: null}
</div>) : null;
</div>
</div>
);
}
}
+5 -3
View File
@@ -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) {
+128 -6
View File
@@ -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);
-97
View File
@@ -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>
);
}
}
-104
View File
@@ -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>
);
}
}
-412
View File
@@ -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);
+2 -1
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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\">&nbsp;{c.name}</span>\n <span className=\"dial-code\">&nbsp;+{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\">&nbsp;{c.name}</span>\n <span className=\"dial-code\">&nbsp;+{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
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+5409 -5521
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
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