Compare commits

..
51 Commits
Author SHA1 Message Date
or-else d8f7a41bdd password reset works, missing translations 2023-01-26 15:58:33 -08:00
or-else 6c36324ed4 phone number widget fixes 2023-01-25 18:41:19 -08:00
or-else e2f3201ddb phone editing widget works 2023-01-25 17:08:07 -08:00
or-else 34f17f45b1 Merge branch 'next' into phone 2023-01-25 11:32:24 -08:00
or-else 1cf4722830 fix: broken alert view 2023-01-25 11:29:26 -08:00
or-else 7c4327ac71 bump version to 0.21.0-rc2 2023-01-24 09:01:07 -08:00
or-else 48caa605c7 work in progress 2023-01-24 08:53:08 -08:00
aforge edb1383d60 Merge branch 'next' of https://github.com/tinode/webapp into next 2023-01-23 13:33:11 -08:00
aforge 2a653f2299 Fix missing audio. 2023-01-23 13:27:50 -08:00
or-else eba83fdec8 support for registering with a phone number 2023-01-23 10:48:43 -08:00
or-else 5f173dd395 merge next 2023-01-23 10:24:48 -08:00
or-else 2115f6d638 merge devel 2023-01-23 10:23:04 -08:00
or-else 5228d44ade rebuilt 2023-01-23 10:19:43 -08:00
or-else ce19ebbbdd fix for https://github.com/tinode/chat/issues/825 2023-01-23 10:18:22 -08:00
or-else 5e60a5e04d call parent on lost focuse 2023-01-23 10:00:07 -08:00
or-else 9f523b54a4 attempt to integrate phone editing 2023-01-22 20:04:13 -08:00
or-else 6c13943f1a forgot to add new files 2023-01-22 19:26:01 -08:00
or-else b0bf93a4d9 phone number editing controls are functional 2023-01-22 18:57:19 -08:00
aforge b766e827c9 Set data channel in state. 2023-01-18 14:10:49 -08:00
or-else 424a8b64bf remove autoFocus from message input field on mobile, #111 2023-01-17 12:50:58 -08:00
or-else 9108efc8c9 merge google analytics branch 2023-01-14 12:54:53 -08:00
or-else f94c3612be Merge branch 'next' into devel 2023-01-14 11:47:03 -08:00
or-else 95ddfd21eb version bumped to 0.21.0-rc1, rebuilt 2023-01-14 11:46:50 -08:00
Gene ae5618c072 Merge pull request #108 from tinode/audio-only
Audio-only calls
2023-01-14 11:09:51 -08:00
or-else d083a286df correct comment wording 2023-01-13 19:00:26 -08:00
or-else e0db170aec merge next 2023-01-13 17:28:06 -08:00
or-else e9d0f0f1f3 remove styling from focused buttons 2023-01-13 17:25:57 -08:00
or-else 08d4c08cbc optional integration of google analytics 2023-01-12 20:15:08 -08:00
or-else 15d24c6607 do not logout of server errors 500 or timeouts 2023-01-11 09:03:48 -08:00
or-else c085ef8ce1 make disabled buttons more obviously disabled 2023-01-10 17:59:12 -08:00
or-else 2d76befea1 fix missed calls when topic is not in focus 2023-01-10 17:18:34 -08:00
or-else 20c54fb7e3 removed debug logging 2023-01-10 16:44:03 -08:00
aforge c5834fad4d Merge pull request #109 from tinode/mute-video
Mute/unmute video properly.
2023-01-10 16:29:57 -08:00
or-else 640704ee2a bug fixes 2023-01-10 16:28:32 -08:00
aforge 18dd37b402 Fix remote video screen flickering. 2023-01-10 13:00:49 -08:00
aforge 54d6719307 Fix race between setState and PeerConnection events. 2023-01-09 20:23:26 -08:00
aforge 255e7fda98 Style + unmute delay fix. 2023-01-09 17:52:10 -08:00
aforge 1a6e8c8e74 Mute/unmute video properly.
Now muting turns the camera off instead of sending blank frames.
2023-01-09 16:08:44 -08:00
or-else b642d05a10 merge next 2023-01-09 11:56:46 -08:00
or-else 920619a988 make call buttons round for consistency with other apps 2023-01-09 11:55:56 -08:00
or-else acc24b552d simplify a bit and remove debug logging 2023-01-09 11:21:30 -08:00
or-else 2758bb9293 dependencies updated, version bumped to -beta2 2023-01-08 12:39:32 -08:00
or-else 4635456c1a rebuilt with changed SDK 2023-01-07 14:57:41 -08:00
or-else 89f9821255 audio-only calls kind of work 2023-01-07 14:56:07 -08:00
or-else 8690ae6a1b merge next 2023-01-06 10:48:25 -08:00
or-else 483d7ef58d Merge branch 'next' into devel 2023-01-05 18:46:19 -08:00
or-else e1d7ebcc22 rebuilt with the sdk 2023-01-05 18:46:16 -08:00
Gene 7d796e3dae Merge pull request #106 from tinode/next
Message editing, video messages
2023-01-05 09:48:35 -08:00
or-else 29cce0a4cc rebuilt with the sdk fix for previews of forwarded messages 2023-01-04 20:59:14 -08:00
or-else c59c3eeb0b merge next 2023-01-04 18:09:16 -08:00
or-else 80d48077c9 initial pass on audio-only calls 2023-01-04 12:47:02 -08:00
90 changed files with 5126 additions and 1112 deletions
+106 -16
View File
@@ -4,6 +4,8 @@
margin: 0;
font-family: Roboto, Arial, sans-serif;
text-rendering: optimizeLegibility;
scrollbar-width: thin;
scrollbar-color: rgba(0,0,0,0.3) rgba(255,255,255,0.1);
}
/* Basic elements defined */
@@ -96,6 +98,7 @@ html input[disabled] {
cursor: default;
opacity: 0.65;
box-shadow: none;
filter: grayscale(50%);
}
button.round {
@@ -117,7 +120,7 @@ button.round.small {
box-shadow: 0 2px 0.35rem 0 rgba(0, 0, 0, 0.25);
}
button:focus, button:hover, button:active {
button:hover:not([disabled]), button:active:not([disabled]) {
color: #fff;
}
button:active {
@@ -131,7 +134,7 @@ button.primary {
background-color: #1e88e5; /* blue 600 */
color: white;
}
button.primary:focus, button.primary:hover, button.primary:active {
button.primary:hover:not([disabled]), button.primary:active:not([disabled]) {
background-color: #2962ff;
}
@@ -139,7 +142,7 @@ button.secondary {
background-color: #FFF;
color: #2196f3; /* blue 500 */
}
button.secondary:focus, button.secondary:hover, button.secondary:active {
button.secondary:hover:not([disabled]), button.secondary:active:not([disabled]) {
background-color: #e3f2fd;
}
@@ -149,7 +152,7 @@ button.outline {
border: 1px solid silver;
box-shadow: none;
}
button.outline:focus, button.outline:hover, button.outline:active {
button.outline:hover:not([disabled]), button.outline:active:not([disabled]) {
background-color: #e8eaf6; /* indigo 50 */
}
@@ -157,7 +160,7 @@ button.danger {
background-color: #ec6560;
color: white;
}
button.danger:focus, button.danger:hover, button.danger:active {
button.danger:hover:not([disabled]), button.danger:active:not([disabled]) {
background-color: #f77;
}
@@ -165,7 +168,7 @@ button.positive {
background-color: #71c46f;
color: white;
}
button.positive:focus, button.positive:hover, button.positive:active {
button.positive:hover:not([disabled]), button.positive:active:not([disabled]) {
background-color: rgb(127, 237, 127);
}
@@ -184,6 +187,7 @@ input[type="text"],
input[type="email"],
input[type="password"],
input[type="search"],
input[type="tel"],
textarea {
height: 2.5rem;
border-bottom: 1px solid #ccc;
@@ -194,6 +198,7 @@ textarea {
input[type="text"],
input[type="email"],
input[type="password"],
input[type="tel"],
textarea {
width: 100%;
}
@@ -202,7 +207,8 @@ textarea:focus,
input[type="text"]:focus,
input[type="email"]:focus,
input[type="password"]:focus,
input[type="search"]:focus {
input[type="search"]:focus,
input[type="tel"]:focus {
border-bottom: 1px solid #2196f3;
box-shadow: 0 1px 0 rgba(33, 150, 243, 0.25);
}
@@ -211,7 +217,8 @@ textarea.invalid,
input[type="text"].invalid,
input[type="email"].invalid,
input[type="password"].invalid,
input[type="search"].invalid {
input[type="search"].invalid,
input[type="tel"].invalid {
border-bottom: 1px solid #e57373;
box-shadow: 0 1px 0 rgba(229, 115, 115, 0.25);
}
@@ -220,6 +227,7 @@ input[type="text"]::placeholder,
input[type="email"]::placeholder,
input[type="password"]::placeholder,
input[type="search"]::placeholder,
input[type="tel"]::placeholder,
textarea::placeholder {
color: #bbb;
font-weight: lighter;
@@ -327,13 +335,16 @@ li:hover .menuTrigger a {
div.alert-container {
position: absolute;
display: flex;
padding: 1rem;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
overflow: hidden;
background: rgba(240,240,240,0.85);
z-index: 3;
}
div.alert {
position: relative;
background-color: white;
@@ -342,10 +353,15 @@ div.alert {
border-radius: 0.3rem;
margin: auto 2rem auto 2rem;
width: fit-content;
height: min-content;
min-width: 16rem;
max-width: 24rem;
max-height: 40rem;
overflow: hidden;
}
div.alert-container .title {
position: relative;
color: #2196f3;
font-size: 1.2rem;
font-weight: 500;
@@ -354,6 +370,7 @@ div.alert-container .title {
}
div.alert-container .title.with-control {
position: relative;
display: flex;
padding: 0.5rem 0.5rem 0 0.5rem;
align-items: center;
@@ -362,6 +379,7 @@ div.alert-container .title.with-control {
}
div.alert-container .content {
position: relative;
padding: 0.5rem 0 0.5rem 0;
}
/* END Alert */
@@ -756,6 +774,7 @@ table.permission-editor td.checkbox {
display: flex;
padding: 0.5rem;
justify-content: flex-end;
align-items: center;
}
.dialog-buttons button {
@@ -1605,13 +1624,26 @@ img.channel {
}
.controls {
position: absolute;
display: flex;
column-gap: 1rem;
align-items: center;
justify-content: center;
height: 3.2rem;
width: 100%;
bottom: 2.5rem;
z-index: 1;
background-color: #eceff1;
box-shadow: 0px -2px 3px -1px rgba(0,0,0,0.1); /* Faint shadow above panel */
}
.incoming-call .controls {
bottom: 1rem;
}
.controls button {
border-radius: 50%;
width: 4rem;
height: 4rem;
min-width: initial;
box-shadow: 0 3px 6px 0 rgba(0, 0, 0, 0.25);
}
#video-container-panel {
@@ -1649,6 +1681,10 @@ img.channel {
z-index: 2;
}
.call-party.self[disabled] {
display: none;
}
video {
width: 100%;
height: 100%;
@@ -1659,6 +1695,10 @@ video {
transform: scaleX(-1);
}
.call-party[disabled] video {
display: none;
}
.caller-card {
position: absolute;
top: 0;
@@ -1737,7 +1777,7 @@ video {
left: initial;
width: 100%;
height: 100%;
padding: 5rem 3.5rem 2rem 3.5rem;
padding: 4rem 3.5rem 5.5rem 3.5rem;
display: flex;
align-items: center;
justify-content: center;
@@ -1896,10 +1936,8 @@ video {
bottom: 0;
background-color: #c5e1a5;
clip-path: polygon(100% 0, 0 110%, 100% 110%);
-webkit-clip-path: polygon(100% 0, 0 110%, 100% 110%);
box-shadow: 0 1px 0.5px rgb(0 0 0 / 12%);
border-bottom-left-radius: 0.3rem;
-moz-transition: ease-out background-color 0.5s;
transition: ease-out background-color 0.5s;
transition-delay: 0.3s;
}
@@ -1918,10 +1956,8 @@ video {
bottom: 0;
background-color: #fafafa;
clip-path: polygon(0 0, 100% 110%, 0 110%);
-webkit-clip-path: polygon(0 0, 100% 110%, 0 110%);
box-shadow: 0 1px 0.5px rgb(0 0 0 / 12%);
border-bottom-right-radius: 0.3rem;
-moz-transition: ease-out background-color 0.5s;
transition: ease-out background-color 0.5s;
transition-delay: 0.3s;
}
@@ -2997,6 +3033,55 @@ ul.tabbar li.active > a {
outline: 0.5rem solid rgba(255,255,255,.95);
}
.country-flag {
display: inline-block;
font-size: larger;
}
.dial-code {
white-space: nowrap;
display: inline-block;
cursor: pointer;
vertical-align: middle;
margin-bottom: 0.15rem;
}
ul.phone-country-selector {
list-style: none;
overflow-x: hidden;
}
ul.phone-country-selector > li {
position: relative;
font-weight: lighter;
padding: 0.25rem 0.5rem 0.15rem 0.5rem;
cursor: pointer;
width: 100%;
}
ul.phone-country-selector > li:hover {
background-color: #eceff1; /* blue-gray 50 highlight */
}
ul.phone-country-selector > li.selected {
background-color: #cfd8dc; /* blue-gray 100 highlight */
}
ul.phone-country-selector .dial-code {
position: absolute;
right: 0.5rem;
color: #1e88e5;
font-weight: normal;
}
ul.phone-country-selector span {
display: inline-block;
}
ul.phone-country-selector span.country {
text-overflow: ellipsis;
overflow-x: hidden;
}
/*
Very wide monitor.
*/
@@ -3042,4 +3127,9 @@ ul.tabbar li.active > a {
li .menuTrigger {
opacity: 0.3;
}
#send-message-input {
/* Just an indicator that the screen is in mobile mode */
transition-property: -tinode-mobile;
}
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -9,5 +9,7 @@ const FIREBASE_INIT = {
// Project Settings -> Cloud Messaging -> Web configuration -> Web Push certificates.
// This value IS NOT included in the generated js config you get from
// the firebase console. It needs to be added separately.
messagingVapidKey: "BOgQVPOMzIMXUpsYGpbVkZoEBc0ifKY_f2kSU5DNDGYI6i6CoKqqxDd7w7PJ3FaGRBgVGJffldETumOx831jl58"
messagingVapidKey: "BOgQVPOMzIMXUpsYGpbVkZoEBc0ifKY_f2kSU5DNDGYI6i6CoKqqxDd7w7PJ3FaGRBgVGJffldETumOx831jl58",
// Google analytics tag. Uncomment and use one you got from https://analytics.google.com/.
// measurementId: "G-ABCDEF1234"
};
+518 -510
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -1,12 +1,13 @@
{
"name": "tinode-webapp",
"description": "Tinode messenger for the web",
"version": "0.21.0-beta1",
"version": "0.21.0-rc2",
"repository": {
"type": "git",
"url": "https://github.com/tinode/example-react-js.git"
},
"scripts": {
"start": "echo 'Read installation instructions'",
"build": "npm run vers && npm run build:css && npm run extract && npm run build:i18n && npm run build:prod && npm run build:dev",
"clean": "rm umd/* && rm css/base.min.css && rm i18n.min/*",
"build:css": "postcss css/base.css > css/base.min.css",
@@ -48,6 +49,7 @@
"de",
"en",
"es",
"fr",
"ko",
"ro",
"ru",
@@ -64,10 +66,11 @@
"license": "Apache-2.0",
"dependencies": {
"firebase": "^9.14.0",
"libphonenumber-js": "^1.10.18",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-intl": "^6.2.1",
"tinode-sdk": "^0.21.0-beta1",
"tinode-sdk": "^0.21.0-rc2",
"webm-duration-fix": "^1.0.4"
},
"devDependencies": {
+5 -5
View File
@@ -64,16 +64,16 @@ const fbMessaging = firebase.messaging();
// This method shows the push notifications while the window is in background.
fbMessaging.onBackgroundMessage(payload => {
if (payload.data.silent == 'true') {
// TODO: if this is an 'msg', fetch the new message in the background.
return;
}
// Notify webapp that a message was received.
if (webAppChannel) {
webAppChannel.postMessage(payload.data);
}
if (payload.data.silent == 'true') {
// No need to show anything.
return;
}
const titles = {'msg': 'new_message', 'sub': 'new_chat'};
const pushType = payload.data.what || 'msg';
const title = payload.data.title || self.i18nMessage(titles[pushType]);
+1 -1
View File
@@ -4,5 +4,5 @@ export const CALL_STATE_OUTGOING_INITATED = 1;
export const CALL_STATE_INCOMING_RECEIVED = 2;
export const CALL_STATE_IN_PROGRESS = 3;
// Video call drafty headers representing call state.
// Video call message headers representing call state.
export const CALL_HEAD_STARTED = 'started';
+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
+52 -3
View File
@@ -358,7 +358,7 @@
},
"enter_confirmation_code_prompt": {
"translation": "Geben Sie den Bestätigungscode ein, der per {method} geschickt wurde:",
"defaultMessage": "Enter confirmation code sent to you by {method}:",
"defaultMessage": "Confirmation code",
"description": "Request to enter confirmation code",
"missing": false,
"obsolete": false
@@ -533,7 +533,7 @@
},
"label_reset_password": {
"translation": "Passwort per E-Mail wiederherstellen:",
"defaultMessage": "Send a password reset email:",
"defaultMessage": "Send a password reset email",
"description": "Label for password reset field",
"missing": false,
"obsolete": false
@@ -1308,7 +1308,7 @@
},
"password_reset_email_sent": {
"translation": "Eine E-Mail wurde an {email} gesendet. Folgen Sie den Anweisungen in der E-Mail, um Ihr Passwort zurückzusetzen.",
"defaultMessage": "An email has been sent to {email}. Follow the directions in the email to reset your password.",
"defaultMessage": "An email with security code has been sent to {email}. Enter the code below.",
"description": "Notification that the email with password reset instructions has been sent",
"missing": false,
"obsolete": false
@@ -1543,11 +1543,60 @@
"missing": false,
"obsolete": false
},
"menu_item_audio_call": {
"translation": "Anruf",
"defaultMessage": "Call",
"description": "Start audio call",
"missing": false,
"obsolete": false
},
"unrecognized_video_format": {
"translation": "Das Format dieses Videos wird nicht erkannt",
"defaultMessage": "Format of this video is not recognized",
"description": "Error message when uploaded video is invalid",
"missing": false,
"obsolete": false
},
"loading_note": {
"translation": "Laden...",
"defaultMessage": "Loading...",
"description": "Message shown when component is loading",
"missing": false,
"obsolete": false
},
"password_i_have_code": {
"translation": "Ich habe Code",
"defaultMessage": "I have code",
"description": "Call to open field to enter password reset code",
"missing": false,
"obsolete": false
},
"label_reset_password_tel": {
"translation": "SMS zum Zurücksetzen des Passworts senden",
"defaultMessage": "Send SMS to reset password",
"description": "Label for password reset field by phone",
"missing": false,
"obsolete": false
},
"mobile_phone_number": {
"translation": "Mobiltelefonnummer",
"defaultMessage": "Mobile phone number",
"description": "Prompt for entering a mobile phone number",
"missing": false,
"obsolete": false
},
"password_reset_sms_sent": {
"translation": "Eine Textnachricht mit Sicherheitscode wurde an {phone} gesendet. Geben Sie den Code unten ein.",
"defaultMessage": "A text message with security code has been sent to {phone}. Enter the code below.",
"description": "Notification that the SMS with password reset instructions has been sent",
"missing": false,
"obsolete": false
},
"mobile_number_required": {
"translation": "Handynummer erforderlich",
"defaultMessage": "Mobile phone number required",
"description": "Error message",
"missing": false,
"obsolete": false
}
}
+53 -4
View File
@@ -357,8 +357,8 @@
"obsolete": false
},
"enter_confirmation_code_prompt": {
"translation": "Enter confirmation code sent to you by {method}:",
"defaultMessage": "Enter confirmation code sent to you by {method}:",
"translation": "Confirmation code",
"defaultMessage": "Confirmation code",
"description": "Request to enter confirmation code",
"missing": false,
"obsolete": false
@@ -533,7 +533,7 @@
},
"label_reset_password": {
"translation": "Send a password reset email:",
"defaultMessage": "Send a password reset email:",
"defaultMessage": "Send a password reset email",
"description": "Label for password reset field",
"missing": false,
"obsolete": false
@@ -1315,7 +1315,7 @@
},
"password_reset_email_sent": {
"translation": "An email has been sent to {email}. Follow the directions in the email to reset your password.",
"defaultMessage": "An email has been sent to {email}. Follow the directions in the email to reset your password.",
"defaultMessage": "An email with security code has been sent to {email}. Enter the code below.",
"description": "Notification that the email with password reset instructions has been sent",
"missing": false,
"obsolete": false
@@ -1543,11 +1543,60 @@
"missing": false,
"obsolete": false
},
"menu_item_audio_call": {
"translation": "Call",
"defaultMessage": "Call",
"description": "Start audio call",
"missing": false,
"obsolete": false
},
"unrecognized_video_format": {
"translation": "Format of this video is not recognized",
"defaultMessage": "Format of this video is not recognized",
"description": "Error message when uploaded video is invalid",
"missing": false,
"obsolete": false
},
"loading_note": {
"translation": "Loading...",
"defaultMessage": "Loading...",
"description": "Message shown when component is loading",
"missing": false,
"obsolete": false
},
"password_i_have_code": {
"translation": "I have code",
"defaultMessage": "I have code",
"description": "Call to open field to enter password reset code",
"missing": false,
"obsolete": false
},
"label_reset_password_tel": {
"translation": "Send a password reset SMS:",
"defaultMessage": "Send SMS to reset password",
"description": "Label for password reset field by phone",
"missing": false,
"obsolete": false
},
"mobile_phone_number": {
"translation": "Mobile phone number",
"defaultMessage": "Mobile phone number",
"description": "Prompt for entering a mobile phone number",
"missing": false,
"obsolete": false
},
"password_reset_sms_sent": {
"translation": "A text message with security code has been sent to {phone}. Enter the code below.",
"defaultMessage": "A text message with security code has been sent to {phone}. Enter the code below.",
"description": "Notification that the SMS with password reset instructions has been sent",
"missing": false,
"obsolete": false
},
"mobile_number_required": {
"translation": "Mobile phone number required",
"defaultMessage": "Mobile phone number required",
"description": "Error message",
"missing": false,
"obsolete": false
}
}
+52 -3
View File
@@ -358,7 +358,7 @@
},
"enter_confirmation_code_prompt": {
"translation": "Introduzca el código de confirmación enviado a tu {method}:",
"defaultMessage": "Enter confirmation code sent to you by {method}:",
"defaultMessage": "Confirmation code",
"description": "Request to enter confirmation code",
"missing": false,
"obsolete": false
@@ -533,7 +533,7 @@
},
"label_reset_password": {
"translation": "Enviar un correo electrónico de restablecimiento de contraseña:",
"defaultMessage": "Send a password reset email:",
"defaultMessage": "Send a password reset email",
"description": "Label for password reset field",
"missing": false,
"obsolete": false
@@ -1308,7 +1308,7 @@
},
"password_reset_email_sent": {
"translation": "Se ha enviado un correo electrónico a {email}. Siga las instrucciones del correo electrónico para restablecer su contraseña.",
"defaultMessage": "An email has been sent to {email}. Follow the directions in the email to reset your password.",
"defaultMessage": "An email with security code has been sent to {email}. Enter the code below.",
"description": "Notification that the email with password reset instructions has been sent",
"missing": false,
"obsolete": false
@@ -1543,11 +1543,60 @@
"missing": false,
"obsolete": false
},
"menu_item_audio_call": {
"translation": "Llamada",
"defaultMessage": "Call",
"description": "Start audio call",
"missing": false,
"obsolete": false
},
"unrecognized_video_format": {
"translation": "No se reconoce el formato de este video",
"defaultMessage": "Format of this video is not recognized",
"description": "Error message when uploaded video is invalid",
"missing": false,
"obsolete": false
},
"loading_note": {
"translation": "Cargando...",
"defaultMessage": "Loading...",
"description": "Message shown when component is loading",
"missing": false,
"obsolete": false
},
"password_i_have_code": {
"translation": "Tengo código",
"defaultMessage": "I have code",
"description": "Call to open field to enter password reset code",
"missing": false,
"obsolete": false
},
"label_reset_password_tel": {
"translation": "Enviar SMS para restablecer la contraseña",
"defaultMessage": "Send SMS to reset password",
"description": "Label for password reset field by phone",
"missing": false,
"obsolete": false
},
"mobile_phone_number": {
"translation": "Número de teléfono móvil",
"defaultMessage": "Mobile phone number",
"description": "Prompt for entering a mobile phone number",
"missing": false,
"obsolete": false
},
"password_reset_sms_sent": {
"translation": "Se ha enviado un mensaje de texto con un código de seguridad a {phone}. Ingresa el código a continuación.",
"defaultMessage": "A text message with security code has been sent to {phone}. Enter the code below.",
"description": "Notification that the SMS with password reset instructions has been sent",
"missing": false,
"obsolete": false
},
"mobile_number_required": {
"translation": "Número de teléfono móvil requerido",
"defaultMessage": "Mobile phone number required",
"description": "Error message",
"missing": false,
"obsolete": false
}
}
+31 -3
View File
@@ -268,7 +268,7 @@
"description": "Call to action to enable peer's messaging"
},
"enter_confirmation_code_prompt": {
"defaultMessage": "Enter confirmation code sent to you by {method}:",
"defaultMessage": "Confirmation code",
"description": "Request to enter confirmation code"
},
"error_invalid_id": {
@@ -415,9 +415,13 @@
"description": "Label for push notifications switch"
},
"label_reset_password": {
"defaultMessage": "Send a password reset email:",
"defaultMessage": "Send a password reset email",
"description": "Label for password reset field"
},
"label_reset_password_tel": {
"defaultMessage": "Send SMS to reset password",
"description": "Label for password reset field by phone"
},
"label_sdk": {
"defaultMessage": "SDK:"
},
@@ -492,6 +496,10 @@
"defaultMessage": "Terms of Service",
"description": "Ancor text for terms of service link"
},
"loading_note": {
"defaultMessage": "Loading...",
"description": "Message shown when component is loading"
},
"login_prompt": {
"defaultMessage": "Login",
"description": "Placeholer for username/login"
@@ -500,6 +508,10 @@
"defaultMessage": "Archive",
"description": "Move topic from the list of active chats to archive"
},
"menu_item_audio_call": {
"defaultMessage": "Call",
"description": "Start audio call"
},
"menu_item_block": {
"defaultMessage": "Block",
"description": "Block topic or user"
@@ -592,6 +604,14 @@
"defaultMessage": "Messaging disabled",
"description": "Prompt in SendMessage in read-only topic"
},
"mobile_number_required": {
"defaultMessage": "Mobile phone number required",
"description": "Error message"
},
"mobile_phone_number": {
"defaultMessage": "Mobile phone number",
"description": "Prompt for entering a mobile phone number"
},
"more_online_members": {
"defaultMessage": "+{overflow} more",
"description": "Shown in MessagesView title bar when the number of online subscribers exceeds MAX_ONLINE_IN_TOPIC"
@@ -640,14 +660,22 @@
"defaultMessage": "Security",
"description": "Title for TopicSecirity and AccSecurity."
},
"password_i_have_code": {
"defaultMessage": "I have code",
"description": "Call to open field to enter password reset code"
},
"password_prompt": {
"defaultMessage": "Password",
"description": "Placeholder/prompt for entering password"
},
"password_reset_email_sent": {
"defaultMessage": "An email has been sent to {email}. Follow the directions in the email to reset your password.",
"defaultMessage": "An email with security code has been sent to {email}. Enter the code below.",
"description": "Notification that the email with password reset instructions has been sent"
},
"password_reset_sms_sent": {
"defaultMessage": "A text message with security code has been sent to {phone}. Enter the code below.",
"description": "Notification that the SMS with password reset instructions has been sent"
},
"password_unchanged_prompt": {
"defaultMessage": "Unchanged",
"description": "Message in editor while password is unchanged"
+52 -3
View File
@@ -358,7 +358,7 @@
},
"enter_confirmation_code_prompt": {
"translation": "Entrez le code de confirmation envoyé par {method}:",
"defaultMessage": "Enter confirmation code sent to you by {method}:",
"defaultMessage": "Confirmation code",
"description": "Request to enter confirmation code",
"missing": false,
"obsolete": false
@@ -533,7 +533,7 @@
},
"label_reset_password": {
"translation": "Envoyer un mail de remise à zéro de mot de passe :",
"defaultMessage": "Send a password reset email:",
"defaultMessage": "Send a password reset email",
"description": "Label for password reset field",
"missing": false,
"obsolete": false
@@ -1315,7 +1315,7 @@
},
"password_reset_email_sent": {
"translation": "Un email a été envoyé à {email}. Suivez les instructions du mail pour remettre à zéro votre mot de passe.",
"defaultMessage": "An email has been sent to {email}. Follow the directions in the email to reset your password.",
"defaultMessage": "An email with security code has been sent to {email}. Enter the code below.",
"description": "Notification that the email with password reset instructions has been sent",
"missing": false,
"obsolete": false
@@ -1543,11 +1543,60 @@
"missing": false,
"obsolete": false
},
"menu_item_audio_call": {
"translation": "Appel",
"defaultMessage": "Call",
"description": "Start audio call",
"missing": false,
"obsolete": false
},
"unrecognized_video_format": {
"translation": "Le format de cette vidéo n'est pas reconnu",
"defaultMessage": "Format of this video is not recognized",
"description": "Error message when uploaded video is invalid",
"missing": false,
"obsolete": false
},
"loading_note": {
"translation": "Chargement...",
"defaultMessage": "Loading...",
"description": "Message shown when component is loading",
"missing": false,
"obsolete": false
},
"password_i_have_code": {
"translation": "J'ai du code",
"defaultMessage": "I have code",
"description": "Call to open field to enter password reset code",
"missing": false,
"obsolete": false
},
"label_reset_password_tel": {
"translation": "Envoyer un SMS pour réinitialiser le mot de passe",
"defaultMessage": "Send SMS to reset password",
"description": "Label for password reset field by phone",
"missing": false,
"obsolete": false
},
"mobile_phone_number": {
"translation": "Numéro de téléphone mobile",
"defaultMessage": "Mobile phone number",
"description": "Prompt for entering a mobile phone number",
"missing": false,
"obsolete": false
},
"password_reset_sms_sent": {
"translation": "Un SMS avec un code de sécurité a été envoyé à {phone}. Saisissez le code ci-dessous.",
"defaultMessage": "A text message with security code has been sent to {phone}. Enter the code below.",
"description": "Notification that the SMS with password reset instructions has been sent",
"missing": false,
"obsolete": false
},
"mobile_number_required": {
"translation": "Numéro de téléphone portable requis",
"defaultMessage": "Mobile phone number required",
"description": "Error message",
"missing": false,
"obsolete": false
}
}
+52 -3
View File
@@ -358,7 +358,7 @@
},
"enter_confirmation_code_prompt": {
"translation": "{방법}으로보낸확인코드를입력하십시오:",
"defaultMessage": "Enter confirmation code sent to you by {method}:",
"defaultMessage": "Confirmation code",
"description": "Request to enter confirmation code",
"missing": false,
"obsolete": false
@@ -533,7 +533,7 @@
},
"label_reset_password": {
"translation": "비밀번호재설정이메일보내기: ",
"defaultMessage": "Send a password reset email:",
"defaultMessage": "Send a password reset email",
"description": "Label for password reset field",
"missing": false,
"obsolete": false
@@ -1308,7 +1308,7 @@
},
"password_reset_email_sent": {
"translation": "이메일이 {email}(으)로 전송되었습니다. 이메일의 지침에 따라 비밀번호를 재설정하세요.",
"defaultMessage": "An email has been sent to {email}. Follow the directions in the email to reset your password.",
"defaultMessage": "An email with security code has been sent to {email}. Enter the code below.",
"description": "Notification that the email with password reset instructions has been sent",
"missing": false,
"obsolete": false
@@ -1543,11 +1543,60 @@
"missing": false,
"obsolete": false
},
"menu_item_audio_call": {
"translation": "부르다",
"defaultMessage": "Call",
"description": "Start audio call",
"missing": false,
"obsolete": false
},
"unrecognized_video_format": {
"translation": "이 동영상의 형식을 인식할 수 없습니다.",
"defaultMessage": "Format of this video is not recognized",
"description": "Error message when uploaded video is invalid",
"missing": false,
"obsolete": false
},
"loading_note": {
"translation": "로드 중...",
"defaultMessage": "Loading...",
"description": "Message shown when component is loading",
"missing": false,
"obsolete": false
},
"password_i_have_code": {
"translation": "코드가 있습니다",
"defaultMessage": "I have code",
"description": "Call to open field to enter password reset code",
"missing": false,
"obsolete": false
},
"label_reset_password_tel": {
"translation": "비밀번호 재설정을 위해 SMS 보내기",
"defaultMessage": "Send SMS to reset password",
"description": "Label for password reset field by phone",
"missing": false,
"obsolete": false
},
"mobile_phone_number": {
"translation": "휴대전화 번호",
"defaultMessage": "Mobile phone number",
"description": "Prompt for entering a mobile phone number",
"missing": false,
"obsolete": false
},
"password_reset_sms_sent": {
"translation": "보안 코드가 포함된 문자 메시지가 {phone}로 전송되었습니다. 아래 코드를 입력하세요.",
"defaultMessage": "A text message with security code has been sent to {phone}. Enter the code below.",
"description": "Notification that the SMS with password reset instructions has been sent",
"missing": false,
"obsolete": false
},
"mobile_number_required": {
"translation": "휴대전화 번호 필요",
"defaultMessage": "Mobile phone number required",
"description": "Error message",
"missing": false,
"obsolete": false
}
}
+52 -3
View File
@@ -358,7 +358,7 @@
},
"enter_confirmation_code_prompt": {
"translation": "Introu codul de confirmare trimis pe {method}:",
"defaultMessage": "Enter confirmation code sent to you by {method}:",
"defaultMessage": "Confirmation code",
"description": "Request to enter confirmation code",
"missing": false,
"obsolete": false
@@ -533,7 +533,7 @@
},
"label_reset_password": {
"translation": "Resetează parola:",
"defaultMessage": "Send a password reset email:",
"defaultMessage": "Send a password reset email",
"description": "Label for password reset field",
"missing": false,
"obsolete": false
@@ -1308,7 +1308,7 @@
},
"password_reset_email_sent": {
"translation": "Un e-mail a fost trimis către {email}. Urmați instrucțiunile din e-mail pentru a vă reseta parola.",
"defaultMessage": "An email has been sent to {email}. Follow the directions in the email to reset your password.",
"defaultMessage": "An email with security code has been sent to {email}. Enter the code below.",
"description": "Notification that the email with password reset instructions has been sent",
"missing": false,
"obsolete": false
@@ -1543,11 +1543,60 @@
"missing": false,
"obsolete": false
},
"menu_item_audio_call": {
"translation": "Apel",
"defaultMessage": "Call",
"description": "Start audio call",
"missing": false,
"obsolete": false
},
"unrecognized_video_format": {
"translation": "Formatul acestui videoclip nu este recunoscut",
"defaultMessage": "Format of this video is not recognized",
"description": "Error message when uploaded video is invalid",
"missing": false,
"obsolete": false
},
"loading_note": {
"translation": "Se încarcă...",
"defaultMessage": "Loading...",
"description": "Message shown when component is loading",
"missing": false,
"obsolete": false
},
"password_i_have_code": {
"translation": "Am cod",
"defaultMessage": "I have code",
"description": "Call to open field to enter password reset code",
"missing": false,
"obsolete": false
},
"label_reset_password_tel": {
"translation": "Trimite SMS pentru a reseta parola",
"defaultMessage": "Send SMS to reset password",
"description": "Label for password reset field by phone",
"missing": false,
"obsolete": false
},
"mobile_phone_number": {
"translation": "Număr de telefon mobil",
"defaultMessage": "Mobile phone number",
"description": "Prompt for entering a mobile phone number",
"missing": false,
"obsolete": false
},
"password_reset_sms_sent": {
"translation": "Un mesaj text cu cod de securitate a fost trimis la {phone}. Introduceți codul de mai jos.",
"defaultMessage": "A text message with security code has been sent to {phone}. Enter the code below.",
"description": "Notification that the SMS with password reset instructions has been sent",
"missing": false,
"obsolete": false
},
"mobile_number_required": {
"translation": "Este necesar un număr de telefon mobil",
"defaultMessage": "Mobile phone number required",
"description": "Error message",
"missing": false,
"obsolete": false
}
}
+52 -3
View File
@@ -358,7 +358,7 @@
},
"enter_confirmation_code_prompt": {
"translation": "Код подтверждения, полученный по {method}:",
"defaultMessage": "Enter confirmation code sent to you by {method}:",
"defaultMessage": "Confirmation code",
"description": "Request to enter confirmation code",
"missing": false,
"obsolete": false
@@ -533,7 +533,7 @@
},
"label_reset_password": {
"translation": "Отправить емейл для смены пароля:",
"defaultMessage": "Send a password reset email:",
"defaultMessage": "Send a password reset email",
"description": "Label for password reset field",
"missing": false,
"obsolete": false
@@ -1308,7 +1308,7 @@
},
"password_reset_email_sent": {
"translation": "Сообщение было отправлено на адрес {email}. Следуйте инструкциям в сообщении, чтобы изменить пароль.",
"defaultMessage": "An email has been sent to {email}. Follow the directions in the email to reset your password.",
"defaultMessage": "An email with security code has been sent to {email}. Enter the code below.",
"description": "Notification that the email with password reset instructions has been sent",
"missing": false,
"obsolete": false
@@ -1543,11 +1543,60 @@
"missing": false,
"obsolete": false
},
"menu_item_audio_call": {
"translation": "Позвонить",
"defaultMessage": "Call",
"description": "Start audio call",
"missing": false,
"obsolete": false
},
"unrecognized_video_format": {
"translation": "Неизвестный формат видео файла",
"defaultMessage": "Format of this video is not recognized",
"description": "Error message when uploaded video is invalid",
"missing": false,
"obsolete": false
},
"loading_note": {
"translation": "Загружается...",
"defaultMessage": "Loading...",
"description": "Message shown when component is loading",
"missing": false,
"obsolete": false
},
"password_i_have_code": {
"translation": "У меня есть код",
"defaultMessage": "I have code",
"description": "Call to open field to enter password reset code",
"missing": false,
"obsolete": false
},
"label_reset_password_tel": {
"translation": "Отправить SMS для сброса пароля",
"defaultMessage": "Send SMS to reset password",
"description": "Label for password reset field by phone",
"missing": false,
"obsolete": false
},
"mobile_phone_number": {
"translation": "Номер мобильного телефона",
"defaultMessage": "Mobile phone number",
"description": "Prompt for entering a mobile phone number",
"missing": false,
"obsolete": false
},
"password_reset_sms_sent": {
"translation": "SMS с защитным кодом отправлено на {phone}. Введите код ниже.",
"defaultMessage": "A text message with security code has been sent to {phone}. Enter the code below.",
"description": "Notification that the SMS with password reset instructions has been sent",
"missing": false,
"obsolete": false
},
"mobile_number_required": {
"translation": "Номер мобильного телефона",
"defaultMessage": "Mobile phone number required",
"description": "Error message",
"missing": false,
"obsolete": false
}
}
+52 -3
View File
@@ -358,7 +358,7 @@
},
"enter_confirmation_code_prompt": {
"translation": "輸入透過 {method} 傳送給您的確認碼:",
"defaultMessage": "Enter confirmation code sent to you by {method}:",
"defaultMessage": "Confirmation code",
"description": "Request to enter confirmation code",
"missing": false,
"obsolete": false
@@ -533,7 +533,7 @@
},
"label_reset_password": {
"translation": "傳送重設密碼郵件:",
"defaultMessage": "Send a password reset email:",
"defaultMessage": "Send a password reset email",
"description": "Label for password reset field",
"missing": false,
"obsolete": false
@@ -1308,7 +1308,7 @@
},
"password_reset_email_sent": {
"translation": "一封電子郵件已發送至 {email}。按照電子郵件中的說明重置密碼。",
"defaultMessage": "An email has been sent to {email}. Follow the directions in the email to reset your password.",
"defaultMessage": "An email with security code has been sent to {email}. Enter the code below.",
"description": "Notification that the email with password reset instructions has been sent",
"missing": false,
"obsolete": false
@@ -1543,11 +1543,60 @@
"missing": false,
"obsolete": false
},
"menu_item_audio_call": {
"translation": "稱呼",
"defaultMessage": "Call",
"description": "Start audio call",
"missing": false,
"obsolete": false
},
"unrecognized_video_format": {
"translation": "無法識別此視頻的格式",
"defaultMessage": "Format of this video is not recognized",
"description": "Error message when uploaded video is invalid",
"missing": false,
"obsolete": false
},
"loading_note": {
"translation": "加載中...",
"defaultMessage": "Loading...",
"description": "Message shown when component is loading",
"missing": false,
"obsolete": false
},
"password_i_have_code": {
"translation": "我有代碼",
"defaultMessage": "I have code",
"description": "Call to open field to enter password reset code",
"missing": false,
"obsolete": false
},
"label_reset_password_tel": {
"translation": "發送短信重置密碼",
"defaultMessage": "Send SMS to reset password",
"description": "Label for password reset field by phone",
"missing": false,
"obsolete": false
},
"mobile_phone_number": {
"translation": "手機號碼",
"defaultMessage": "Mobile phone number",
"description": "Prompt for entering a mobile phone number",
"missing": false,
"obsolete": false
},
"password_reset_sms_sent": {
"translation": "帶有安全碼的短信已發送至{phone}。請在下方輸入密碼。",
"defaultMessage": "A text message with security code has been sent to {phone}. Enter the code below.",
"description": "Notification that the SMS with password reset instructions has been sent",
"missing": false,
"obsolete": false
},
"mobile_number_required": {
"translation": "需要手機號",
"defaultMessage": "Mobile phone number required",
"description": "Error message",
"missing": false,
"obsolete": false
}
}
+52 -3
View File
@@ -358,7 +358,7 @@
},
"enter_confirmation_code_prompt": {
"translation": "输入通过{method}发送的验证码:",
"defaultMessage": "Enter confirmation code sent to you by {method}:",
"defaultMessage": "Confirmation code",
"description": "Request to enter confirmation code",
"missing": false,
"obsolete": false
@@ -533,7 +533,7 @@
},
"label_reset_password": {
"translation": "发送密码重置邮件:",
"defaultMessage": "Send a password reset email:",
"defaultMessage": "Send a password reset email",
"description": "Label for password reset field",
"missing": false,
"obsolete": false
@@ -1308,7 +1308,7 @@
},
"password_reset_email_sent": {
"translation": "一封电子邮件已发送至 {email}。按照电子邮件中的说明重置密码。",
"defaultMessage": "An email has been sent to {email}. Follow the directions in the email to reset your password.",
"defaultMessage": "An email with security code has been sent to {email}. Enter the code below.",
"description": "Notification that the email with password reset instructions has been sent",
"missing": false,
"obsolete": false
@@ -1543,11 +1543,60 @@
"missing": false,
"obsolete": false
},
"menu_item_audio_call": {
"translation": "称呼",
"defaultMessage": "Call",
"description": "Start audio call",
"missing": false,
"obsolete": false
},
"unrecognized_video_format": {
"translation": "无法识别此视频的格式",
"defaultMessage": "Format of this video is not recognized",
"description": "Error message when uploaded video is invalid",
"missing": false,
"obsolete": false
},
"loading_note": {
"translation": "加载中...",
"defaultMessage": "Loading...",
"description": "Message shown when component is loading",
"missing": false,
"obsolete": false
},
"password_i_have_code": {
"translation": "我有代码",
"defaultMessage": "I have code",
"description": "Call to open field to enter password reset code",
"missing": false,
"obsolete": false
},
"label_reset_password_tel": {
"translation": "发送短信重置密码",
"defaultMessage": "Send SMS to reset password",
"description": "Label for password reset field by phone",
"missing": false,
"obsolete": false
},
"mobile_phone_number": {
"translation": "手机号",
"defaultMessage": "Mobile phone number",
"description": "Prompt for entering a mobile phone number",
"missing": false,
"obsolete": false
},
"password_reset_sms_sent": {
"translation": "带有安全码的短信已发送至{phone}。请在下方输入密码。",
"defaultMessage": "A text message with security code has been sent to {phone}. Enter the code below.",
"description": "Notification that the SMS with password reset instructions has been sent",
"missing": false,
"obsolete": false
},
"mobile_number_required": {
"translation": "需要手机号",
"defaultMessage": "Mobile phone number required",
"description": "Error message",
"missing": false,
"obsolete": false
}
}
+13
View File
@@ -9,6 +9,19 @@ import { IntlProvider } from 'react-intl';
import TinodeWeb from './views/tinode-web.jsx';
import HashNavigation from './lib/navigation.js';
// Insert google analytics script and tag if configured.
if (FIREBASE_INIT && FIREBASE_INIT.measurementId) {
const head = document.getElementsByTagName('head')[0];
let script = document.createElement('script');
script.src = 'https://www.googletagmanager.com/gtag/js?id=' + FIREBASE_INIT.measurementId;
script.async = true;
head.prepend(script);
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', FIREBASE_INIT.measurementId);
}
// Allow loading translation strings for just one language.
const messageLoader = {
'de': _ => import('./i18n.min/de.json'),
+9
View File
@@ -99,3 +99,12 @@ export function letterTileColorId(userId) {
export function idToColorClass(id, light, fg) {
return (light ? 'lt-' : 'dk-') + (fg ? 'fg-' : 'bg-') + letterTileColorId(id);
}
// Takes ISO Alpha-2 country code, like 'US' and converts it to a flag emoji.
export function flagEmoji(countryCode) {
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 0x1F1A5 + char.charCodeAt());
return String.fromCodePoint(...codePoints);
}
+1 -1
View File
@@ -1,3 +1,3 @@
// This is a generated file. Don't edit.
export const PACKAGE_VERSION = "0.21.0-beta1";
export const PACKAGE_VERSION = "0.21.0-rc2";
+51 -11
View File
@@ -1,10 +1,11 @@
// Account registration.
import React from 'react';
import React, { Suspense } from 'react';
import { FormattedMessage } from 'react-intl';
import AvatarCrop from '../widgets/avatar-crop.jsx';
import AvatarUpload from '../widgets/avatar-upload.jsx';
import CheckBox from '../widgets/checkbox.jsx';
const PhoneEdit = React.lazy(_ => import('../widgets/phone-edit.jsx'));
import VisiblePassword from '../widgets/visible-password.jsx';
import LocalStorageUtil from '../lib/local-storage.js';
@@ -21,7 +22,9 @@ export default class CreateAccountView extends React.PureComponent {
this.state = {
login: '',
password: '',
meth: '',
email: '',
tel: '',
fn: '', // full/formatted name
imageUrl: null,
uploadUrl: null,
@@ -35,6 +38,7 @@ export default class CreateAccountView extends React.PureComponent {
this.handleLoginChange = this.handleLoginChange.bind(this);
this.handlePasswordChange = this.handlePasswordChange.bind(this);
this.handleEmailChange = this.handleEmailChange.bind(this);
this.handlePhoneChange = this.handlePhoneChange.bind(this);
this.handleFnChange = this.handleFnChange.bind(this);
this.handleImageChanged = this.handleImageChanged.bind(this);
this.handleToggleSaveToken = this.handleToggleSaveToken.bind(this);
@@ -42,6 +46,12 @@ export default class CreateAccountView extends React.PureComponent {
this.handleAvatarCropCancel = this.handleAvatarCropCancel.bind(this);
this.uploadAvatar = this.uploadAvatar.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
// Connection will trigger change by changing the this.props.serverVersion.
props.tinode.connect()
.catch(err => {
this.props.onError(err.message, 'err');
});
}
handleLoginChange(e) {
@@ -53,7 +63,11 @@ export default class CreateAccountView extends React.PureComponent {
}
handleEmailChange(e) {
this.setState({email: e.target.value})
this.setState({meth: 'email', email: e.target.value});
}
handlePhoneChange(number) {
this.setState({meth: 'tel', tel: number});
}
handleFnChange(e) {
@@ -76,7 +90,13 @@ export default class CreateAccountView extends React.PureComponent {
this.state.login.trim(),
this.state.password.trim(),
theCard(this.state.fn.trim().substring(0, MAX_TITLE_LENGTH), this.state.uploadUrl),
{'meth': 'email', 'val': this.state.email});
{
'meth': this.state.meth,
'val': this.state.meth == 'email' ?
this.state.email :
this.state.meth == 'tel' ? this.state.tel : null
}
);
}
// AvatarCropView calls this method when the user has cropped the image.
@@ -124,6 +144,9 @@ export default class CreateAccountView extends React.PureComponent {
}
render() {
// "reqCred":{"auth":["email"]}
const method = (this.props.tinode.getServerParam('reqCred', {}).auth || [])[0] || 'email';
if (this.state.newAvatar) {
return (
<AvatarCrop
@@ -143,7 +166,7 @@ export default class CreateAccountView extends React.PureComponent {
return (
<form className="panel-form-column" onSubmit={this.handleSubmit}>
<div className="panel-form-row">
<div className="panel-form-column">
<div className="umn">
<FormattedMessage id="login_prompt" defaultMessage="Login"
description="Placeholer for username/login">{
(login_prompt) => <input type="text" placeholder={login_prompt} autoComplete="user-name"
@@ -169,13 +192,30 @@ export default class CreateAccountView extends React.PureComponent {
value={this.state.fn} onChange={this.handleFnChange} required/>
}</FormattedMessage>
</div>
<div className="panel-form-row">
<FormattedMessage id="email_prompt" defaultMessage="Email, e.g. jdoe@example.com"
description="Input placeholder for email entry">{
(email_prompt) => <input type="email" placeholder={email_prompt} autoComplete="email"
value={this.state.email} onChange={this.handleEmailChange} required/>
}</FormattedMessage>
</div>
{method == 'email' ?
<div className="panel-form-row">
<FormattedMessage id="email_prompt" defaultMessage="Email, e.g. jdoe@example.com"
description="Input placeholder for email entry">{
(email_prompt) => <input type="email" placeholder={email_prompt} autoComplete="email"
value={this.state.email} onChange={this.handleEmailChange} required/>
}</FormattedMessage>
</div>
: method == 'tel' ?
<><div className="panel-form-row">
<label className="small gray"><FormattedMessage id="mobile_phone_number" defaultMessage="Mobile phone number"
description="Prompt for entering a mobile phone number" /></label>
</div>
<div className="panel-form-row">
<Suspense fallback={<div><FormattedMessage id="loading_note" defaultMessage="Loading..."
description="Message shown when component is loading"/></div>}>
<PhoneEdit
autoFocus={false}
onShowCountrySelector={this.props.onShowCountrySelector}
onSubmit={this.handlePhoneChange} />
</Suspense>
</div></>
: null
}
<div className="panel-form-row">
<CheckBox id="save-token" name="save-token" checked={this.state.saveToken}
onChange={this.handleToggleSaveToken} />
+1 -1
View File
@@ -480,7 +480,7 @@ class InfoView extends React.Component {
tinode={this.props.tinode}
topic={this.props.topic}
onCredAdd={this.props.onCredAdd}
onTopicTagsUpdateRequest={this.props.onTopicTagsUpdateRequest}
onUpdateTagsRequest={this.props.onTopicTagsUpdateRequest}
onCredConfirm={this.props.onCredConfirm}
onCredDelete={this.props.onCredDelete}
onUpdateTopicDesc={this.props.onTopicDescUpdateRequest}
-1
View File
@@ -93,4 +93,3 @@ export default class LoginView extends React.Component {
);
}
};
/* END Login */
+3 -2
View File
@@ -303,8 +303,6 @@ class MessagesView extends React.Component {
scrollPosition: 0,
fetchingMessages: false,
showGoToLastButton: false,
forwardMessage: null,
reply: null,
contentToEdit: null,
dragging: false
};
@@ -315,6 +313,8 @@ class MessagesView extends React.Component {
content: nextProps.forwardMessage.preview,
seq: null
};
} else {
nextState.reply = null;
}
if (topic) {
@@ -1358,6 +1358,7 @@ class MessagesView extends React.Component {
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}
+157 -58
View File
@@ -1,7 +1,8 @@
// A password reset form.
import React from 'react';
import React, { Suspense } from 'react';
import { FormattedMessage } from 'react-intl';
const PhoneEdit = React.lazy(_ => import('../widgets/phone-edit.jsx'));
import VisiblePassword from '../widgets/visible-password.jsx';
import HashNavigation from '../lib/navigation.js';
@@ -11,14 +12,25 @@ export default class PasswordResetView extends React.PureComponent {
super(props);
this.state = {
tel: '',
email: '',
password: '',
sent: false
sent: false,
haveCode: false,
code: ''
};
this.handleSubmit = this.handleSubmit.bind(this);
this.handleEmailChange = this.handleEmailChange.bind(this);
this.handlePasswordChange = this.handlePasswordChange.bind(this);
this.handleCodeChange = this.handleCodeChange.bind(this);
this.handleShowCodeField = this.handleShowCodeField.bind(this);
// Connection will trigger change by changing the this.props.serverVersion.
props.tinode.connect()
.catch(err => {
this.props.onError(err.message, 'err');
});
}
componentDidMount() {
@@ -29,13 +41,24 @@ export default class PasswordResetView extends React.PureComponent {
handleSubmit(e) {
e.preventDefault();
if (this.state.token) {
this.props.onReset(this.state.scheme, this.state.password.trim(), this.state.token);
} else if (this.state.sent) {
this.props.onCancel();
// Reset using token.
this.props.onReset(this.state.password.trim(), {
scheme: 'token',
token: this.state.token
});
} else if (this.state.code && this.method) {
// Reset using security code.
// The secret is structured as <code>:<cred_method>:<cred_value>, "123456:email:alice@example.com".
const cred = this.state.email.trim() || this.state.tel.trim();
this.props.onReset(this.state.password.trim(), {
scheme: 'code',
secret: `${this.state.code}:${this.method}:${cred}`
});
} else {
const email = this.state.email.trim();
this.setState({email: email});
this.props.onRequest('email', email).then(_ => this.setState({sent: true}));
const tel = this.state.tel.trim();
this.setState({email: email, tel: tel});
this.props.onRequest(this.method, email || tel).then(_ => this.setState({sent: true}));
}
}
@@ -47,63 +70,139 @@ export default class PasswordResetView extends React.PureComponent {
this.setState({password: e.target.value});
}
handleCodeChange(e) {
this.setState({code: e.target.value.replace(/[^\d]/g, '')});
}
handleShowCodeField(e) {
e.preventDefault();
this.setState({haveCode: true});
}
render() {
// "reqCred":{"auth":["email"]}
this.method = (this.props.tinode.getServerParam('reqCred', {}).auth || [])[0] || 'email';
const showCredentialInput = !(this.state.token && this.state.scheme);
const showPasswordInput = !showCredentialInput || this.state.haveCode || this.state.sent;
const passwordInput = (<>
<label className="small gray" htmlFor="new-password">
<FormattedMessage id="label_new_password"
defaultMessage="New password"
description="Label for entering a new password" />
</label>
<FormattedMessage id="new_password_placeholder" defaultMessage="Enter new password"
description="Placeholder for entering new password">{
(placeholder) => <VisiblePassword
id="new-password"
placeholder={placeholder}
autoComplete="new-password"
value={this.state.password}
required={true} autoFocus={true}
onChange={this.handlePasswordChange} />
}</FormattedMessage>
</>);
const emailInput = (<>
{this.state.haveCode ?
null :
<label htmlFor="inputEmail">
<FormattedMessage id="label_reset_password"
defaultMessage="Send a password reset email"
description="Label for password reset field" />
</label>
}
<FormattedMessage id="credential_email_prompt" defaultMessage="Your registration email"
description="Placeholder for entering email">{
(placeholder) => <input type="email" id="inputEmail"
placeholder={placeholder}
autoComplete="email"
value={this.state.email}
onChange={this.handleEmailChange}
required autoFocus />
}</FormattedMessage>
</>);
const phoneInput = (<>
{this.state.haveCode ?
null :
<div><label>
<FormattedMessage id="label_reset_password_tel"
defaultMessage="Send SMS to reset password"
description="Label for password reset field by phone" />
</label></div>
}
<div>
<label className="small gray">
<FormattedMessage id="mobile_phone_number" defaultMessage="Mobile phone number"
description="Prompt for entering a mobile phone number" />
</label>
</div>
<div className="panel-form-row">
<Suspense fallback={<div className="panel-form-row"><FormattedMessage id="loading_note"
defaultMessage="Loading..." description="Message shown when component is loading"/></div>}>
<PhoneEdit
autoFocus={true}
onShowCountrySelector={this.props.onShowCountrySelector}
onSubmit={number => this.setState({tel: number})} />
</Suspense>
</div>
</>);
const sentNotification = (<>{
this.method == 'email' ?
<FormattedMessage id="password_reset_email_sent"
defaultMessage="An email with security code has been sent to {email}. Enter the code below."
values={{email: <tt>{this.state.email}</tt>}}
description="Notification that the email with password reset instructions has been sent" />
: this.method == 'tel' ?
<FormattedMessage id="password_reset_sms_sent"
defaultMessage="A text message with security code has been sent to {phone}. Enter the code below."
values={{phone: <tt>{this.state.tel}</tt>}}
description="Notification that the SMS with password reset instructions has been sent" />
:
null
}</>);
const codeInput = (<>
<div>
<label className="small gray" htmlFor="enter-confirmation-code">
<FormattedMessage id="enter_confirmation_code_prompt"
defaultMessage="Confirmation code"
description="Request to enter confirmation code" />
</label>
</div>
<div>
<FormattedMessage id="numeric_confirmation_code_prompt"
defaultMessage="Numbers only" description="Prompt for numeric conformation code">{
(numbers_only) => <input type="text" id="enter-confirmation-code"
placeholder={numbers_only} maxLength={10}
value={this.state.code} onChange={this.handleCodeChange} required />
}</FormattedMessage>
</div>
</>);
const credentialInput = this.method == 'email' ? emailInput : this.method == 'tel' ? phoneInput : null;
return (
<form id="password-reset-form" onSubmit={this.handleSubmit}>
{(this.state.token && this.state.scheme) ?
<><label htmlFor="new-password">
<FormattedMessage id="label_new_password"
defaultMessage="New password"
description="Label for entering a new password" />
</label>
<FormattedMessage id="new_password_placeholder" defaultMessage="Enter new password"
description="Placeholder for entering new password">{
(placeholder) => <VisiblePassword
id="new-password"
placeholder={placeholder}
autoComplete="new-password"
value={this.state.password}
required={true} autoFocus={true}
onChange={this.handlePasswordChange} />
}</FormattedMessage></>
: this.state.sent ?
<>
<br/>
<center><i className="material-icons huge green">task_alt</i></center>
<br/>
<center><FormattedMessage id="password_reset_email_sent"
defaultMessage="An email has been sent to {email}. Follow the directions in the email to reset your password."
values={{ email: <tt>{this.state.email}</tt> }}
description="Notification that the email with password reset instructions has been sent" /></center>
</>
:
<>
<label htmlFor="inputEmail">
<FormattedMessage id="label_reset_password"
defaultMessage="Send a password reset email:"
description="Label for password reset field" />
</label>
<FormattedMessage id="credential_email_prompt" defaultMessage="Your registration email"
description="Placeholder for entering email">{
(placeholder) => <input type="email" id="inputEmail"
placeholder={placeholder}
autoComplete="email"
value={this.state.email}
onChange={this.handleEmailChange}
required autoFocus />
}</FormattedMessage>
</>
}
{this.state.sent ? sentNotification : showCredentialInput ? credentialInput : null}
{this.state.haveCode || this.state.sent ? codeInput : null}
{showPasswordInput ? passwordInput : null}
<div className="dialog-buttons">
{this.state.haveCode || this.state.sent ? null :
<a href="#" onClick={this.handleShowCodeField} style={{marginRight: 'auto'}}>
<FormattedMessage id="password_i_have_code" defaultMessage="I have code"
description="Call to open field to enter password reset code" />
</a>
}
<button className="primary" type="submit">{
(this.state.token && this.state.scheme) ?
<FormattedMessage id="button_reset" defaultMessage="Reset" description="Button [Reset]" />
: this.state.sent ?
<FormattedMessage id="button_ok" defaultMessage="OK"
description="Button [OK]" />
showPasswordInput ?
<FormattedMessage id="button_reset" defaultMessage="Reset" description="Button [Reset]" />
:
<FormattedMessage id="button_send_request" defaultMessage="Send request"
description="Button [Send request]" />
<FormattedMessage id="button_send_request" defaultMessage="Send request"
description="Button [Send request]" />
}</button>
</div>
</form>
+6 -1
View File
@@ -157,6 +157,8 @@ class SidepanelView extends React.PureComponent {
view === 'register' ?
<CreateAccountView
tinode={this.props.tinode}
serverVersion={this.props.serverVersion}
onShowCountrySelector={this.props.onShowCountrySelector}
onCreateAccount={this.props.onCreateAccount}
onCancel={this.props.onCancel}
onError={this.props.onError} /> :
@@ -182,7 +184,7 @@ class SidepanelView extends React.PureComponent {
tinode={this.props.tinode}
myUserId={this.props.myUserId}
onUpdateTopicDesc={this.props.onUpdateAccountDesc}
onUpdateTags={this.props.onUpdateAccountTags}
onUpdateTagsRequest={this.props.onUpdateAccountTags}
onCredAdd={this.props.onCredAdd}
onCredDelete={this.props.onCredDelete}
onCredConfirm={this.props.onCredConfirm}
@@ -245,6 +247,9 @@ class SidepanelView extends React.PureComponent {
view === 'reset' ?
<PasswordResetView
tinode={this.props.tinode}
serverVersion={this.props.serverVersion}
onShowCountrySelector={this.props.onShowCountrySelector}
onRequest={this.props.onPasswordResetRequest}
onReset={this.props.onResetPassword}
onCancel={this.props.onCancel} /> :
+67 -26
View File
@@ -1,5 +1,5 @@
// The top-level class to hold all functionality together.
import React from 'react';
import React, { Suspense } from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { initializeApp as firebaseInitApp } from 'firebase/app';
@@ -12,6 +12,7 @@ import Alert from '../widgets/alert.jsx';
import ContextMenu from '../widgets/context-menu.jsx';
import ForwardDialog from '../widgets/forward-dialog.jsx';
import CallIncoming from '../widgets/call-incoming.jsx';
const PhoneCountrySelector = React.lazy(_ => import('../widgets/phone-country-selector.jsx'));
import InfoView from './info-view.jsx';
import MessagesView from './messages-view.jsx';
@@ -69,6 +70,11 @@ const messages = defineMessages({
defaultMessage: 'Info',
description: 'Show extended topic information'
},
menu_item_audio_call: {
id: 'menu_item_audio_call',
defaultMessage: 'Call',
description: 'Start audio call'
},
menu_item_video_call: {
id: 'menu_item_video_call',
defaultMessage: 'Video call',
@@ -153,11 +159,13 @@ class TinodeWeb extends React.Component {
this.handlePasswordResetRequest = this.handlePasswordResetRequest.bind(this);
this.handleResetPassword = this.handleResetPassword.bind(this);
this.handleContextMenuAction = this.handleContextMenuAction.bind(this);
this.handleShowCountrySelector = this.handleShowCountrySelector.bind(this);
this.handleShowForwardDialog = this.handleShowForwardDialog.bind(this);
this.handleHideForwardDialog = this.handleHideForwardDialog.bind(this);
this.handleStartVideoCall = this.handleStartVideoCall.bind(this);
this.handleStartAudioCall = this.handleStartAudioCall.bind(this);
this.handleInfoMessage = this.handleInfoMessage.bind(this);
this.handleDataMessage = this.handleDataMessage.bind(this);
this.handleCallClose = this.handleCallClose.bind(this);
@@ -233,6 +241,7 @@ class TinodeWeb extends React.Component {
// Video calls.
callTopic: undefined,
callState: CALL_STATE_NONE,
callAudioOnly: undefined,
// If true, call state should be transitioned to CALL_STATE_IN_PROGRESS upon
// switching to the call topic.
callShouldStart: false,
@@ -373,16 +382,12 @@ class TinodeWeb extends React.Component {
try {
this.fcm = firebaseGetMessaging(firebaseInitApp(FIREBASE_INIT, APP_NAME));
return navigator.serviceWorker.getRegistration('/service-worker.js').then(reg => {
if (reg) {
return reg
}
return navigator.serviceWorker.register('/service-worker.js').then(reg => {
return reg || navigator.serviceWorker.register('/service-worker.js').then(reg => {
this.checkForAppUpdate(reg);
return reg;
});
}).then(reg => {
// Pass locale and version config to the service worker.
console.log("SW active:", reg.active, "installing:", reg.installing);
(reg.active || reg.installing).postMessage(JSON.stringify({locale: locale, version: PACKAGE_VERSION}));
// Request token.
return TinodeWeb.requestFCMToken(this.fcm, reg);
@@ -402,7 +407,7 @@ class TinodeWeb extends React.Component {
// Handhe FCM pushes
// (a) for channels always,
// (b) pushes when the app is in foreground but has no focus.
firebaseOnMessage(this.fcm, (payload) => { this.handlePushMessage(payload); });
firebaseOnMessage(this.fcm, payload => { this.handlePushMessage(payload); });
}).catch(err => {
// SW registration or FCM has failed :(
onError(err);
@@ -721,20 +726,23 @@ class TinodeWeb extends React.Component {
this.handleLoginSuccessful();
}
}).catch(err => {
const autoLogin = err.code >= 500;
// Connection or login failed, report error.
this.setState({
loginDisabled: false,
credMethod: undefined,
credCode: undefined,
loadSpinnerVisible: false,
autoLogin: false
autoLogin: autoLogin
});
this.handleError(err.message, 'err');
console.warn("Login failed", err);
if (token) {
this.handleLogout();
if (!autoLogin) {
if (token) {
this.handleLogout();
}
HashNavigation.navigateTo('');
}
HashNavigation.navigateTo('');
});
}
@@ -1162,7 +1170,7 @@ class TinodeWeb extends React.Component {
});
}
handleUpdateAccountTagsRequest(tags) {
handleUpdateAccountTagsRequest(_, tags) {
this.tinode.getMeTopic().setMeta({tags: tags})
.catch(err => this.handleError(err.message, 'err'));
}
@@ -1593,6 +1601,10 @@ class TinodeWeb extends React.Component {
title: this.props.intl.formatMessage(messages.menu_item_info),
handler: this.handleShowInfoView
} : null,
subscribed && Tinode.isP2PTopicName(topicName) && webrtc ? {
title: this.props.intl.formatMessage(messages.menu_item_audio_call),
handler: this.handleStartAudioCall
} : null,
subscribed && Tinode.isP2PTopicName(topicName) && webrtc ? {
title: this.props.intl.formatMessage(messages.menu_item_video_call),
handler: this.handleStartVideoCall
@@ -1700,13 +1712,13 @@ class TinodeWeb extends React.Component {
});
}
handleResetPassword(scheme, newPassword, token) {
token = base64ReEncode(token);
if (!token) {
handleResetPassword(newPassword, tempAuth) {
const secret = base64ReEncode(tempAuth.secret);
if (!secret || !tempAuth.scheme) {
this.handleError(this.props.intl.formatMessage(messages.invalid_security_token), 'err');
} else {
this.tinode.connect()
.then(_ => this.tinode.updateAccountBasic(null, null, newPassword, {token: token}))
.then(_ => this.tinode.updateAccountBasic(null, null, newPassword, {scheme: tempAuth.scheme, secret: secret}))
.then(_ => HashNavigation.navigateTo(''))
.catch(err => {
// Socket error
@@ -1715,18 +1727,41 @@ class TinodeWeb extends React.Component {
}
}
handleShowCountrySelector(code, dial, selectedCallback) {
this.handleShowAlert("Select country",
<Suspense fallback={<div><FormattedMessage id="loading_note" defaultMessage="Loading..."
description="Message shown when component is loading"/></div>}>
<PhoneCountrySelector
selected={code}
onSubmit={(c, d) => {
this.setState({alertVisible: false});
selectedCallback(c, d);
}} />
</Suspense>,
null, null, _ => {}, "Cancel");
}
handleStartVideoCall() {
this.setState({
callTopic: this.state.topicSelected,
callState: CALL_STATE_OUTGOING_INITATED
callState: CALL_STATE_OUTGOING_INITATED,
callAudioOnly: false
});
}
handleCallInvite(callTopic, callSeq, callState) {
handleStartAudioCall() {
this.setState({
callTopic: this.state.topicSelected,
callState: CALL_STATE_OUTGOING_INITATED,
callAudioOnly: true
});
}
handleCallInvite(callTopic, callSeq, callState, audioOnly) {
switch (callState) {
case CALL_STATE_OUTGOING_INITATED:
let head = { webrtc: CALL_HEAD_STARTED };
this.handleSendMessage(Drafty.videoCall(), undefined, undefined, head)
const head = { webrtc: CALL_HEAD_STARTED, aonly: !!audioOnly };
this.handleSendMessage(Drafty.videoCall(audioOnly), undefined, undefined, head)
.then(ctrl => {
if (ctrl.code < 200 || ctrl.code >= 300 || !ctrl.params || !ctrl.params.seq) {
this.handleCallClose();
@@ -1796,7 +1831,8 @@ class TinodeWeb extends React.Component {
}
this.setState({
callTopic: undefined,
callState: CALL_STATE_NONE
callState: CALL_STATE_NONE,
callAudioOnly: undefined
});
}
@@ -1829,7 +1865,8 @@ class TinodeWeb extends React.Component {
this.setState({
callTopic: null,
callState: CALL_STATE_NONE,
callSeq: null
callSeq: null,
callAudioOnly: undefined
});
return;
}
@@ -1862,7 +1899,8 @@ class TinodeWeb extends React.Component {
this.setState({
callTopic: data.topic,
callState: CALL_STATE_INCOMING_RECEIVED,
callSeq: data.seq
callSeq: data.seq,
callAudioOnly: !!msg.head.aonly
});
} else {
// Another call is either in progress or being established.
@@ -1918,10 +1956,11 @@ class TinodeWeb extends React.Component {
{this.state.callTopic && this.state.callState == CALL_STATE_INCOMING_RECEIVED ?
<CallIncoming
tinode={this.tinode}
onClose={this.handleCallClose}
topic={this.state.callTopic}
seq={this.state.callSeq}
callState={this.state.callState}
audioOnly={this.state.callAudioOnly}
onClose={this.handleCallClose}
onRinging={this.handleCallRinging}
onAcceptCall={this.handleCallAccept}
onReject={this.handleCallHangup}
@@ -1931,12 +1970,12 @@ class TinodeWeb extends React.Component {
}
{this.state.alertVisible ?
<Alert
visible={this.state.alertVisible}
title={this.state.alertParams.title}
content={this.state.alertParams.content}
onReject={this.state.alertParams.onReject ? (_ => this.setState({alertVisible: false})) : null}
reject={this.state.alertParams.reject}
onConfirm={_ => {this.setState({alertVisible: false}); this.state.alertParams.onConfirm();}}
onConfirm={this.state.alertParams.onConfirm ?
(_ => {this.setState({alertVisible: false}); this.state.alertParams.onConfirm();}) : null}
confirm={this.state.alertParams.confirm}
/> : null}
<SidepanelView
@@ -2002,6 +2041,7 @@ class TinodeWeb extends React.Component {
onResetPassword={this.handleResetPassword}
onShowArchive={this.handleShowArchive}
onShowBlocked={this.handleShowBlocked}
onShowCountrySelector={this.handleShowCountrySelector}
onInitFind={this.tnInitFind}
searchResults={this.state.searchResults}
@@ -2034,6 +2074,7 @@ class TinodeWeb extends React.Component {
callTopic={this.state.callTopic}
callSeq={this.state.callSeq}
callState={this.state.callState}
callAudioOnly={this.state.callAudioOnly}
onCallHangup={this.handleCallHangup}
onCallInvite={this.handleCallInvite}
+9 -9
View File
@@ -87,21 +87,21 @@ class ValidationView extends React.PureComponent {
return (
<div className="panel-form">
<div className="panel-form-row">
<label className="small" htmlFor="enter-confirmation-code">
<label className="small gray" htmlFor="enter-confirmation-code">
<FormattedMessage id="enter_confirmation_code_prompt"
defaultMessage="Enter confirmation code sent to you by {method}:"
defaultMessage="Confirmation code"
description="Request to enter confirmation code"
values={{method: method}} />
</label>
</div>
<div className="panel-form-row">
<FormattedMessage id="numeric_confirmation_code_prompt"
defaultMessage="Numbers only" description="Prompt for numeric conformation code">{
(numbers_only) => <input type="text" id="enter-confirmation-code"
placeholder={numbers_only}
value={this.state.code} onChange={this.handleChange}
onKeyPress={this.handleKeyPress} required />
}</FormattedMessage>
<FormattedMessage id="numeric_confirmation_code_prompt"
defaultMessage="Numbers only" description="Prompt for numeric conformation code">{
(numbers_only) => <input type="text" id="enter-confirmation-code"
placeholder={numbers_only}
value={this.state.code} onChange={this.handleChange}
onKeyPress={this.handleKeyPress} required />
}</FormattedMessage>
</div>
<div className="dialog-buttons">
<button className="secondary" onClick={this.handleCancel}>
+9 -5
View File
@@ -3,7 +3,7 @@ import { FormattedMessage } from 'react-intl';
export default class Alert extends React.PureComponent {
render() {
<div className="alert-container">
return (<div className="alert-container">
<div className="alert">
<div className="title">{this.props.title}</div>
<div className="content">{this.props.content}</div>
@@ -15,11 +15,15 @@ export default class Alert extends React.PureComponent {
:
null
}
<button className="primary" onClick={this.props.onConfirm}>
{this.props.confirm || <FormattedMessage id="button_ok" defaultMessage="OK" description="Button [OK]" />}
</button>
{this.props.onConfirm ?
<button className="primary" onClick={this.props.onConfirm}>
{this.props.confirm || <FormattedMessage id="button_ok" defaultMessage="OK" description="Button [OK]" />}
</button>
:
null
}
</div>
</div>
</div>
</div>);
}
};
+238 -59
View File
@@ -15,6 +15,9 @@ const CALL_ENDED_SOUND = new Audio('audio/call-end.m4a');
CALL_ENDED_SOUND.loop = true;
const DIALING_SOUND = new Audio('audio/dialing.m4a');
const VIDEO_MUTED_EVENT = 'video:muted';
const VIDEO_UNMUTED_EVENT = 'video:unmuted';
const messages = defineMessages({
already_in_call: {
id: 'already_in_call',
@@ -29,17 +32,24 @@ class CallPanel extends React.PureComponent {
this.state = {
localStream: undefined,
remoteStream: undefined,
pc: undefined,
dataChannel: undefined,
previousOnInfo: undefined,
waitingForPeer: false,
// If true, the client has received a remote SDP from the peer and has sent a local SDP to the peer.
callInitialSetupComplete: false
callInitialSetupComplete: false,
audioOnly: props.callAudioOnly,
// Video mute/unmute in progress.
videoToggleInProgress: false,
// Indicates if the remote peer has informed us that their camera is on.
remoteVideoLive: false,
};
this.localStreamConstraints = {
audio: true,
video: true
video: !props.callAudioOnly
};
this.isOutgoingCall = props.callState == CALL_STATE_OUTGOING_INITATED;
@@ -73,14 +83,24 @@ class CallPanel extends React.PureComponent {
this.handleGetUserMediaError = this.handleGetUserMediaError.bind(this);
this.stopTracks = this.stopTracks.bind(this);
this.disconnectMedia = this.disconnectMedia.bind(this);
this.handleCloseClick = this.handleCloseClick.bind(this);
this.handleToggleCameraClick = this.handleToggleCameraClick.bind(this);
this.handleToggleMicClick = this.handleToggleMicClick.bind(this);
this.toggleMedia = this.toggleMedia.bind(this);
this.handleRemoteHangup = this.handleRemoteHangup.bind(this);
this.handleVideoCallAccepted = this.handleVideoCallAccepted.bind(this);
this.muteVideo = this.muteVideo.bind(this);
this.unmuteVideo = this.unmuteVideo.bind(this);
this.emptyVideoTrack = this.emptyVideoTrack.bind(this);
this.handleDataChannelEvent = this.handleDataChannelEvent.bind(this);
this.handleDataChannelError = this.handleDataChannelError.bind(this);
this.handleDataChannelMessage = this.handleDataChannelMessage.bind(this);
this.handleDataChannelOpen = this.handleDataChannelOpen.bind(this);
this.handleDataChannelClose = this.handleDataChannelClose.bind(this);
}
componentDidMount() {
@@ -105,6 +125,14 @@ class CallPanel extends React.PureComponent {
const stream = this.state.localStream;
stream.getTracks().forEach(track => {
pc.addTrack(track, stream);
if (track.kind == 'video' && this.state.audioOnly) {
// This is an audio-only call.
// Remove dummy video track (placeholder remains).
track.enabled = false;
track.stop();
stream.removeTrack(track);
}
});
}
@@ -138,6 +166,16 @@ class CallPanel extends React.PureComponent {
}
}
// Creates an empty video track placeholder.
emptyVideoTrack() {
const width = 640;
const height = 480;
const canvas = Object.assign(document.createElement("canvas"), {width, height});
canvas.getContext('2d').fillRect(0, 0, width, height);
const stream = canvas.captureStream(0);
return Object.assign(stream.getVideoTracks()[0], {enabled: false});
}
start() {
if (this.state.localStream) {
this.props.onError(this.props.intl.formatMessage(messages.already_in_call), 'info');
@@ -146,7 +184,7 @@ class CallPanel extends React.PureComponent {
if (this.props.callState == CALL_STATE_IN_PROGRESS) {
// We apparently just accepted the call.
this.props.onInvite(this.props.topic, this.props.seq, this.props.callState);
this.props.onInvite(this.props.topic, this.props.seq, CALL_STATE_IN_PROGRESS, this.props.callAudioOnly);
return;
}
@@ -154,13 +192,18 @@ class CallPanel extends React.PureComponent {
// Start local video.
navigator.mediaDevices.getUserMedia(this.localStreamConstraints)
.then(stream => {
if (!this.localStreamConstraints.video) {
// Starting an audio-only call. Create a dummy video track
// (so video can be enabled during the call if the user desires).
stream.addTrack(this.emptyVideoTrack());
}
this.setState({localStream: stream, waitingForPeer: true});
this.localRef.current.srcObject = stream;
DIALING_SOUND.play();
// Send call invitation.
this.props.onInvite(this.props.topic, this.props.seq, this.props.callState);
this.props.onInvite(this.props.topic, this.props.seq, this.props.callState, this.props.callAudioOnly);
})
.catch(this.handleGetUserMediaError);
}
@@ -171,8 +214,10 @@ class CallPanel extends React.PureComponent {
RING_SOUND.pause();
RING_SOUND.currentTime = 0;
this.stopTracks(this.localRef.current);
this.stopTracks(this.remoteRef.current);
this.stopTracks(this.state.localStream);
this.stopTracks(this.state.remoteStream);
this.disconnectMedia(this.localRef.current);
this.disconnectMedia(this.remoteRef.current);
if (this.state.pc) {
this.state.pc.ontrack = null;
this.state.pc.onremovetrack = null;
@@ -183,17 +228,26 @@ class CallPanel extends React.PureComponent {
this.state.pc.onicegatheringstatechange = null;
this.state.pc.onnegotiationneeded = null;
this.state.pc.onicecandidateerror = null;
this.state.pc.ondatachannel = null;
if (this.state.dataChannel) {
this.state.dataChannel.close();
}
this.state.pc.close();
}
this.setState({pc: null, waitingForPeer: false});
}
stopTracks(el) {
disconnectMedia(el) {
if (!el) {
return;
}
let stream = el.srcObject;
el.srcObject = null;
el.src = '';
}
stopTracks(stream) {
if (!stream) {
return;
}
@@ -205,8 +259,42 @@ class CallPanel extends React.PureComponent {
track.enabled = false;
});
}
el.srcObject = null;
el.src = '';
}
handleDataChannelError(error) {
console.error('data channel error', error);
}
handleDataChannelMessage(event) {
switch (event.data) {
case VIDEO_MUTED_EVENT:
this.setState({remoteVideoLive: false}, _ => { this.remoteRef.current.srcObject = this.state.remoteStream; });
break;
case VIDEO_UNMUTED_EVENT:
this.setState({remoteVideoLive: true}, _ => { this.remoteRef.current.srcObject = this.state.remoteStream; });
break;
default:
break;
}
}
handleDataChannelOpen(event) {
if (!this.state.audioOnly) {
event.target.send(VIDEO_UNMUTED_EVENT);
}
}
handleDataChannelClose() {
/* no action */
}
handleDataChannelEvent(event) {
const channel = event.channel;
channel.onerror = this.handleDataChannelError;
channel.onmessage = this.handleDataChannelMessage;
channel.onopen = this.handleDataChannelOpen;
channel.onclose = this.handleDataChannelClose;
this.setState({dataChannel: channel});
}
createPeerConnection() {
@@ -220,8 +308,17 @@ class CallPanel extends React.PureComponent {
pc.onnegotiationneeded = this.handleNegotiationNeededEvent;
pc.onicecandidateerror = this.handleIceCandidateErrorEvent;
pc.ontrack = this.handleTrackEvent;
pc.ondatachannel = this.handleDataChannelEvent;
// creating data channel events
const channel = pc.createDataChannel("events", {ordered: true});
channel.onerror = this.handleDataChannelError;
channel.onmessage = this.handleDataChannelMessage;
channel.onopen = this.handleDataChannelOpen;
channel.onclose = this.handleDataChannelClose;
this.setState({pc: pc, dataChannel: channel, waitingForPeer: false});
this.setState({pc: pc, waitingForPeer: false});
return pc;
}
@@ -233,7 +330,7 @@ class CallPanel extends React.PureComponent {
.then(_ => {
this.setState({ callInitialSetupComplete: true }, _ => this.drainRemoteIceCandidatesCache());
})
.catch(this.reportError);
.catch(err => this.reportError(err));
}
reportError(err) {
@@ -244,17 +341,18 @@ class CallPanel extends React.PureComponent {
return this.isOutgoingCall || this.state.callInitialSetupComplete;
}
handleNegotiationNeededEvent() {
handleNegotiationNeededEvent(event) {
const pc = event.target;
if (!this.canSendOffer()) {
return;
}
this.state.pc.createOffer().then(offer => {
return this.state.pc.setLocalDescription(offer);
pc.createOffer().then(offer => {
return pc.setLocalDescription(offer);
})
.then(_ => {
this.props.onSendOffer(this.props.topic, this.props.seq, this.state.pc.localDescription.toJSON());
this.props.onSendOffer(this.props.topic, this.props.seq, pc.localDescription.toJSON());
})
.catch(this.reportError);
.catch(err => this.reportError(err));
}
handleIceCandidateErrorEvent(event) {
@@ -271,7 +369,12 @@ class CallPanel extends React.PureComponent {
const candidate = new RTCIceCandidate(info.payload);
if (this.state.callInitialSetupComplete) {
this.state.pc.addIceCandidate(candidate)
.catch(this.reportError);
.catch(err => {
if (candidate.candidate) {
this.reportError(err);
}
console.warn("Error adding new ice candidate", candidate, err);
});
} else {
this.remoteIceCandidatesCache.push(candidate);
}
@@ -280,13 +383,18 @@ class CallPanel extends React.PureComponent {
drainRemoteIceCandidatesCache() {
this.remoteIceCandidatesCache.forEach(candidate => {
this.state.pc.addIceCandidate(candidate)
.catch(this.reportError);
.catch(err => {
if (candidate.candidate) {
this.reportError(err);
}
console.warn("Error adding cached ice candidate", candidate, err);
});
});
this.remoteIceCandidatesCache = [];
}
handleICEConnectionStateChangeEvent(event) {
switch (this.state.pc.iceConnectionState) {
switch (event.target.iceConnectionState) {
case 'closed':
case 'failed':
this.handleCloseClick();
@@ -295,7 +403,7 @@ class CallPanel extends React.PureComponent {
}
handleSignalingStateChangeEvent(event) {
if (this.state.pc.signalingState == 'closed') {
if (event.target.signalingState == 'closed') {
this.handleCloseClick();
}
}
@@ -307,23 +415,22 @@ class CallPanel extends React.PureComponent {
handleTrackEvent(event) {
// Remote video becomes available.
this.remoteRef.current.srcObject = event.streams[0];
// Make sure we display the title (peer's name) over the remote video.
this.forceUpdate();
this.setState({remoteStream: event.streams[0]});
}
handleGetUserMediaError(e) {
console.error("Error opening camera and/or microphone", e);
switch(e.name) {
case 'NotFoundError':
// Cannot start the call b/c no camera and/or microphone found.
this.reportError(e.message);
this.reportError(e);
break;
case 'SecurityError':
case 'PermissionDeniedError':
// Do nothing; this is the same as the user canceling the call.
break;
default:
this.reportError(e.message);
console.error("Error opening your camera and/or microphone:", e.message);
this.reportError(e);
break;
}
@@ -334,13 +441,20 @@ class CallPanel extends React.PureComponent {
handleVideoOfferMsg(info) {
let localStream = null;
const pc = this.createPeerConnection();
const pc = this.state.pc ? this.state.pc : this.createPeerConnection();
const desc = new RTCSessionDescription(info.payload);
pc.setRemoteDescription(desc).then(_ => {
return navigator.mediaDevices.getUserMedia(this.localStreamConstraints);
})
.then(stream => {
let dummyVideo;
if (!this.localStreamConstraints.video) {
// Starting an audio-only call. Create an empty video track so
// so the user can enable the video during the call.
dummyVideo = this.emptyVideoTrack();
stream.addTrack(dummyVideo);
}
localStream = stream;
this.localRef.current.srcObject = stream;
this.setState({localStream: stream});
@@ -348,6 +462,12 @@ class CallPanel extends React.PureComponent {
localStream.getTracks().forEach(track => {
pc.addTrack(track, localStream);
});
if (dummyVideo) {
dummyVideo.enabled = false;
dummyVideo.stop();
stream.removeTrack(dummyVideo);
}
})
.then(_ => {
return pc.createAnswer();
@@ -387,60 +507,119 @@ class CallPanel extends React.PureComponent {
this.props.onHangup(this.props.topic, this.props.seq);
}
// Mute/unmute audio/video (specified by kind).
toggleMedia(kind) {
// Ends video track and turns off the camera.
muteVideo() {
if (!this.state.pc || !this.state.dataChannel) {
return;
}
const stream = this.state.localStream;
stream.getTracks().forEach(track => {
if (track.kind != kind) {
return;
const t = stream.getVideoTracks()[0];
t.enabled = false;
t.stop();
stream.removeTrack(t);
this.state.dataChannel.send(VIDEO_MUTED_EVENT);
this.setState({videoToggleInProgress: false});
}
unmuteVideo() {
if (!this.state.pc || !this.state.dataChannel) {
return;
}
navigator.mediaDevices.getUserMedia({ video: true })
.then(stream => {
// Will extract video track from stream and throw stream away,
// and replace video track in the media sender.
this.localRef.current.srcObject = null;
const sender = this.state.pc.getSenders().find(s => s.track.kind == 'video');
const track = stream.getVideoTracks()[0];
// Remote track from new stream.
stream.removeTrack(track);
// Add this track to the existing local stream.
this.state.localStream.addTrack(track);
return sender.replaceTrack(track);
})
.then(_ => {
this.localRef.current.srcObject = this.state.localStream;
this.state.dataChannel.send(VIDEO_UNMUTED_EVENT);
})
.catch(err => this.handleGetUserMediaError(err))
.finally(_ => { this.setState({videoToggleInProgress: false}); }); // Make sure we redraw the mute/unmute icons (e.g. camera -> camera_off).
}
handleToggleCameraClick() {
if (this.state.videoToggleInProgress) {
// Toggle currently in progress.
return;
}
const tracks = this.state.localStream.getVideoTracks();
this.setState({videoToggleInProgress: true}, _ => {
if (tracks && tracks.length > 0 && tracks[0].enabled && tracks[0].readyState == 'live') {
this.muteVideo();
} else {
this.unmuteVideo();
}
track.enabled = !track.enabled;
this.setState({audioOnly: !this.state.audioOnly});
});
}
handleToggleMicClick() {
const stream = this.state.localStream;
const t = stream.getAudioTracks()[0];
t.enabled = !t.enabled;
// Make sure we redraw the mute/unmute icons (e.g. mic -> mic_off).
this.forceUpdate();
}
handleToggleCameraClick() {
this.toggleMedia('video');
}
handleToggleMicClick() {
this.toggleMedia('audio');
}
render() {
const remoteActive = this.remoteRef.current && this.remoteRef.current.srcObject;
const disabled = !(this.state.localStream && this.state.localStream.getTracks());
const audioIcon = this.state.localStream && this.state.localStream.getAudioTracks()[0].enabled ? 'mic' : 'mic_off';
const videoIcon = this.state.localStream && this.state.localStream.getVideoTracks()[0].enabled ? 'videocam' : 'videocam_off';
const audioTracks = this.state.localStream && this.state.localStream.getAudioTracks();
const videoTracks = !this.state.audioOnly && this.state.localStream && this.state.localStream.getVideoTracks();
const disabled = !this.state.pc || !this.state.dataChannel || !(audioTracks && audioTracks[0]);
const audioIcon = audioTracks && audioTracks[0] && audioTracks[0].enabled ? 'mic' : 'mic_off';
const videoIcon = videoTracks && videoTracks[0] && videoTracks[0].enabled && videoTracks[0].readyState == 'live' ? 'videocam' : 'videocam_off';
const peerTitle = clipStr(this.props.title, MAX_PEER_TITLE_LENGTH);
const pulseAnimation = this.state.waitingForPeer ? ' pulse' : '';
let remoteActive = false;
if (this.remoteRef.current && this.remoteRef.current.srcObject && this.state.remoteVideoLive) {
const rstream = this.remoteRef.current.srcObject;
if (rstream.getVideoTracks().length > 0) {
const t = rstream.getVideoTracks()[0];
remoteActive = t.enabled && t.readyState == 'live';
}
}
return (
<>
<div id="video-container">
<div id="video-container-panel">
<div className="call-party self">
<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>
<div className="call-party peer">
<video ref={this.remoteRef} autoPlay playsInline />
<div className="call-party peer" disabled={!remoteActive}>
{remoteActive ?
<div className="caller-name inactive">{peerTitle}</div> :
<div className={`caller-card${pulseAnimation}`}>
<div className="avatar-box">
<LetterTile
tinode={this.props.tinode}
avatar={this.props.avatar}
topic={this.props.topic}
title={this.props.title} />
<>
<video ref={this.remoteRef} autoPlay playsInline />
<div className="caller-name inactive">{peerTitle}</div>
</> :
<>
<audio ref={this.remoteRef} autoPlay />
<div className={`caller-card${pulseAnimation}`}>
<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 className="caller-name">{peerTitle}</div>
</div>
</>
}
</div>
</div>
+1 -1
View File
@@ -28,7 +28,7 @@ export default class CallStatus extends React.PureComponent {
duration = <FormattedMessage id="call_disconnected" defaultMessage="disconnected" description="Label for disconnected call" />;
break;
}
} else if (this.props.callState == 'started' && !this.props.duration) {
} else if (['accepted', 'started'].includes(this.props.callState) && !this.props.duration) {
duration = <FormattedMessage id="call_in_progress" defaultMessage="in progress" description="Label for call in progress" />
} else {
duration = <span>{secondsToTime(this.props.duration / 1000)}</span>;
+61
View File
@@ -0,0 +1,61 @@
// Selector for country dialing code.
import React from 'react';
import { injectIntl } from 'react-intl';
import * as dcodes from '../dcodes.json';
import { flagEmoji } from '../lib/strformat';
class PhoneCountrySelector extends React.PureComponent {
constructor(props) {
super(props);
this.countries = [];
const { formatDisplayName } = props.intl;
dcodes.default.forEach(dc => {
const parts = dc.dial.split(',');
parts.forEach(part => {
this.countries.push({
dial: part.trim(),
code: dc.code,
flag: flagEmoji(dc.code),
name: formatDisplayName(dc.code, {type: 'region'})
});
});
});
this.countries.sort((a, b) => a.name.localeCompare(b.name));
}
componentDidMount() {
if (this.selectedRef) {
this.selectedRef.scrollIntoView({block: 'center', inline: 'nearest'});
}
}
render() {
const countries = [];
const selected = this.props.selected || 'US';
this.countries.forEach((c, idx) => {
const style = (c.code == selected ? 'selected ' : '');
countries.push(<li className={style} key={idx}
ref={ref => {if (c.code == selected) {this.selectedRef = ref}}}
onClick={_ => this.props.onSubmit(c.code, c.dial)}>
<span className="country-flag">{c.flag}</span>
<span className="country">&nbsp;{c.name}</span>
<span className="dial-code">&nbsp;+{c.dial}</span>
</li>);
});
return (
<div className="scrollable-panel" style={{height: '30rem'}}>
<ul className="phone-country-selector">
{countries}
</ul>
</div>
);
}
}
export default injectIntl(PhoneCountrySelector);
+109
View File
@@ -0,0 +1,109 @@
// Editor for a phone number.
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { getExampleNumber, parsePhoneNumber } from 'libphonenumber-js/mobile';
import examples from 'libphonenumber-js/mobile/examples'
import * as dcodes from '../dcodes.json';
import { flagEmoji } from '../lib/strformat';
const messages = defineMessages({
mobile_number_required: {
id: 'mobile_number_required',
defaultMessage: 'Mobile phone number required',
description: 'Error message'
}
});
class PhoneEdit extends React.PureComponent {
constructor(props) {
super(props);
this.codeMap = {};
dcodes.default.forEach(dc => { this.codeMap[dc.code] = dc.dial; });
const code = props.countryCode || 'US';
const dial = this.codeMap[code];
this.state = {
countryCode: code,
dialCode: dial,
localNumber: '',
placeholderNumber: this.placeholderNumber(code, dial)
};
this.handleChange = this.handleChange.bind(this);
this.handleFinished = this.handleFinished.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.showCountrySelector = this.showCountrySelector.bind(this);
}
handleChange(e) {
this.setState({localNumber: this.filterNumber(e.target.value)});
}
handleFinished(e) {
e.preventDefault();
const raw = `${this.state.dialCode}${this.state.localNumber.trim()}`.replace(/[^\d]/g, '');
let number = null;
try {
number = parsePhoneNumber(`+${raw}`);
} catch (err) {}
if (!number || !number.isValid()) {
this.inputField.setCustomValidity(this.props.intl.formatMessage(messages.mobile_number_required));
return;
}
this.props.onSubmit(number.format('E.164'));
}
handleKeyDown(e) {
if (e.key === 'Enter') {
this.handleFinished(e);
}
}
showCountrySelector() {
this.props.onShowCountrySelector(this.state.countryCode, this.state.dialCode,
(code, dial) => {
this.setState({
countryCode: code,
dialCode: dial,
placeholderNumber: this.placeholderNumber(code, dial)
})
});
}
// Filter out characters not permitted in a phone number.
filterNumber(number) {
if (!number) {
return number;
}
// Leave numbers, space, (, ), -, and .
// The + is not allowed: it's handled by the country code portion.
return number.replace(/[^-\s().\d]/g, '');
}
placeholderNumber(code, dial) {
const sample = getExampleNumber(code, examples);
return sample ? sample.formatInternational().substring(dial.length + 1).trim() : '123 0123';
}
render() {
return (
<>
<span className="dial-code" onClick={this.showCountrySelector}>
<span className="country-flag">{flagEmoji(this.state.countryCode)}&nbsp;</span>
+{this.state.dialCode}&nbsp;</span>
<input type="tel" ref={ref => {this.inputField = ref}} placeholder={this.state.placeholderNumber}
value={this.state.localNumber} onChange={this.handleChange}
maxLength={17} onKeyDown={this.handleKeyDown} onBlur={this.handleFinished}
required autoFocus={this.props.autoFocus} />
</>
);
}
}
export default injectIntl(PhoneEdit);
+1 -1
View File
@@ -30,7 +30,7 @@ export default class SearchContacts extends React.PureComponent {
handleSearch(e) {
e.preventDefault();
var query = this.state.search.trim();
const query = this.state.search.trim();
this.setState({edited: (query.length > 0)});
this.props.onSearchContacts(query.length > 0 ? query : Tinode.DEL_CHAR);
}
+13 -6
View File
@@ -1,6 +1,6 @@
// Send message form.
import React, { Suspense } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { Drafty } from 'tinode-sdk';
// Lazy-loading AudioRecorder because it's quite large due to
@@ -90,6 +90,10 @@ class SendMessage extends React.PureComponent {
componentDidMount() {
if (this.messageEditArea) {
this.messageEditArea.addEventListener('paste', this.handlePasteEvent, false);
if (window.getComputedStyle(this.messageEditArea).getPropertyValue('transition-property') == 'all') {
// Set focus on desktop, but not on mobile: focus causes soft keyboard to pop up.
this.messageEditArea.focus();
}
}
this.setState({quote: this.formatReply()});
@@ -103,7 +107,10 @@ class SendMessage extends React.PureComponent {
componentDidUpdate(prevProps) {
if (this.messageEditArea) {
this.messageEditArea.focus();
if (window.getComputedStyle(this.messageEditArea).getPropertyValue('transition-property') == 'all') {
// Set focus on desktop, but not on mobile: focus causes soft keyboard to pop up.
this.messageEditArea.focus();
}
}
if (prevProps.topicName != this.props.topicName) {
@@ -260,17 +267,17 @@ class SendMessage extends React.PureComponent {
{this.props.noInput ?
(quote || <div className="hr thin" />) :
(this.state.audioRec ?
(<Suspense fallback={<div>Loading...</div>}>
(<Suspense fallback={<div><FormattedMessage id="loading_note" defaultMessage="Loading..."
description="Message shown when component is loading"/></div>}>
<AudioRecorder
onRecordingProgress={_ => this.props.onKeyPress(true)}
onDeleted={_ => this.setState({audioRec: false})}
onFinished={this.handleAttachAudio}/>
</Suspense>) :
<textarea id="sendMessage" placeholder={prompt}
<textarea id="send-message-input" placeholder={prompt}
value={this.state.message} onChange={this.handleMessageTyping}
onKeyPress={this.handleKeyPress}
ref={(ref) => {this.messageEditArea = ref;}}
autoFocus />)}
ref={ref => {this.messageEditArea = ref;}} />)}
{this.state.message || !audioEnabled ?
<a href="#" onClick={this.handleSend} title={formatMessage(messages.icon_title_send)}>
<i className="material-icons">{sendIcon}</i>
+1 -1
View File
@@ -117,7 +117,7 @@ export default class TopicCommon extends React.Component {
// Request server to change tags.
handleTagsUpdated(tags) {
if (!arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
this.props.onTopicTagsUpdateRequest(this.props.topic, tags);
this.props.onUpdateTagsRequest(this.props.topic, tags);
}
}
-4
View File
@@ -2,10 +2,6 @@
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import InPlaceEdit from './in-place-edit.jsx';
import { NO_ACCESS_MODE } from '../config.js';
const messages = defineMessages({
clear_messages: {
id: 'action_clear_messages',
+2
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
+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
+2
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"251.prod.js","mappings":"skNAQA,MAAMA,UAAkBC,IAAAA,cACtBC,YAAYC,GACVC,MAAMD,GAENE,KAAKC,QAAU,CAAC,EAChBC,EAAAA,SAAuBC,IAAQH,KAAKC,QAAQE,EAAGC,MAAQD,EAAGE,IAAI,IAE9DL,KAAKM,MAAQ,CACXC,YAAaT,EAAMS,aAAe,KAClCC,SAAUR,KAAKC,QAAQH,EAAMS,aAAe,MAC5CE,YAAa,IAGfT,KAAKU,aAAeV,KAAKU,aAAaC,KAAKX,MAC3CA,KAAKY,eAAiBZ,KAAKY,eAAeD,KAAKX,MAC/CA,KAAKa,cAAgBb,KAAKa,cAAcF,KAAKX,MAC7CA,KAAKc,oBAAsBd,KAAKc,oBAAoBH,KAAKX,KAC3D,CAEAU,aAAaK,GACXf,KAAKgB,SAAS,CAACP,YAAaT,KAAKiB,aAAaF,EAAEG,OAAOC,QACzD,CAEAP,eAAeG,GAEbA,EAAEK,iBACF,MAAMC,EAAU,GAAErB,KAAKM,MAAME,WAAWR,KAAKM,MAAMG,YAAYa,SAASC,QAAQ,SAAU,IAC1FvB,KAAKF,MAAM0B,SAAU,IAAGH,IAC1B,CAGAR,cAAcE,GACE,UAAVA,EAAEU,KACJzB,KAAKY,eAAeG,EAExB,CAEAD,sBACEd,KAAKF,MAAM4B,sBAAsB1B,KAAKM,MAAMC,YAAaP,KAAKM,MAAME,SACtE,CAGAS,aAAaI,GACX,OAAKA,EAKEA,EAAOE,QAAQ,eAAgB,IAJ7BF,CAKX,CAEAM,SACE,OACE,oCACE,0BAAMC,UAAU,YAAYC,QAAS7B,KAAKc,qBACxC,0BAAMc,UAAU,iBAAgBE,EAAAA,EAAAA,IAAU9B,KAAKM,MAAMC,aAAa,KAAa,IAC7EP,KAAKM,MAAME,SAAS,KACxB,2BAAOuB,KAAK,MAAMC,YAAahC,KAAKM,MAAM0B,YACtCb,MAAOnB,KAAKM,MAAMG,YAAawB,SAAUjC,KAAKU,aAC9CwB,UAAW,GAAIC,UAAWnC,KAAKa,cAAeuB,OAAQpC,KAAKY,eAC3DyB,UAAQ,EAACC,UAAWtC,KAAKF,MAAMwC,YAGzC,EAGF,SAAeC,EAAAA,EAAAA,YAAW5C,E","sources":["webpack://tinode-webapp/./src/widgets/phone-edit.jsx"],"sourcesContent":["// Editor for a phone number.\n\nimport React from 'react';\nimport { injectIntl } from 'react-intl';\n\nimport * as dcodes from '../dcodes.json';\nimport { flagEmoji } from '../lib/strformat';\n\nclass PhoneEdit extends React.PureComponent {\n constructor(props) {\n super(props);\n\n this.codeMap = {};\n dcodes.default.forEach(dc => { this.codeMap[dc.code] = dc.dial; });\n\n this.state = {\n countryCode: props.countryCode || 'US',\n dialCode: this.codeMap[props.countryCode || 'US'],\n localNumber: ''\n };\n\n this.handleChange = this.handleChange.bind(this);\n this.handleFinished = this.handleFinished.bind(this);\n this.handleKeyDown = this.handleKeyDown.bind(this);\n this.showCountrySelector = this.showCountrySelector.bind(this);\n }\n\n handleChange(e) {\n this.setState({localNumber: this.filterNumber(e.target.value)});\n }\n\n handleFinished(e) {\n // this.setCustomValidity(\"Mobile phone number required\");\n e.preventDefault();\n const number = `${this.state.dialCode}${this.state.localNumber.trim()}`.replace(/[^\\d]/g, '')\n this.props.onSubmit(`+${number}`);\n }\n\n\n handleKeyDown(e) {\n if (e.key === 'Enter') {\n this.handleFinished(e);\n }\n }\n\n showCountrySelector() {\n this.props.onShowCountrySelector(this.state.countryCode, this.state.dialCode);\n }\n\n // Filter out characters not permitted in a phone number.\n filterNumber(number) {\n if (!number) {\n return number;\n }\n // Leave numbers, space, (, ), -, and .\n // The + is not allowed: it's handled by the country code portion.\n return number.replace(/[^-\\s().\\d]/g, '');\n }\n\n render() {\n return (\n <>\n <span className=\"dial-code\" onClick={this.showCountrySelector}>\n <span className=\"country-flag\">{flagEmoji(this.state.countryCode)}&nbsp;</span>\n +{this.state.dialCode}&nbsp;</span>\n <input type=\"tel\" placeholder={this.state.placeholder}\n value={this.state.localNumber} onChange={this.handleChange}\n maxLength={17} onKeyDown={this.handleKeyDown} onBlur={this.handleFinished}\n required autoFocus={this.props.autoFocus} />\n </>\n );\n }\n}\n\nexport default injectIntl(PhoneEdit);\n"],"names":["PhoneEdit","React","constructor","props","super","this","codeMap","dcodes","dc","code","dial","state","countryCode","dialCode","localNumber","handleChange","bind","handleFinished","handleKeyDown","showCountrySelector","e","setState","filterNumber","target","value","preventDefault","number","trim","replace","onSubmit","key","onShowCountrySelector","render","className","onClick","flagEmoji","type","placeholder","onChange","maxLength","onKeyDown","onBlur","required","autoFocus","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
+2
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"290.prod.js","mappings":"2MASA,MAAMA,UAA6BC,IAAAA,cACjCC,YAAYC,GACVC,MAAMD,GAENE,KAAKC,UAAY,GACjB,MAAM,kBAAEC,GAAsBJ,EAAMK,KACpCC,EAAAA,SAAuBC,IACPA,EAAGC,KAAKC,MAAM,KACtBC,SAAQC,IACZT,KAAKC,UAAUS,KAAK,CAClBJ,KAAMG,EAAKE,OACXC,KAAMP,EAAGO,KACTC,MAAMC,EAAAA,EAAAA,IAAUT,EAAGO,MACnBG,KAAMb,EAAkBG,EAAGO,KAAM,CAACI,KAAM,YACxC,GACF,IAGJhB,KAAKC,UAAUgB,MAAK,CAACC,EAAGC,IAAMD,EAAEH,KAAKK,cAAcD,EAAEJ,OACvD,CAEAM,oBACMrB,KAAKsB,aACPtB,KAAKsB,YAAYC,eAAe,CAACC,MAAO,SAAUC,OAAQ,WAE9D,CAEAC,SACE,MAAMzB,EAAY,GACZ0B,EAAW3B,KAAKF,MAAM6B,UAAY,KAYxC,OAXA3B,KAAKC,UAAUO,SAAQ,CAACoB,EAAGC,KACzB,MAAMC,EAASF,EAAEhB,MAAQe,EAAW,YAAc,GAClD1B,EAAUS,KAAK,wBAAIqB,UAAWD,EAAOE,IAAKH,EACxCI,IAAKA,IAAYL,EAAEhB,MAAQe,IAAW3B,KAAKsB,YAAcW,EAAG,EAC5DC,QAASC,GAAKnC,KAAKF,MAAMsC,SAASR,EAAEhB,KAAMgB,EAAEtB,OAC5C,0BAAMyB,UAAU,gBAAgBH,EAAEf,MAClC,0BAAMkB,UAAU,WAAU,IAAOH,EAAEb,MACnC,0BAAMgB,UAAU,aAAY,KAAQH,EAAEtB,OAClC,IAIN,yBAAKyB,UAAU,mBAAmBD,MAAO,CAACO,OAAQ,UAChD,wBAAIN,UAAU,0BACX9B,GAIT,EAGF,SAAeqC,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","dc","dial","split","forEach","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
+2
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
+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
+721 -295
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
@@ -0,0 +1 @@
{"version":3,"file":"src_widgets_phone-country-selector_jsx.dev.js","mappings":";;;;;;;;;;;;;;;;;;;AAE0B;AACc;AAEC;AAEI;AAE7C,MAAMI,oBAAoB,SAASJ,4DAAmB,CAAC;EACrDM,WAAW,CAACC,KAAK,EAAE;IACjB,KAAK,CAACA,KAAK,CAAC;IAEZ,IAAI,CAACC,SAAS,GAAG,EAAE;IACnB,MAAM;MAAEC;IAAkB,CAAC,GAAGF,KAAK,CAACG,IAAI;IACxCR,iDAAsB,CAACW,EAAE,IAAI;MAC3B,MAAMC,KAAK,GAAGD,EAAE,CAACE,IAAI,CAACC,KAAK,CAAC,GAAG,CAAC;MAChCF,KAAK,CAACF,OAAO,CAACK,IAAI,IAAI;QACpB,IAAI,CAACT,SAAS,CAACU,IAAI,CAAC;UAClBH,IAAI,EAAEE,IAAI,CAACE,IAAI,EAAE;UACjBC,IAAI,EAAEP,EAAE,CAACO,IAAI;UACbC,IAAI,EAAElB,yDAAS,CAACU,EAAE,CAACO,IAAI,CAAC;UACxBE,IAAI,EAAEb,iBAAiB,CAACI,EAAE,CAACO,IAAI,EAAE;YAACG,IAAI,EAAE;UAAQ,CAAC;QACnD,CAAC,CAAC;MACJ,CAAC,CAAC;IACJ,CAAC,CAAC;IAEF,IAAI,CAACf,SAAS,CAACgB,IAAI,CAAC,CAACC,CAAC,EAAEC,CAAC,KAAKD,CAAC,CAACH,IAAI,CAACK,aAAa,CAACD,CAAC,CAACJ,IAAI,CAAC,CAAC;EAC7D;EAEAM,iBAAiB,GAAG;IAClB,IAAI,IAAI,CAACC,WAAW,EAAE;MACpB,IAAI,CAACA,WAAW,CAACC,cAAc,CAAC;QAACC,KAAK,EAAE,QAAQ;QAAEC,MAAM,EAAE;MAAS,CAAC,CAAC;IACvE;EACF;EAEAC,MAAM,GAAG;IACP,MAAMzB,SAAS,GAAG,EAAE;IACpB,MAAM0B,QAAQ,GAAG,IAAI,CAAC3B,KAAK,CAAC2B,QAAQ,IAAI,IAAI;IAC5C,IAAI,CAAC1B,SAAS,CAACI,OAAO,CAAC,CAACuB,CAAC,EAAEC,GAAG,KAAK;MACjC,MAAMC,KAAK,GAAIF,CAAC,CAACf,IAAI,IAAIc,QAAQ,GAAG,WAAW,GAAG,EAAG;MACrD1B,SAAS,CAACU,IAAI,CAAC;QAAI,SAAS,EAAEmB,KAAM;QAAC,GAAG,EAAED,GAAI;QAC5C,GAAG,EAAEE,GAAG,IAAI;UAAC,IAAIH,CAAC,CAACf,IAAI,IAAIc,QAAQ,EAAE;YAAC,IAAI,CAACL,WAAW,GAAGS,GAAG;UAAA;QAAC,CAAE;QAC/D,OAAO,EAAEC,CAAC,IAAI,IAAI,CAAChC,KAAK,CAACiC,QAAQ,CAACL,CAAC,CAACf,IAAI,EAAEe,CAAC,CAACpB,IAAI;MAAE,GAClD;QAAM,SAAS,EAAC;MAAc,GAAEoB,CAAC,CAACd,IAAI,CAAQ,EAC9C;QAAM,SAAS,EAAC;MAAS,GAAC,MAAM,EAACc,CAAC,CAACb,IAAI,CAAQ,EAC/C;QAAM,SAAS,EAAC;MAAW,GAAC,OAAO,EAACa,CAAC,CAACpB,IAAI,CAAQ,CAC/C,CAAC;IACR,CAAC,CAAC;IAEF,OACE;MAAK,SAAS,EAAC,kBAAkB;MAAC,KAAK,EAAE;QAAC0B,MAAM,EAAE;MAAO;IAAE,GACzD;MAAI,SAAS,EAAC;IAAwB,GACnCjC,SAAS,CACP,CACD;EAEV;AACF;AAEA,iEAAeP,sDAAU,CAACG,oBAAoB,CAAC","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":["React","injectIntl","dcodes","flagEmoji","PhoneCountrySelector","PureComponent","constructor","props","countries","formatDisplayName","intl","default","forEach","dc","parts","dial","split","part","push","trim","code","flag","name","type","sort","a","b","localeCompare","componentDidMount","selectedRef","scrollIntoView","block","inline","render","selected","c","idx","style","ref","_","onSubmit","height"],"sourceRoot":""}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+51 -27
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,3 +1,3 @@
// This is a generated file. Don't edit.
const PACKAGE_VERSION = "0.21.0-beta1";
const PACKAGE_VERSION = "0.21.0-rc2";