Merge branch 'develop' into feature-change-password-validation

This commit is contained in:
Šimon Brandner 2020-11-27 14:46:08 +01:00
commit eb64e5b2af
61 changed files with 2079 additions and 1439 deletions

View file

@ -1,3 +1,73 @@
Changes in [3.9.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.9.0) (2020-11-23)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.9.0-rc.1...v3.9.0)
* Upgrade JS SDK to 9.2.0
* [Release] Fix encrypted video playback in Chrome-based browsers
[\#5431](https://github.com/matrix-org/matrix-react-sdk/pull/5431)
Changes in [3.9.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.9.0-rc.1) (2020-11-18)
=============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.8.0...v3.9.0-rc.1)
* Upgrade JS SDK to 9.2.0-rc.1
* Translations update from Weblate
[\#5429](https://github.com/matrix-org/matrix-react-sdk/pull/5429)
* Fix message search summary text
[\#5428](https://github.com/matrix-org/matrix-react-sdk/pull/5428)
* Shrink new room intro top margin to half for encryption bubble tile
[\#5427](https://github.com/matrix-org/matrix-react-sdk/pull/5427)
* Small delight tweaks to improve rough corners in the app
[\#5418](https://github.com/matrix-org/matrix-react-sdk/pull/5418)
* Fix DM logic to always pick a more reliable DM room
[\#5424](https://github.com/matrix-org/matrix-react-sdk/pull/5424)
* Update styling of the Analytics toast
[\#5408](https://github.com/matrix-org/matrix-react-sdk/pull/5408)
* Fix vertical centering of the Homepage and button layout
[\#5420](https://github.com/matrix-org/matrix-react-sdk/pull/5420)
* Fix BaseAvatar sometimes messing up and duplicating the url
[\#5422](https://github.com/matrix-org/matrix-react-sdk/pull/5422)
* Disable buttons when required by MSC2790
[\#5412](https://github.com/matrix-org/matrix-react-sdk/pull/5412)
* Fix drag drop file to upload for Safari
[\#5414](https://github.com/matrix-org/matrix-react-sdk/pull/5414)
* Fix poorly i18n'd string
[\#5416](https://github.com/matrix-org/matrix-react-sdk/pull/5416)
* Fix the feedback not closing without feedback/countly
[\#5417](https://github.com/matrix-org/matrix-react-sdk/pull/5417)
* Fix New Room Intro invite to this room button
[\#5419](https://github.com/matrix-org/matrix-react-sdk/pull/5419)
* Change how we expose Role in User Info and hide in DMs
[\#5413](https://github.com/matrix-org/matrix-react-sdk/pull/5413)
* Disallow sending of empty messages
[\#5390](https://github.com/matrix-org/matrix-react-sdk/pull/5390)
* hide some validation tooltips if fields are valid.
[\#5403](https://github.com/matrix-org/matrix-react-sdk/pull/5403)
* Improvements around new room empty space interactions
[\#5398](https://github.com/matrix-org/matrix-react-sdk/pull/5398)
* Implement call hold
[\#5366](https://github.com/matrix-org/matrix-react-sdk/pull/5366)
* Fix Skeleton UI showing up when not intended.
[\#5407](https://github.com/matrix-org/matrix-react-sdk/pull/5407)
* Close context menu when user clicks the Home button
[\#5406](https://github.com/matrix-org/matrix-react-sdk/pull/5406)
* Skip e2ee warn logout prompt if user has no megolm sessions to lose
[\#5410](https://github.com/matrix-org/matrix-react-sdk/pull/5410)
* Allow country names to be translated
[\#5405](https://github.com/matrix-org/matrix-react-sdk/pull/5405)
* Support thirdparty lookup for phone numbers
[\#5396](https://github.com/matrix-org/matrix-react-sdk/pull/5396)
* Change "Password" to "New Password"
[\#5371](https://github.com/matrix-org/matrix-react-sdk/pull/5371)
* Add customisation point for dehydration key
[\#5397](https://github.com/matrix-org/matrix-react-sdk/pull/5397)
* Rebrand Riot -> Element in the permalink classes
[\#5386](https://github.com/matrix-org/matrix-react-sdk/pull/5386)
* Invite / Create DM UX tweaks
[\#5387](https://github.com/matrix-org/matrix-react-sdk/pull/5387)
* Tweaks to toasts and post-registration landing
[\#5383](https://github.com/matrix-org/matrix-react-sdk/pull/5383)
Changes in [3.8.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.8.0) (2020-11-09) Changes in [3.8.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.8.0) (2020-11-09)
=================================================================================================== ===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.8.0-rc.1...v3.8.0) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.8.0-rc.1...v3.8.0)

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "3.8.0", "version": "3.9.0",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -76,10 +76,12 @@
"highlight.js": "^10.1.2", "highlight.js": "^10.1.2",
"html-entities": "^1.3.1", "html-entities": "^1.3.1",
"is-ip": "^2.0.0", "is-ip": "^2.0.0",
"katex": "^0.12.0",
"cheerio": "^1.0.0-rc.3",
"linkifyjs": "^2.1.9", "linkifyjs": "^2.1.9",
"lodash": "^4.17.19", "lodash": "^4.17.19",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
"matrix-widget-api": "^0.1.0-beta.9", "matrix-widget-api": "^0.1.0-beta.10",
"minimist": "^1.2.5", "minimist": "^1.2.5",
"pako": "^1.0.11", "pako": "^1.0.11",
"parse5": "^5.1.1", "parse5": "^5.1.1",

View file

@ -19,57 +19,6 @@ limitations under the License.
min-height: 50px; min-height: 50px;
} }
/* position the indicator in the same place horizontally as .mx_EventTile_avatar. */
.mx_RoomStatusBar_indicator {
padding-left: 17px;
padding-right: 12px;
margin-left: -73px;
margin-top: 15px;
float: left;
width: 24px;
text-align: center;
}
.mx_RoomStatusBar_callBar {
height: 50px;
line-height: $font-50px;
}
.mx_RoomStatusBar_placeholderIndicator span {
color: $primary-fg-color;
opacity: 0.5;
position: relative;
top: -4px;
/*
animation-duration: 1s;
animation-name: bounce;
animation-direction: alternate;
animation-iteration-count: infinite;
*/
}
.mx_RoomStatusBar_placeholderIndicator span:nth-child(1) {
animation-delay: 0.3s;
}
.mx_RoomStatusBar_placeholderIndicator span:nth-child(2) {
animation-delay: 0.6s;
}
.mx_RoomStatusBar_placeholderIndicator span:nth-child(3) {
animation-delay: 0.9s;
}
@keyframes bounce {
from {
opacity: 0.5;
top: 0;
}
to {
opacity: 0.2;
top: -3px;
}
}
.mx_RoomStatusBar_typingIndicatorAvatars { .mx_RoomStatusBar_typingIndicatorAvatars {
width: 52px; width: 52px;
margin-top: -1px; margin-top: -1px;
@ -162,11 +111,6 @@ limitations under the License.
margin-top: 10px; margin-top: 10px;
} }
.mx_RoomStatusBar_callBar {
height: 40px;
line-height: $font-40px;
}
.mx_RoomStatusBar_typingBar { .mx_RoomStatusBar_typingBar {
height: 40px; height: 40px;
line-height: $font-40px; line-height: $font-40px;

View file

@ -231,9 +231,29 @@ limitations under the License.
justify-content: center; justify-content: center;
} }
&.mx_UserMenu_contextMenu_guestPrompts,
&.mx_UserMenu_contextMenu_hostingLink { &.mx_UserMenu_contextMenu_hostingLink {
padding-top: 0; padding-top: 0;
} }
&.mx_UserMenu_contextMenu_guestPrompts {
display: inline-block;
> span {
font-weight: 600;
display: block;
& + span {
margin-top: 8px;
}
}
.mx_AccessibleButton_kind_link {
font-weight: normal;
font-size: inherit;
padding: 0;
}
}
} }
.mx_IconizedContextMenu_icon { .mx_IconizedContextMenu_icon {

View file

@ -41,7 +41,7 @@ limitations under the License.
.mx_BaseAvatar_image { .mx_BaseAvatar_image {
object-fit: cover; object-fit: cover;
border-radius: 40px; border-radius: 125px;
vertical-align: top; vertical-align: top;
background-color: $avatar-bg-color; background-color: $avatar-bg-color;
} }

View file

@ -15,82 +15,37 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_CallView {
border-radius: 10px;
background-color: $input-lighter-bg-color;
padding-left: 8px;
padding-right: 8px;
// XXX: CallContainer sets pointer-events: none - should probably be set back in a better place
pointer-events: initial;
}
.mx_CallView_large {
padding-bottom: 10px;
.mx_CallView_voice { .mx_CallView_voice {
background-color: $accent-color; height: 360px;
color: $accent-fg-color; }
cursor: pointer; }
padding: 6px;
font-weight: bold;
border-radius: 8px; .mx_CallView_pip {
min-width: 200px; width: 320px;
.mx_CallView_voice {
height: 180px;
}
}
.mx_CallView_voice {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
img { background-color: $inverted-bg-color;
margin: 4px;
margin-right: 10px;
}
> div {
display: flex;
flex-direction: column;
// Hacky vertical align
padding-top: 3px;
}
> div > p,
> div > h1 {
padding: 0;
margin: 0;
font-size: $font-13px;
line-height: $font-15px;
}
> div > p {
font-weight: bold;
}
> * {
flex-grow: 0;
flex-shrink: 0;
}
}
.mx_CallView_hangup {
position: absolute;
right: 8px;
bottom: 10px;
height: 35px;
width: 35px;
border-radius: 35px;
background-color: $notice-primary-color;
z-index: 101;
cursor: pointer;
&::before {
content: '';
position: absolute;
height: 20px;
width: 20px;
top: 6.5px;
left: 7.5px;
mask: url('$(res)/img/hangup.svg');
mask-size: contain;
background-size: contain;
background-color: $primary-fg-color;
}
} }
.mx_CallView_video { .mx_CallView_video {
@ -99,3 +54,157 @@ limitations under the License.
z-index: 30; z-index: 30;
} }
.mx_CallView_header {
height: 44px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: left;
.mx_BaseAvatar {
margin-right: 12px;
}
}
.mx_CallView_header_callType {
font-weight: bold;
vertical-align: middle;
}
.mx_CallView_header_controls {
margin-left: auto;
}
.mx_CallView_header_button {
display: inline-block;
vertical-align: middle;
cursor: pointer;
&::before {
content: '';
display: inline-block;
height: 20px;
width: 20px;
vertical-align: middle;
background-color: $secondary-fg-color;
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center;
}
}
.mx_CallView_header_button_fullscreen {
&::before {
mask-image: url('$(res)/img/element-icons/call/fullscreen.svg');
}
}
.mx_CallView_header_button_expand {
&::before {
mask-image: url('$(res)/img/element-icons/call/expand.svg');
}
}
.mx_CallView_header_roomName {
font-weight: bold;
font-size: 12px;
line-height: initial;
}
.mx_CallView_header_callTypeSmall {
font-size: 12px;
color: $secondary-fg-color;
line-height: initial;
}
.mx_CallView_header_phoneIcon {
display: inline-block;
margin-right: 6px;
height: 16px;
width: 16px;
vertical-align: middle;
&::before {
content: '';
display: inline-block;
vertical-align: top;
height: 16px;
width: 16px;
background-color: $warning-color;
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center;
mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
}
}
.mx_CallView_callControls {
position: absolute;
display: flex;
justify-content: center;
bottom: 5px;
width: 100%;
opacity: 1;
transition: opacity 0.5s;
}
.mx_CallView_callControls_hidden {
opacity: 0.001; // opacity 0 can cause a re-layout
pointer-events: none;
}
.mx_CallView_callControls_button {
cursor: pointer;
margin-left: 8px;
margin-right: 8px;
&::before {
content: '';
display: inline-block;
height: 48px;
width: 48px;
background-repeat: no-repeat;
background-size: contain;
background-position: center;
}
}
.mx_CallView_callControls_button_micOn {
&::before {
background-image: url('$(res)/img/voip/mic-on.svg');
}
}
.mx_CallView_callControls_button_micOff {
&::before {
background-image: url('$(res)/img/voip/mic-off.svg');
}
}
.mx_CallView_callControls_button_vidOn {
&::before {
background-image: url('$(res)/img/voip/vid-on.svg');
}
}
.mx_CallView_callControls_button_vidOff {
&::before {
background-image: url('$(res)/img/voip/vid-off.svg');
}
}
.mx_CallView_callControls_button_hangup {
&::before {
background-image: url('$(res)/img/voip/hangup.svg');
}
}
.mx_CallView_callControls_button_invisible {
visibility: hidden;
pointer-events: none;
position: absolute;
}

View file

@ -14,10 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_VideoFeed video {
width: 100%;
}
.mx_VideoFeed_remote { .mx_VideoFeed_remote {
width: 100%; width: 100%;
background-color: #000; background-color: #000;
@ -28,16 +24,12 @@ limitations under the License.
width: 25%; width: 25%;
height: 25%; height: 25%;
position: absolute; position: absolute;
left: 10px; right: 10px;
bottom: 10px; top: 10px;
z-index: 100; z-index: 100;
border-radius: 4px;
} }
.mx_VideoFeed_local video { .mx_VideoFeed_mirror {
width: auto;
height: 100%;
}
.mx_VideoFeed_mirror video {
transform: scale(-1, 1); transform: scale(-1, 1);
} }

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 19H18C18.55 19 19 18.55 19 18V6C19 5.45 18.55 5 18 5H13C12.45 5 12 4.55 12 4C12 3.45 12.45 3 13 3H19C20.11 3 21 3.9 21 5V19C21 20.1 20.1 21 19 21H5C3.9 21 3 20.1 3 19V13C3 12.45 3.45 12 4 12C4.55 12 5 12.45 5 13V18C5 18.55 5.45 19 6 19ZM10 4C10 4.55 9.55 5 9 5H6.41L15.54 14.13C15.93 14.52 15.93 15.15 15.54 15.54C15.15 15.93 14.52 15.93 14.13 15.54L5 6.41V9C5 9.55 4.55 10 4 10C3.45 10 3 9.55 3 9V4C3 3.44772 3.44772 3 4 3H9C9.55 3 10 3.45 10 4Z" fill="#737D8C"/>
</svg>

After

Width:  |  Height:  |  Size: 580 B

View file

@ -1,5 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 2L19 20" stroke="white" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 4H4.41122L17 16.615V19.415C16.9914 19.4057 16.9825 19.3965 16.9735 19.3874L1.98909 4.37176C1.93823 4.32079 1.88324 4.27646 1.82519 4.23876C2.18599 4.08505 2.58306 4 3 4ZM0.386676 5.52565C0.140502 5.96107 0 6.46413 0 7V17C0 18.6569 1.34315 20 3 20H14.7593L0.573407 5.78449C0.495634 5.70656 0.433392 5.619 0.386676 5.52565ZM17 7V13.7837L7.2367 4H14C15.6569 4 17 5.34315 17 7Z" fill="white"/>
<path d="M19 9L22.3753 6.29976C23.0301 5.77595 24 6.24212 24 7.08062V16.9194C24 17.7579 23.0301 18.2241 22.3753 17.7002L19 15V9Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 791 B

View file

@ -1,4 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 3L21 21" stroke="white" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 6.59209V12C8 14.2091 9.79086 16 12 16C13.4616 16 14.7402 15.216 15.4381 14.0457L8 6.59209ZM16.8804 15.491C15.7918 17.0102 14.0115 18 12 18C8.68629 18 6 15.3137 6 12C6 11.4477 5.55228 11 5 11C4.44772 11 4 11.4477 4 12C4 16.0796 7.05369 19.446 11 19.9381V21C11 21.5523 11.4477 22 12 22C12.5523 22 13 21.5523 13 21V19.9381C15.1511 19.6699 17.037 18.5476 18.3077 16.9213L16.8804 15.491ZM19.3589 15.1433L17.7917 13.5729C17.9275 13.0716 18 12.5443 18 12C18 11.4477 18.4477 11 19 11C19.5523 11 20 11.4477 20 12C20 13.1159 19.7715 14.1783 19.3589 15.1433ZM16 11.7774L8.43077 4.19238C9.09091 2.89149 10.4413 2 12 2C14.2091 2 16 3.79086 16 6V11.7774Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 913 B

View file

@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 6C8 3.79086 9.79086 2 12 2C14.2091 2 16 3.79086 16 6V12C16 14.2091 14.2091 16 12 16C9.79086 16 8 14.2091 8 12V6ZM5 11C5.55228 11 6 11.4477 6 12C6 15.3137 8.68629 18 12 18C15.3137 18 18 15.3137 18 12C18 11.4477 18.4477 11 19 11C19.5523 11 20 11.4477 20 12C20 16.0796 16.9463 19.446 13 19.9381V21C13 21.5523 12.5523 22 12 22C11.4477 22 11 21.5523 11 21V19.9381C7.05369 19.446 4 16.0796 4 12C4 11.4477 4.44772 11 5 11Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 587 B

View file

@ -1,6 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 9C16 9 17 10.2857 17 12C17 13.7143 16 15 16 15" stroke="white" stroke-width="2" stroke-linecap="round"/>
<path d="M19 6C19 6 21 8.57143 21 12C21 15.4286 19 18 19 18" stroke="white" stroke-width="2" stroke-linecap="round"/>
<rect x="2" y="8" width="11" height="8" rx="2" fill="white"/>
<path d="M7 8L11.3598 4.36682C12.0111 3.82405 13 4.2872 13 5.13504V18.865C13 19.7128 12.0111 20.176 11.3598 19.6332L7 16V8Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 541 B

View file

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="25px" height="26px" viewBox="-1 -1 25 26" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
<!-- Generator: Sketch 3.4.3 (16618) - http://www.bohemiancoding.com/sketch -->
<title>Fill 72 + Path 98</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="05-Voice-and-video" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
<g id="05_2-Video-call" sketch:type="MSArtboardGroup" transform="translate(-910.000000, -719.000000)" stroke="#FF0064">
<g id="Fill-72-+-Path-98" sketch:type="MSLayerGroup" transform="translate(910.000000, 719.000000)">
<path d="M17.8404444,24 C15.8137778,24 10.8875556,21.408 6.75022222,15.8448889 C2.88088889,10.6413333 1,6.88222222 1,4.35244444 C1,2.36088889 2.37511111,1.41022222 3.11422222,0.9 L3.29644444,0.772888889 C4.11288889,0.188888889 5.38222222,0 5.86888889,0 C6.72222222,0 7.08177778,0.499555556 7.29955556,0.935111111 C7.48488889,1.30311111 9.01777778,4.59511111 9.17288889,5.00444444 C9.41111111,5.63377778 9.33288889,6.55111111 8.596,7.07822222 L8.46622222,7.16844444 C8.10044444,7.42222222 7.42,7.89333333 7.32577778,8.46622222 C7.28,8.74488889 7.37333333,9.03644444 7.61111111,9.35688889 C8.79777778,10.956 12.5862222,15.6506667 13.2693333,16.2884444 C13.8044444,16.7884444 14.4826667,16.8595556 14.9444444,16.4702222 C15.4222222,16.0675556 15.6342222,15.8297778 15.6364444,15.8271111 L15.6857778,15.7795556 C15.7257778,15.7457778 16.0991111,15.4497778 16.7093333,15.4497778 C17.1497778,15.4497778 17.5973333,15.6017778 18.04,15.9008889 C19.1884444,16.6768889 21.7808889,18.4106667 21.7808889,18.4106667 L21.8226667,18.4426667 C22.1542222,18.7266667 22.6333333,19.5453333 22.0751111,20.6106667 C21.496,21.7168889 19.6986667,24 17.8404444,24 L17.8404444,24 Z" id="Fill-72" sketch:type="MSShapeGroup"></path>
<path d="M19.8035085,4 L0,22.8035085" id="Path-98" sketch:type="MSShapeGroup"></path>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

17
res/img/voip/hangup.svg Normal file
View file

@ -0,0 +1,17 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d)">
<circle cx="24" cy="20" r="20" fill="#FE2928"/>
</g>
<path d="m 24.008382,14.7565 c -1.6873,-0.0649 -5.157,0.373 -6.0006,0.5948 -0.0499,0.0131 -0.1074,0.0278 -0.1716,0.0442 -1.2952,0.3306 -5.35348,1.3663 -5.79196,4.6481 -0.33971,2.5426 1.36151,3.3122 2.21196,3.195 0.5886,-0.0738 2.2739,-0.3403 3.831,-0.6197 1.5291,-0.2743 1.5283,-1.283 1.5278,-1.9651 0,-0.0125 0,-0.025 0,-0.0373 v -1.3712 c 0,-0.3492 0.3281,-0.5511 0.7808,-0.6057 1.6024,-0.2176 2.9401,-0.2183 3.6097,-0.2183 h 0.0057 c 0.6695,0 1.9906,7e-4 3.593,0.2183 0.4527,0.0546 0.7808,0.2565 0.7808,0.6057 v 1.3712 c 0,0.0124 0,0.0248 0,0.0373 -5e-4,0.6821 -0.0013,1.6908 1.5278,1.9652 1.5571,0.2793 3.2424,0.5458 3.831,0.6196 0.8504,0.1172 2.5517,-0.6524 2.212,-3.195 -0.4385,-3.2818 -4.4968,-4.3175 -5.792,-4.6481 -0.0642,-0.0164 -0.1217,-0.031 -0.1716,-0.0442 -0.8435,-0.2218 -4.2966,-0.6597 -5.9838,-0.5948 z" fill="#ffffff" />
<defs>
<filter id="filter0_d" x="0" y="0" width="48" height="48" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

18
res/img/voip/mic-off.svg Normal file
View file

@ -0,0 +1,18 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d)">
<circle cx="24" cy="20" r="20" fill="#61708B"/>
</g>
<path d="M15 11L33 29" stroke="white" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20 14.5921V20C20 22.2091 21.7909 24 24 24C25.4616 24 26.7402 23.216 27.4381 22.0457L20 14.5921ZM28.8804 23.491C27.7918 25.0102 26.0115 26 24 26C20.6863 26 18 23.3137 18 20C18 19.4477 17.5523 19 17 19C16.4477 19 16 19.4477 16 20C16 24.0796 19.0537 27.446 23 27.9381V29C23 29.5523 23.4477 30 24 30C24.5523 30 25 29.5523 25 29V27.9381C27.1511 27.6699 29.037 26.5476 30.3077 24.9213L28.8804 23.491ZM31.3589 23.1433L29.7917 21.5729C29.9275 21.0716 30 20.5443 30 20C30 19.4477 30.4477 19 31 19C31.5523 19 32 19.4477 32 20C32 21.1159 31.7715 22.1783 31.3589 23.1433ZM28 19.7774L20.4308 12.1924C21.0909 10.8915 22.4413 10 24 10C26.2091 10 28 11.7909 28 14V19.7774Z" fill="white"/>
<defs>
<filter id="filter0_d" x="0" y="0" width="48" height="48" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

17
res/img/voip/mic-on.svg Normal file
View file

@ -0,0 +1,17 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d)">
<circle cx="24" cy="20" r="20" fill="white"/>
</g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20 14C20 11.7909 21.7909 10 24 10C26.2091 10 28 11.7909 28 14V20C28 22.2091 26.2091 24 24 24C21.7909 24 20 22.2091 20 20V14ZM17 19C17.5523 19 18 19.4477 18 20C18 23.3137 20.6863 26 24 26C27.3137 26 30 23.3137 30 20C30 19.4477 30.4477 19 31 19C31.5523 19 32 19.4477 32 20C32 24.0796 28.9463 27.446 25 27.9381V29C25 29.5523 24.5523 30 24 30C23.4477 30 23 29.5523 23 29V27.9381C19.0537 27.446 16 24.0796 16 20C16 19.4477 16.4477 19 17 19Z" fill="#61708B"/>
<defs>
<filter id="filter0_d" x="0" y="0" width="48" height="48" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

19
res/img/voip/vid-off.svg Normal file
View file

@ -0,0 +1,19 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d)">
<circle cx="24" cy="20" r="20" fill="#61708B"/>
</g>
<path d="M14.8334 11.6666L29.8334 26.6666" stroke="white" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17 13.3333H17.676L28.1667 23.8458V26.1791C28.1595 26.1713 28.1521 26.1636 28.1446 26.1561L15.662 13.6474C16.0648 13.4464 16.5192 13.3333 17 13.3333ZM14.4359 14.7751C14.1593 15.2292 14 15.7626 14 16.3332V23.6666C14 25.3234 15.3431 26.6666 17 26.6666H26.2994L14.4778 14.8203C14.4632 14.8056 14.4492 14.7906 14.4359 14.7751ZM28.1667 16.3333V21.4863L20.0306 13.3333H25.1667C26.8235 13.3333 28.1667 14.6764 28.1667 16.3333Z" fill="white"/>
<path d="M29.8334 17.5L32.3753 15.4664C33.0301 14.9426 34 15.4087 34 16.2473V23.7527C34 24.5912 33.0301 25.0573 32.3753 24.5335L29.8334 22.5V17.5Z" fill="white"/>
<defs>
<filter id="filter0_d" x="0" y="0" width="48" height="48" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

18
res/img/voip/vid-on.svg Normal file
View file

@ -0,0 +1,18 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d)">
<circle cx="24" cy="20" r="20" fill="white"/>
</g>
<path d="M14 16.3334C14 14.6765 15.3431 13.3334 17 13.3334H25.1667C26.8235 13.3334 28.1667 14.6765 28.1667 16.3334V23.6667C28.1667 25.3236 26.8235 26.6667 25.1667 26.6667H17C15.3431 26.6667 14 25.3236 14 23.6667V16.3334Z" fill="#61708B"/>
<path d="M29.8334 17.5001L32.3753 15.4665C33.0301 14.9427 34 15.4089 34 16.2474V23.7528C34 24.5913 33.0301 25.0575 32.3753 24.5337L29.8334 22.5001V17.5001Z" fill="#61708B"/>
<defs>
<filter id="filter0_d" x="0" y="0" width="48" height="48" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -79,6 +79,7 @@ import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions";
import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty, CallType } from "matrix-js-sdk/src/webrtc/call"; import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty, CallType } from "matrix-js-sdk/src/webrtc/call";
import Analytics from './Analytics'; import Analytics from './Analytics';
import CountlyAnalytics from "./CountlyAnalytics"; import CountlyAnalytics from "./CountlyAnalytics";
import {UIFeature} from "./settings/UIFeature";
enum AudioID { enum AudioID {
Ring = 'ringAudio', Ring = 'ringAudio',
@ -124,7 +125,7 @@ export default class CallHandler {
return window.mxCallHandler; return window.mxCallHandler;
} }
constructor() { start() {
dis.register(this.onAction); dis.register(this.onAction);
// add empty handlers for media actions, otherwise the media keys // add empty handlers for media actions, otherwise the media keys
// end up causing the audio elements with our ring/ringback etc // end up causing the audio elements with our ring/ringback etc
@ -137,6 +138,27 @@ export default class CallHandler {
navigator.mediaSession.setActionHandler('previoustrack', function() {}); navigator.mediaSession.setActionHandler('previoustrack', function() {});
navigator.mediaSession.setActionHandler('nexttrack', function() {}); navigator.mediaSession.setActionHandler('nexttrack', function() {});
} }
if (SettingsStore.getValue(UIFeature.Voip)) {
MatrixClientPeg.get().on('Call.incoming', this.onCallIncoming);
}
}
stop() {
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener('Call.incoming', this.onCallIncoming);
}
}
private onCallIncoming = (call) => {
// we dispatch this synchronously to make sure that the event
// handlers on the call are set up immediately (so that if
// we get an immediate hangup, we don't get a stuck call)
dis.dispatch({
action: 'incoming_call',
call: call,
}, true);
} }
getCallForRoom(roomId: string): MatrixCall { getCallForRoom(roomId: string): MatrixCall {

View file

@ -27,9 +27,12 @@ import _linkifyString from 'linkifyjs/string';
import classNames from 'classnames'; import classNames from 'classnames';
import EMOJIBASE_REGEX from 'emojibase-regex'; import EMOJIBASE_REGEX from 'emojibase-regex';
import url from 'url'; import url from 'url';
import katex from 'katex';
import { AllHtmlEntities } from 'html-entities';
import SettingsStore from './settings/SettingsStore';
import cheerio from 'cheerio';
import {MatrixClientPeg} from './MatrixClientPeg'; import {MatrixClientPeg} from './MatrixClientPeg';
import SettingsStore from './settings/SettingsStore';
import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks"; import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks";
import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji"; import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji";
import ReplyThread from "./components/views/elements/ReplyThread"; import ReplyThread from "./components/views/elements/ReplyThread";
@ -240,7 +243,8 @@ const sanitizeHtmlParams: IExtendedSanitizeOptions = {
allowedAttributes: { allowedAttributes: {
// custom ones first: // custom ones first:
font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
span: ['data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style'], // custom to matrix span: ['data-mx-maths', 'data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style'], // custom to matrix
div: ['data-mx-maths'],
a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix
img: ['src', 'width', 'height', 'alt', 'title'], img: ['src', 'width', 'height', 'alt', 'title'],
ol: ['start'], ol: ['start'],
@ -414,6 +418,21 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
if (isHtmlMessage) { if (isHtmlMessage) {
isDisplayedWithHtml = true; isDisplayedWithHtml = true;
safeBody = sanitizeHtml(formattedBody, sanitizeParams); safeBody = sanitizeHtml(formattedBody, sanitizeParams);
if (SettingsStore.getValue("feature_latex_maths")) {
const phtml = cheerio.load(safeBody,
{ _useHtmlParser2: true, decodeEntities: false })
phtml('div, span[data-mx-maths!=""]').replaceWith(function(i, e) {
return katex.renderToString(
AllHtmlEntities.decode(phtml(e).attr('data-mx-maths')),
{
throwOnError: false,
displayMode: e.name == 'div',
output: "htmlAndMathml",
});
});
safeBody = phtml.html();
}
} }
} finally { } finally {
delete sanitizeParams.textFilter; delete sanitizeParams.textFilter;
@ -515,7 +534,6 @@ export function checkBlockNode(node: Node) {
case "H6": case "H6":
case "PRE": case "PRE":
case "BLOCKQUOTE": case "BLOCKQUOTE":
case "DIV":
case "P": case "P":
case "UL": case "UL":
case "OL": case "OL":
@ -528,6 +546,9 @@ export function checkBlockNode(node: Node) {
case "TH": case "TH":
case "TD": case "TD":
return true; return true;
case "DIV":
// don't treat math nodes as block nodes for deserializing
return !(node as HTMLElement).hasAttribute("data-mx-maths");
default: default:
return false; return false;
} }

View file

@ -48,6 +48,8 @@ import {Jitsi} from "./widgets/Jitsi";
import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "./BasePlatform"; import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "./BasePlatform";
import ThreepidInviteStore from "./stores/ThreepidInviteStore"; import ThreepidInviteStore from "./stores/ThreepidInviteStore";
import CountlyAnalytics from "./CountlyAnalytics"; import CountlyAnalytics from "./CountlyAnalytics";
import CallHandler from './CallHandler';
import LifecycleCustomisations from "./customisations/Lifecycle";
const HOMESERVER_URL_KEY = "mx_hs_url"; const HOMESERVER_URL_KEY = "mx_hs_url";
const ID_SERVER_URL_KEY = "mx_is_url"; const ID_SERVER_URL_KEY = "mx_is_url";
@ -588,9 +590,9 @@ export function logout(): void {
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
// logout doesn't work for guest sessions // logout doesn't work for guest sessions
// Also we sometimes want to re-log in a guest session // Also we sometimes want to re-log in a guest session if we abort the login.
// if we abort the login // defer until next tick because it calls a synchronous dispatch and we are likely here from a dispatch.
onLoggedOut(); setImmediate(() => onLoggedOut());
return; return;
} }
@ -665,6 +667,7 @@ async function startMatrixClient(startSyncing = true): Promise<void> {
DMRoomMap.makeShared().start(); DMRoomMap.makeShared().start();
IntegrationManagers.sharedInstance().startWatching(); IntegrationManagers.sharedInstance().startWatching();
ActiveWidgetStore.start(); ActiveWidgetStore.start();
CallHandler.sharedInstance().start();
// Start Mjolnir even though we haven't checked the feature flag yet. Starting // Start Mjolnir even though we haven't checked the feature flag yet. Starting
// the thing just wastes CPU cycles, but should result in no actual functionality // the thing just wastes CPU cycles, but should result in no actual functionality
@ -714,6 +717,7 @@ export async function onLoggedOut(): Promise<void> {
dis.dispatch({action: 'on_logged_out'}, true); dis.dispatch({action: 'on_logged_out'}, true);
stopMatrixClient(); stopMatrixClient();
await clearStorage({deleteEverything: true}); await clearStorage({deleteEverything: true});
LifecycleCustomisations.onLoggedOutAndStorageCleared?.();
} }
/** /**
@ -760,6 +764,7 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise<void
*/ */
export function stopMatrixClient(unsetClient = true): void { export function stopMatrixClient(unsetClient = true): void {
Notifier.stop(); Notifier.stop();
CallHandler.sharedInstance().stop();
UserActivity.sharedInstance().stop(); UserActivity.sharedInstance().stop();
TypingStore.sharedInstance().reset(); TypingStore.sharedInstance().reset();
Presence.stop(); Presence.stop();

View file

@ -23,6 +23,11 @@ const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u'];
const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document']; const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document'];
function is_allowed_html_tag(node) { function is_allowed_html_tag(node) {
if (node.literal != null &&
node.literal.match('^<((div|span) data-mx-maths="[^"]*"|\/(div|span))>$') != null) {
return true;
}
// Regex won't work for tags with attrs, but we only // Regex won't work for tags with attrs, but we only
// allow <del> anyway. // allow <del> anyway.
const matches = /^<\/?(.*)>$/.exec(node.literal); const matches = /^<\/?(.*)>$/.exec(node.literal);
@ -30,6 +35,7 @@ function is_allowed_html_tag(node) {
const tag = matches[1]; const tag = matches[1];
return ALLOWED_HTML_TAGS.indexOf(tag) > -1; return ALLOWED_HTML_TAGS.indexOf(tag) > -1;
} }
return false; return false;
} }

View file

@ -455,7 +455,7 @@ function textForWidgetEvent(event) {
let widgetName = name || prevName || type || prevType || ''; let widgetName = name || prevName || type || prevType || '';
// Apply sentence case to widget name // Apply sentence case to widget name
if (widgetName && widgetName.length > 0) { if (widgetName && widgetName.length > 0) {
widgetName = widgetName[0].toUpperCase() + widgetName.slice(1) + ' '; widgetName = widgetName[0].toUpperCase() + widgetName.slice(1);
} }
// If the widget was removed, its content should be {}, but this is sufficiently // If the widget was removed, its content should be {}, but this is sufficiently

View file

@ -1353,18 +1353,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}); });
}); });
if (SettingsStore.getValue(UIFeature.Voip)) {
cli.on('Call.incoming', function(call) {
// we dispatch this synchronously to make sure that the event
// handlers on the call are set up immediately (so that if
// we get an immediate hangup, we don't get a stuck call)
dis.dispatch({
action: 'incoming_call',
call: call,
}, true);
});
}
cli.on('Session.logged_out', function(errObj) { cli.on('Session.logged_out', function(errObj) {
if (Lifecycle.isLoggingOut()) return; if (Lifecycle.isLoggingOut()) return;

View file

@ -18,13 +18,11 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Matrix from 'matrix-js-sdk'; import Matrix from 'matrix-js-sdk';
import { _t, _td } from '../../languageHandler'; import { _t, _td } from '../../languageHandler';
import * as sdk from '../../index';
import {MatrixClientPeg} from '../../MatrixClientPeg'; import {MatrixClientPeg} from '../../MatrixClientPeg';
import Resend from '../../Resend'; import Resend from '../../Resend';
import dis from '../../dispatcher/dispatcher'; import dis from '../../dispatcher/dispatcher';
import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils'; import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils';
import {Action} from "../../dispatcher/actions"; import {Action} from "../../dispatcher/actions";
import { CallState, CallType } from 'matrix-js-sdk/lib/webrtc/call';
const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1; const STATUS_BAR_EXPANDED = 1;
@ -42,13 +40,6 @@ export default class RoomStatusBar extends React.Component {
// the room this statusbar is representing. // the room this statusbar is representing.
room: PropTypes.object.isRequired, room: PropTypes.object.isRequired,
// The active call in the room, if any (means we show the call bar
// along with the status of the call)
callState: PropTypes.string,
// The type of the call in progress, or null if no call is in progress
callType: PropTypes.string,
// true if the room is being peeked at. This affects components that shouldn't // true if the room is being peeked at. This affects components that shouldn't
// logically be shown when peeking, such as a prompt to invite people to a room. // logically be shown when peeking, such as a prompt to invite people to a room.
isPeeking: PropTypes.bool, isPeeking: PropTypes.bool,
@ -115,12 +106,6 @@ export default class RoomStatusBar extends React.Component {
}); });
}; };
_showCallBar() {
return (this.props.callState &&
(this.props.callState !== CallState.Ended && this.props.callState !== CallState.Ringing)
);
}
_onResendAllClick = () => { _onResendAllClick = () => {
Resend.resendUnsentEvents(this.props.room); Resend.resendUnsentEvents(this.props.room);
dis.fire(Action.FocusComposer); dis.fire(Action.FocusComposer);
@ -152,7 +137,7 @@ export default class RoomStatusBar extends React.Component {
// changed - so we use '0' to indicate normal size, and other values to // changed - so we use '0' to indicate normal size, and other values to
// indicate other sizes. // indicate other sizes.
_getSize() { _getSize() {
if (this._shouldShowConnectionError() || this._showCallBar()) { if (this._shouldShowConnectionError()) {
return STATUS_BAR_EXPANDED; return STATUS_BAR_EXPANDED;
} else if (this.state.unsentMessages.length > 0) { } else if (this.state.unsentMessages.length > 0) {
return STATUS_BAR_EXPANDED_LARGE; return STATUS_BAR_EXPANDED_LARGE;
@ -160,22 +145,6 @@ export default class RoomStatusBar extends React.Component {
return STATUS_BAR_HIDDEN; return STATUS_BAR_HIDDEN;
} }
// return suitable content for the image on the left of the status bar.
_getIndicator() {
if (this._showCallBar()) {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
return (
<TintableSvg src={require("../../../res/img/element-icons/room/in-call.svg")} width="23" height="20" />
);
}
if (this._shouldShowConnectionError()) {
return null;
}
return null;
}
_shouldShowConnectionError() { _shouldShowConnectionError() {
// no conn bar trumps the "some not sent" msg since you can't resend without // no conn bar trumps the "some not sent" msg since you can't resend without
// a connection! // a connection!
@ -266,25 +235,6 @@ export default class RoomStatusBar extends React.Component {
</div>; </div>;
} }
_getCallStatusText() {
switch (this.props.callState) {
case CallState.CreateOffer:
case CallState.InviteSent:
return _t('Calling...');
case CallState.Connecting:
case CallState.CreateAnswer:
return _t('Call connecting...');
case CallState.Connected:
return _t('Active call');
case CallState.WaitLocalMedia:
if (this.props.callType === CallType.Video) {
return _t('Starting camera...');
} else {
return _t('Starting microphone...');
}
}
}
// return suitable content for the main (text) part of the status bar. // return suitable content for the main (text) part of the status bar.
_getContent() { _getContent() {
if (this._shouldShowConnectionError()) { if (this._shouldShowConnectionError()) {
@ -307,26 +257,14 @@ export default class RoomStatusBar extends React.Component {
return this._getUnsentMessageContent(); return this._getUnsentMessageContent();
} }
if (this._showCallBar()) {
return (
<div className="mx_RoomStatusBar_callBar">
<b>{ this._getCallStatusText() }</b>
</div>
);
}
return null; return null;
} }
render() { render() {
const content = this._getContent(); const content = this._getContent();
const indicator = this._getIndicator();
return ( return (
<div className="mx_RoomStatusBar"> <div className="mx_RoomStatusBar">
<div className="mx_RoomStatusBar_indicator">
{ indicator }
</div>
<div role="alert"> <div role="alert">
{ content } { content }
</div> </div>

View file

@ -41,7 +41,7 @@ import rateLimitedFunc from '../../ratelimitedfunc';
import * as ObjectUtils from '../../ObjectUtils'; import * as ObjectUtils from '../../ObjectUtils';
import * as Rooms from '../../Rooms'; import * as Rooms from '../../Rooms';
import eventSearch, {searchPagination} from '../../Searching'; import eventSearch, {searchPagination} from '../../Searching';
import {isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isOnlyCtrlOrCmdKeyEvent, Key} from '../../Keyboard'; import {isOnlyCtrlOrCmdIgnoreShiftKeyEvent, Key} from '../../Keyboard';
import MainSplit from './MainSplit'; import MainSplit from './MainSplit';
import RightPanel from './RightPanel'; import RightPanel from './RightPanel';
import RoomViewStore from '../../stores/RoomViewStore'; import RoomViewStore from '../../stores/RoomViewStore';
@ -67,10 +67,9 @@ import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar";
import PinnedEventsPanel from "../views/rooms/PinnedEventsPanel"; import PinnedEventsPanel from "../views/rooms/PinnedEventsPanel";
import AuxPanel from "../views/rooms/AuxPanel"; import AuxPanel from "../views/rooms/AuxPanel";
import RoomHeader from "../views/rooms/RoomHeader"; import RoomHeader from "../views/rooms/RoomHeader";
import TintableSvg from "../views/elements/TintableSvg";
import {XOR} from "../../@types/common"; import {XOR} from "../../@types/common";
import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
import { CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import WidgetStore from "../../stores/WidgetStore"; import WidgetStore from "../../stores/WidgetStore";
import {UPDATE_EVENT} from "../../stores/AsyncStore"; import {UPDATE_EVENT} from "../../stores/AsyncStore";
import Notifier from "../../Notifier"; import Notifier from "../../Notifier";
@ -507,8 +506,6 @@ export default class RoomView extends React.Component<IProps, IState> {
this.props.resizeNotifier.on("middlePanelResized", this.onResize); this.props.resizeNotifier.on("middlePanelResized", this.onResize);
} }
this.onResize(); this.onResize();
document.addEventListener("keydown", this.onNativeKeyDown);
} }
shouldComponentUpdate(nextProps, nextState) { shouldComponentUpdate(nextProps, nextState) {
@ -591,8 +588,6 @@ export default class RoomView extends React.Component<IProps, IState> {
this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize); this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize);
} }
document.removeEventListener("keydown", this.onNativeKeyDown);
// Remove RoomStore listener // Remove RoomStore listener
if (this.roomStoreToken) { if (this.roomStoreToken) {
this.roomStoreToken.remove(); this.roomStoreToken.remove();
@ -641,33 +636,6 @@ export default class RoomView extends React.Component<IProps, IState> {
} }
}; };
// we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire
private onNativeKeyDown = ev => {
let handled = false;
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
switch (ev.key) {
case Key.D:
if (ctrlCmdOnly) {
this.onMuteAudioClick();
handled = true;
}
break;
case Key.E:
if (ctrlCmdOnly) {
this.onMuteVideoClick();
handled = true;
}
break;
}
if (handled) {
ev.stopPropagation();
ev.preventDefault();
}
};
private onReactKeyDown = ev => { private onReactKeyDown = ev => {
let handled = false; let handled = false;
@ -1754,8 +1722,6 @@ export default class RoomView extends React.Component<IProps, IState> {
isStatusAreaExpanded = this.state.statusBarVisible; isStatusAreaExpanded = this.state.statusBarVisible;
statusBar = <RoomStatusBar statusBar = <RoomStatusBar
room={this.state.room} room={this.state.room}
callState={this.state.callState}
callType={activeCall ? activeCall.type : null}
isPeeking={myMembership !== "join"} isPeeking={myMembership !== "join"}
onInviteClick={this.onInviteButtonClick} onInviteClick={this.onInviteButtonClick}
onVisible={this.onStatusBarVisible} onVisible={this.onStatusBarVisible}
@ -1879,56 +1845,6 @@ export default class RoomView extends React.Component<IProps, IState> {
}; };
} }
if (activeCall) {
let zoomButton; let videoMuteButton;
if (activeCall.type === CallType.Video) {
zoomButton = (
<div className="mx_RoomView_voipButton" onClick={this.onFullscreenClick} title={_t("Fill screen")}>
<TintableSvg
src={require("../../../res/img/element-icons/call/fullscreen.svg")}
width="29"
height="22"
style={{ marginTop: 1, marginRight: 4 }}
/>
</div>
);
videoMuteButton =
<div className="mx_RoomView_voipButton" onClick={this.onMuteVideoClick}>
<TintableSvg
src={activeCall.isLocalVideoMuted() ?
require("../../../res/img/element-icons/call/video-muted.svg") :
require("../../../res/img/element-icons/call/video-call.svg")}
alt={activeCall.isLocalVideoMuted() ? _t("Click to unmute video") :
_t("Click to mute video")}
width=""
height="27"
/>
</div>;
}
const voiceMuteButton =
<div className="mx_RoomView_voipButton" onClick={this.onMuteAudioClick}>
<TintableSvg
src={activeCall.isMicrophoneMuted() ?
require("../../../res/img/element-icons/call/voice-muted.svg") :
require("../../../res/img/element-icons/call/voice-unmuted.svg")}
alt={activeCall.isMicrophoneMuted() ? _t("Click to unmute audio") : _t("Click to mute audio")}
width="21"
height="26"
/>
</div>;
// wrap the existing status bar into a 'callStatusBar' which adds more knobs.
statusBar =
<div className="mx_RoomView_callStatusBar">
{ voiceMuteButton }
{ videoMuteButton }
{ zoomButton }
{ statusBar }
</div>;
}
// if we have search results, we keep the messagepanel (so that it preserves its // if we have search results, we keep the messagepanel (so that it preserves its
// scroll state), but hide it. // scroll state), but hide it.
let searchResultsPanel; let searchResultsPanel;

View file

@ -29,7 +29,7 @@ import LogoutDialog from "../views/dialogs/LogoutDialog";
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import {getCustomTheme} from "../../theme"; import {getCustomTheme} from "../../theme";
import {getHostingLink} from "../../utils/HostingLink"; import {getHostingLink} from "../../utils/HostingLink";
import {ButtonEvent} from "../views/elements/AccessibleButton"; import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
import SdkConfig from "../../SdkConfig"; import SdkConfig from "../../SdkConfig";
import {getHomePageUrl} from "../../utils/pages"; import {getHomePageUrl} from "../../utils/pages";
import { OwnProfileStore } from "../../stores/OwnProfileStore"; import { OwnProfileStore } from "../../stores/OwnProfileStore";
@ -205,6 +205,16 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.setState({contextMenuPosition: null}); // also close the menu this.setState({contextMenuPosition: null}); // also close the menu
}; };
private onSignInClick = () => {
dis.dispatch({ action: 'start_login' });
this.setState({contextMenuPosition: null}); // also close the menu
};
private onRegisterClick = () => {
dis.dispatch({ action: 'start_registration' });
this.setState({contextMenuPosition: null}); // also close the menu
};
private onHomeClick = (ev: ButtonEvent) => { private onHomeClick = (ev: ButtonEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
@ -261,10 +271,29 @@ export default class UserMenu extends React.Component<IProps, IState> {
const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName(); const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
let hostingLink; let topSection;
const signupLink = getHostingLink("user-context-menu"); const signupLink = getHostingLink("user-context-menu");
if (signupLink) { if (MatrixClientPeg.get().isGuest()) {
hostingLink = ( topSection = (
<div className="mx_UserMenu_contextMenu_header mx_UserMenu_contextMenu_guestPrompts">
{_t("Got an account? <a>Sign in</a>", {}, {
a: sub => (
<AccessibleButton kind="link" onClick={this.onSignInClick}>
{sub}
</AccessibleButton>
),
})}
{_t("New here? <a>Create an account</a>", {}, {
a: sub => (
<AccessibleButton kind="link" onClick={this.onRegisterClick}>
{sub}
</AccessibleButton>
),
})}
</div>
)
} else if (signupLink) {
topSection = (
<div className="mx_UserMenu_contextMenu_header mx_UserMenu_contextMenu_hostingLink"> <div className="mx_UserMenu_contextMenu_header mx_UserMenu_contextMenu_hostingLink">
{_t( {_t(
"<a>Upgrade</a> to your own domain", {}, "<a>Upgrade</a> to your own domain", {},
@ -422,6 +451,20 @@ export default class UserMenu extends React.Component<IProps, IState> {
</IconizedContextMenuOptionList> </IconizedContextMenuOptionList>
</React.Fragment> </React.Fragment>
) )
} else if (MatrixClientPeg.get().isGuest()) {
primaryOptionList = (
<React.Fragment>
<IconizedContextMenuOptionList>
{ homeButton }
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSettings"
label={_t("Settings")}
onClick={(e) => this.onSettingsOpen(e, null)}
/>
{ feedbackButton }
</IconizedContextMenuOptionList>
</React.Fragment>
);
} }
const classes = classNames({ const classes = classNames({
@ -451,7 +494,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
/> />
</AccessibleTooltipButton> </AccessibleTooltipButton>
</div> </div>
{hostingLink} {topSection}
{primaryOptionList} {primaryOptionList}
{secondarySection} {secondarySection}
</IconizedContextMenu>; </IconizedContextMenu>;

View file

@ -1,7 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016, 2017, 2018, 2019 New Vector Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, {ComponentProps, ReactNode} from 'react';
import PropTypes from 'prop-types';
import {_t, _td} from '../../../languageHandler'; import {_t, _td} from '../../../languageHandler';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import Login from '../../../Login'; import Login from '../../../Login';
@ -31,15 +29,12 @@ import PlatformPeg from '../../../PlatformPeg';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import {UIFeature} from "../../../settings/UIFeature"; import {UIFeature} from "../../../settings/UIFeature";
import CountlyAnalytics from "../../../CountlyAnalytics"; import CountlyAnalytics from "../../../CountlyAnalytics";
import {IMatrixClientCreds} from "../../../MatrixClientPeg";
// For validating phone numbers without country codes import ServerConfig from "../../views/auth/ServerConfig";
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; import PasswordLogin from "../../views/auth/PasswordLogin";
import SignInToText from "../../views/auth/SignInToText";
// Phases import InlineSpinner from "../../views/elements/InlineSpinner";
// Show controls to configure server details import Spinner from "../../views/elements/Spinner";
const PHASE_SERVER_DETAILS = 0;
// Show the appropriate login flow(s) for the server
const PHASE_LOGIN = 1;
// Enable phases for login // Enable phases for login
const PHASES_ENABLED = true; const PHASES_ENABLED = true;
@ -55,64 +50,88 @@ _td("Invalid base_url for m.identity_server");
_td("Identity server URL does not appear to be a valid identity server"); _td("Identity server URL does not appear to be a valid identity server");
_td("General failure"); _td("General failure");
/* interface IProps {
* A wire component which glues together login UI components and Login logic serverConfig: ValidatedServerConfig;
*/ // If true, the component will consider itself busy.
export default class LoginComponent extends React.Component { busy?: boolean;
static propTypes = { isSyncing?: boolean;
// Secondary HS which we try to log into if the user is using
// the default HS but login fails. Useful for migrating to a
// different homeserver without confusing users.
fallbackHsUrl?: string;
defaultDeviceDisplayName?: string;
fragmentAfterLogin?: string;
// Called when the user has logged in. Params: // Called when the user has logged in. Params:
// - The object returned by the login API // - The object returned by the login API
// - The user's password, if applicable, (may be cached in memory for a // - The user's password, if applicable, (may be cached in memory for a
// short time so the user is not required to re-enter their password // short time so the user is not required to re-enter their password
// for operations like uploading cross-signing keys). // for operations like uploading cross-signing keys).
onLoggedIn: PropTypes.func.isRequired, onLoggedIn(data: IMatrixClientCreds, password: string): void;
// If true, the component will consider itself busy. // login shouldn't know or care how registration, password recovery, etc is done.
busy: PropTypes.bool, onRegisterClick(): void;
onForgotPasswordClick?(): void;
onServerConfigChange(config: ValidatedServerConfig): void;
}
// Secondary HS which we try to log into if the user is using enum Phase {
// the default HS but login fails. Useful for migrating to a // Show controls to configure server details
// different homeserver without confusing users. ServerDetails,
fallbackHsUrl: PropTypes.string, // Show the appropriate login flow(s) for the server
Login,
}
defaultDeviceDisplayName: PropTypes.string, interface IState {
busy: boolean;
busyLoggingIn?: boolean;
errorText?: ReactNode;
loginIncorrect: boolean;
// can we attempt to log in or are there validation errors?
canTryLogin: boolean;
// login shouldn't know or care how registration, password recovery, // used for preserving form values when changing homeserver
// etc is done. username: string;
onRegisterClick: PropTypes.func.isRequired, phoneCountry?: string;
onForgotPasswordClick: PropTypes.func, phoneNumber: string;
onServerConfigChange: PropTypes.func.isRequired,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, // Phase of the overall login dialog.
isSyncing: PropTypes.bool, phase: Phase;
}; // The current login flow, such as password, SSO, etc.
// we need to load the flows from the server
currentFlow?: string;
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: boolean;
serverErrorIsFatal: boolean;
serverDeadError: string;
}
/*
* A wire component which glues together login UI components and Login logic
*/
export default class LoginComponent extends React.Component<IProps, IState> {
private unmounted = false;
private loginLogic: Login;
private readonly stepRendererMap: Record<string, () => ReactNode>;
constructor(props) { constructor(props) {
super(props); super(props);
this._unmounted = false;
this.state = { this.state = {
busy: false, busy: false,
busyLoggingIn: null, busyLoggingIn: null,
errorText: null, errorText: null,
loginIncorrect: false, loginIncorrect: false,
canTryLogin: true, // can we attempt to log in or are there validation errors? canTryLogin: true,
// used for preserving form values when changing homeserver
username: "", username: "",
phoneCountry: null, phoneCountry: null,
phoneNumber: "", phoneNumber: "",
phase: Phase.Login,
// Phase of the overall login dialog. currentFlow: null,
phase: PHASE_LOGIN,
// The current login flow, such as password, SSO, etc.
currentFlow: null, // we need to load the flows from the server
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: true, serverIsAlive: true,
serverErrorIsFatal: false, serverErrorIsFatal: false,
serverDeadError: "", serverDeadError: "",
@ -120,12 +139,12 @@ export default class LoginComponent extends React.Component {
// map from login step type to a function which will render a control // map from login step type to a function which will render a control
// letting you do that login type // letting you do that login type
this._stepRendererMap = { this.stepRendererMap = {
'm.login.password': this._renderPasswordStep, 'm.login.password': this.renderPasswordStep,
// CAS and SSO are the same thing, modulo the url we link to // CAS and SSO are the same thing, modulo the url we link to
'm.login.cas': () => this._renderSsoStep("cas"), 'm.login.cas': () => this.renderSsoStep("cas"),
'm.login.sso': () => this._renderSsoStep("sso"), 'm.login.sso': () => this.renderSsoStep("sso"),
}; };
CountlyAnalytics.instance.track("onboarding_login_begin"); CountlyAnalytics.instance.track("onboarding_login_begin");
@ -134,11 +153,11 @@ export default class LoginComponent extends React.Component {
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
UNSAFE_componentWillMount() { UNSAFE_componentWillMount() {
this._initLoginLogic(); this.initLoginLogic(this.props.serverConfig);
} }
componentWillUnmount() { componentWillUnmount() {
this._unmounted = true; this.unmounted = true;
} }
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
@ -148,16 +167,9 @@ export default class LoginComponent extends React.Component {
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
// Ensure that we end up actually logging in to the right place // Ensure that we end up actually logging in to the right place
this._initLoginLogic(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl); this.initLoginLogic(newProps.serverConfig);
} }
onPasswordLoginError = errorText => {
this.setState({
errorText,
loginIncorrect: Boolean(errorText),
});
};
isBusy = () => this.state.busy || this.props.busy; isBusy = () => this.state.busy || this.props.busy;
onPasswordLogin = async (username, phoneCountry, phoneNumber, password) => { onPasswordLogin = async (username, phoneCountry, phoneNumber, password) => {
@ -194,13 +206,13 @@ export default class LoginComponent extends React.Component {
loginIncorrect: false, loginIncorrect: false,
}); });
this._loginLogic.loginViaPassword( this.loginLogic.loginViaPassword(
username, phoneCountry, phoneNumber, password, username, phoneCountry, phoneNumber, password,
).then((data) => { ).then((data) => {
this.setState({serverIsAlive: true}); // it must be, we logged in. this.setState({serverIsAlive: true}); // it must be, we logged in.
this.props.onLoggedIn(data, password); this.props.onLoggedIn(data, password);
}, (error) => { }, (error) => {
if (this._unmounted) { if (this.unmounted) {
return; return;
} }
let errorText; let errorText;
@ -212,21 +224,23 @@ export default class LoginComponent extends React.Component {
} else if (error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { } else if (error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
const errorTop = messageForResourceLimitError( const errorTop = messageForResourceLimitError(
error.data.limit_type, error.data.limit_type,
error.data.admin_contact, { error.data.admin_contact,
{
'monthly_active_user': _td( 'monthly_active_user': _td(
"This homeserver has hit its Monthly Active User limit.", "This homeserver has hit its Monthly Active User limit.",
), ),
'': _td( '': _td(
"This homeserver has exceeded one of its resource limits.", "This homeserver has exceeded one of its resource limits.",
), ),
}); },
);
const errorDetail = messageForResourceLimitError( const errorDetail = messageForResourceLimitError(
error.data.limit_type, error.data.limit_type,
error.data.admin_contact, { error.data.admin_contact,
'': _td( {
"Please <a>contact your service administrator</a> to continue using this service.", '': _td("Please <a>contact your service administrator</a> to continue using this service."),
), },
}); );
errorText = ( errorText = (
<div> <div>
<div>{errorTop}</div> <div>{errorTop}</div>
@ -253,7 +267,7 @@ export default class LoginComponent extends React.Component {
} }
} else { } else {
// other errors, not specific to doing a password login // other errors, not specific to doing a password login
errorText = this._errorTextFromError(error); errorText = this.errorTextFromError(error);
} }
this.setState({ this.setState({
@ -291,7 +305,7 @@ export default class LoginComponent extends React.Component {
// the busy state. In the case of a full MXID that resolves to the same // the busy state. In the case of a full MXID that resolves to the same
// HS as Element's default HS though, there may not be any server change. // HS as Element's default HS though, there may not be any server change.
// To avoid this trap, we clear busy here. For cases where the server // To avoid this trap, we clear busy here. For cases where the server
// actually has changed, `_initLoginLogic` will be called and manages // actually has changed, `initLoginLogic` will be called and manages
// busy state for its own liveness check. // busy state for its own liveness check.
this.setState({ this.setState({
busy: false, busy: false,
@ -304,7 +318,7 @@ export default class LoginComponent extends React.Component {
message = e.translatedMessage; message = e.translatedMessage;
} }
let errorText = message; let errorText: ReactNode = message;
let discoveryState = {}; let discoveryState = {};
if (AutoDiscoveryUtils.isLivelinessError(e)) { if (AutoDiscoveryUtils.isLivelinessError(e)) {
errorText = this.state.errorText; errorText = this.state.errorText;
@ -330,21 +344,6 @@ export default class LoginComponent extends React.Component {
}); });
}; };
onPhoneNumberBlur = phoneNumber => {
// Validate the phone number entered
if (!PHONE_NUMBER_REGEX.test(phoneNumber)) {
this.setState({
errorText: _t('The phone number entered looks invalid'),
canTryLogin: false,
});
} else {
this.setState({
errorText: null,
canTryLogin: true,
});
}
};
onRegisterClick = ev => { onRegisterClick = ev => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
@ -352,14 +351,14 @@ export default class LoginComponent extends React.Component {
}; };
onTryRegisterClick = ev => { onTryRegisterClick = ev => {
const step = this._getCurrentFlowStep(); const step = this.getCurrentFlowStep();
if (step === 'm.login.sso' || step === 'm.login.cas') { if (step === 'm.login.sso' || step === 'm.login.cas') {
// If we're showing SSO it means that registration is also probably disabled, // If we're showing SSO it means that registration is also probably disabled,
// so intercept the click and instead pretend the user clicked 'Sign in with SSO'. // so intercept the click and instead pretend the user clicked 'Sign in with SSO'.
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
const ssoKind = step === 'm.login.sso' ? 'sso' : 'cas'; const ssoKind = step === 'm.login.sso' ? 'sso' : 'cas';
PlatformPeg.get().startSingleSignOn(this._loginLogic.createTemporaryClient(), ssoKind, PlatformPeg.get().startSingleSignOn(this.loginLogic.createTemporaryClient(), ssoKind,
this.props.fragmentAfterLogin); this.props.fragmentAfterLogin);
} else { } else {
// Don't intercept - just go through to the register page // Don't intercept - just go through to the register page
@ -367,24 +366,21 @@ export default class LoginComponent extends React.Component {
} }
}; };
onServerDetailsNextPhaseClick = () => { private onServerDetailsNextPhaseClick = () => {
this.setState({ this.setState({
phase: PHASE_LOGIN, phase: Phase.Login,
}); });
}; };
onEditServerDetailsClick = ev => { private onEditServerDetailsClick = ev => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this.setState({ this.setState({
phase: PHASE_SERVER_DETAILS, phase: Phase.ServerDetails,
}); });
}; };
async _initLoginLogic(hsUrl, isUrl) { private async initLoginLogic({hsUrl, isUrl}: ValidatedServerConfig) {
hsUrl = hsUrl || this.props.serverConfig.hsUrl;
isUrl = isUrl || this.props.serverConfig.isUrl;
let isDefaultServer = false; let isDefaultServer = false;
if (this.props.serverConfig.isDefault if (this.props.serverConfig.isDefault
&& hsUrl === this.props.serverConfig.hsUrl && hsUrl === this.props.serverConfig.hsUrl
@ -397,7 +393,7 @@ export default class LoginComponent extends React.Component {
const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, { const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, {
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName, defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
}); });
this._loginLogic = loginLogic; this.loginLogic = loginLogic;
this.setState({ this.setState({
busy: true, busy: true,
@ -428,7 +424,7 @@ export default class LoginComponent extends React.Component {
if (this.state.serverErrorIsFatal) { if (this.state.serverErrorIsFatal) {
// Server is dead: show server details prompt instead // Server is dead: show server details prompt instead
this.setState({ this.setState({
phase: PHASE_SERVER_DETAILS, phase: Phase.ServerDetails,
}); });
return; return;
} }
@ -437,7 +433,7 @@ export default class LoginComponent extends React.Component {
loginLogic.getFlows().then((flows) => { loginLogic.getFlows().then((flows) => {
// look for a flow where we understand all of the steps. // look for a flow where we understand all of the steps.
for (let i = 0; i < flows.length; i++ ) { for (let i = 0; i < flows.length; i++ ) {
if (!this._isSupportedFlow(flows[i])) { if (!this.isSupportedFlow(flows[i])) {
continue; continue;
} }
@ -446,7 +442,7 @@ export default class LoginComponent extends React.Component {
// that for now). // that for now).
loginLogic.chooseFlow(i); loginLogic.chooseFlow(i);
this.setState({ this.setState({
currentFlow: this._getCurrentFlowStep(), currentFlow: this.getCurrentFlowStep(),
}); });
return; return;
} }
@ -460,7 +456,7 @@ export default class LoginComponent extends React.Component {
}); });
}, (err) => { }, (err) => {
this.setState({ this.setState({
errorText: this._errorTextFromError(err), errorText: this.errorTextFromError(err),
loginIncorrect: false, loginIncorrect: false,
canTryLogin: false, canTryLogin: false,
}); });
@ -471,27 +467,27 @@ export default class LoginComponent extends React.Component {
}); });
} }
_isSupportedFlow(flow) { private isSupportedFlow(flow) {
// technically the flow can have multiple steps, but no one does this // technically the flow can have multiple steps, but no one does this
// for login and loginLogic doesn't support it so we can ignore it. // for login and loginLogic doesn't support it so we can ignore it.
if (!this._stepRendererMap[flow.type]) { if (!this.stepRendererMap[flow.type]) {
console.log("Skipping flow", flow, "due to unsupported login type", flow.type); console.log("Skipping flow", flow, "due to unsupported login type", flow.type);
return false; return false;
} }
return true; return true;
} }
_getCurrentFlowStep() { private getCurrentFlowStep() {
return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null; return this.loginLogic ? this.loginLogic.getCurrentFlowStep() : null;
} }
_errorTextFromError(err) { private errorTextFromError(err) {
let errCode = err.errcode; let errCode = err.errcode;
if (!errCode && err.httpStatus) { if (!errCode && err.httpStatus) {
errCode = "HTTP " + err.httpStatus; errCode = "HTTP " + err.httpStatus;
} }
let errorText = _t("Error: Problem communicating with the given homeserver.") + let errorText: ReactNode = _t("Error: Problem communicating with the given homeserver.") +
(errCode ? " (" + errCode + ")" : ""); (errCode ? " (" + errCode + ")" : "");
if (err.cors === 'rejected') { if (err.cors === 'rejected') {
@ -510,8 +506,7 @@ export default class LoginComponent extends React.Component {
{ sub } { sub }
</a>; </a>;
}, },
}, }) }
) }
</span>; </span>;
} else { } else {
errorText = <span> errorText = <span>
@ -523,8 +518,7 @@ export default class LoginComponent extends React.Component {
<a target="_blank" rel="noreferrer noopener" href={this.props.serverConfig.hsUrl}> <a target="_blank" rel="noreferrer noopener" href={this.props.serverConfig.hsUrl}>
{ sub } { sub }
</a>, </a>,
}, }) }
) }
</span>; </span>;
} }
} }
@ -532,18 +526,16 @@ export default class LoginComponent extends React.Component {
return errorText; return errorText;
} }
renderServerComponent() { private renderServerComponent() {
const ServerConfig = sdk.getComponent("auth.ServerConfig");
if (SdkConfig.get()['disable_custom_urls']) { if (SdkConfig.get()['disable_custom_urls']) {
return null; return null;
} }
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) { if (PHASES_ENABLED && this.state.phase !== Phase.ServerDetails) {
return null; return null;
} }
const serverDetailsProps = {}; const serverDetailsProps: ComponentProps<typeof ServerConfig> = {};
if (PHASES_ENABLED) { if (PHASES_ENABLED) {
serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick; serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick;
serverDetailsProps.submitText = _t("Next"); serverDetailsProps.submitText = _t("Next");
@ -558,8 +550,8 @@ export default class LoginComponent extends React.Component {
/>; />;
} }
renderLoginComponentForStep() { private renderLoginComponentForStep() {
if (PHASES_ENABLED && this.state.phase !== PHASE_LOGIN) { if (PHASES_ENABLED && this.state.phase !== Phase.Login) {
return null; return null;
} }
@ -569,7 +561,7 @@ export default class LoginComponent extends React.Component {
return null; return null;
} }
const stepRenderer = this._stepRendererMap[step]; const stepRenderer = this.stepRendererMap[step];
if (stepRenderer) { if (stepRenderer) {
return stepRenderer(); return stepRenderer();
@ -578,9 +570,7 @@ export default class LoginComponent extends React.Component {
return null; return null;
} }
_renderPasswordStep = () => { private renderPasswordStep = () => {
const PasswordLogin = sdk.getComponent('auth.PasswordLogin');
let onEditServerDetailsClick = null; let onEditServerDetailsClick = null;
// If custom URLs are allowed, wire up the server details edit link. // If custom URLs are allowed, wire up the server details edit link.
if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) { if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) {
@ -590,16 +580,14 @@ export default class LoginComponent extends React.Component {
return ( return (
<PasswordLogin <PasswordLogin
onSubmit={this.onPasswordLogin} onSubmit={this.onPasswordLogin}
onError={this.onPasswordLoginError}
onEditServerDetailsClick={onEditServerDetailsClick} onEditServerDetailsClick={onEditServerDetailsClick}
initialUsername={this.state.username} username={this.state.username}
initialPhoneCountry={this.state.phoneCountry} phoneCountry={this.state.phoneCountry}
initialPhoneNumber={this.state.phoneNumber} phoneNumber={this.state.phoneNumber}
onUsernameChanged={this.onUsernameChanged} onUsernameChanged={this.onUsernameChanged}
onUsernameBlur={this.onUsernameBlur} onUsernameBlur={this.onUsernameBlur}
onPhoneCountryChanged={this.onPhoneCountryChanged} onPhoneCountryChanged={this.onPhoneCountryChanged}
onPhoneNumberChanged={this.onPhoneNumberChanged} onPhoneNumberChanged={this.onPhoneNumberChanged}
onPhoneNumberBlur={this.onPhoneNumberBlur}
onForgotPasswordClick={this.props.onForgotPasswordClick} onForgotPasswordClick={this.props.onForgotPasswordClick}
loginIncorrect={this.state.loginIncorrect} loginIncorrect={this.state.loginIncorrect}
serverConfig={this.props.serverConfig} serverConfig={this.props.serverConfig}
@ -609,9 +597,7 @@ export default class LoginComponent extends React.Component {
); );
}; };
_renderSsoStep = loginType => { private renderSsoStep = loginType => {
const SignInToText = sdk.getComponent('views.auth.SignInToText');
let onEditServerDetailsClick = null; let onEditServerDetailsClick = null;
// If custom URLs are allowed, wire up the server details edit link. // If custom URLs are allowed, wire up the server details edit link.
if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) { if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) {
@ -632,7 +618,7 @@ export default class LoginComponent extends React.Component {
<SSOButton <SSOButton
className="mx_Login_sso_link mx_Login_submit" className="mx_Login_sso_link mx_Login_submit"
matrixClient={this._loginLogic.createTemporaryClient()} matrixClient={this.loginLogic.createTemporaryClient()}
loginType={loginType} loginType={loginType}
fragmentAfterLogin={this.props.fragmentAfterLogin} fragmentAfterLogin={this.props.fragmentAfterLogin}
/> />
@ -641,12 +627,10 @@ export default class LoginComponent extends React.Component {
}; };
render() { render() {
const Loader = sdk.getComponent("elements.Spinner");
const InlineSpinner = sdk.getComponent("elements.InlineSpinner");
const AuthHeader = sdk.getComponent("auth.AuthHeader"); const AuthHeader = sdk.getComponent("auth.AuthHeader");
const AuthBody = sdk.getComponent("auth.AuthBody"); const AuthBody = sdk.getComponent("auth.AuthBody");
const loader = this.isBusy() && !this.state.busyLoggingIn ? const loader = this.isBusy() && !this.state.busyLoggingIn ?
<div className="mx_Login_loader"><Loader /></div> : null; <div className="mx_Login_loader"><Spinner /></div> : null;
const errorText = this.state.errorText; const errorText = this.state.errorText;

View file

@ -1,8 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016, 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -18,8 +15,9 @@ limitations under the License.
*/ */
import Matrix from 'matrix-js-sdk'; import Matrix from 'matrix-js-sdk';
import React from 'react'; import React, {ComponentProps, ReactNode} from 'react';
import PropTypes from 'prop-types'; import {MatrixClient} from "matrix-js-sdk/src/client";
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import { _t, _td } from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
@ -34,43 +32,51 @@ import Login from "../../../Login";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
// Phases // Phases
enum Phase {
// Show controls to configure server details // Show controls to configure server details
const PHASE_SERVER_DETAILS = 0; ServerDetails = 0,
// Show the appropriate registration flow(s) for the server // Show the appropriate registration flow(s) for the server
const PHASE_REGISTRATION = 1; Registration = 1,
}
// Enable phases for registration interface IProps {
const PHASES_ENABLED = true; serverConfig: ValidatedServerConfig;
defaultDeviceDisplayName: string;
email?: string;
brand?: string;
clientSecret?: string;
sessionId?: string;
idSid?: string;
export default class Registration extends React.Component {
static propTypes = {
// Called when the user has logged in. Params: // Called when the user has logged in. Params:
// - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken // - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken
// - The user's password, if available and applicable (may be cached in memory // - The user's password, if available and applicable (may be cached in memory
// for a short time so the user is not required to re-enter their password // for a short time so the user is not required to re-enter their password
// for operations like uploading cross-signing keys). // for operations like uploading cross-signing keys).
onLoggedIn: PropTypes.func.isRequired, onLoggedIn(params: {
userId: string;
clientSecret: PropTypes.string, deviceId: string
sessionId: PropTypes.string, homeserverUrl: string;
makeRegistrationUrl: PropTypes.func.isRequired, identityServerUrl?: string;
idSid: PropTypes.string, accessToken: string;
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, }, password: string): void;
brand: PropTypes.string, makeRegistrationUrl(params: {
email: PropTypes.string, /* eslint-disable camelcase */
client_secret: string;
hs_url: string;
is_url?: string;
session_id: string;
/* eslint-enable camelcase */
}): void;
// registration shouldn't know or care how login is done. // registration shouldn't know or care how login is done.
onLoginClick: PropTypes.func.isRequired, onLoginClick(): void;
onServerConfigChange: PropTypes.func.isRequired, onServerConfigChange(config: ValidatedServerConfig): void;
defaultDeviceDisplayName: PropTypes.string, }
};
constructor(props) { interface IState {
super(props); busy: boolean;
errorText?: ReactNode;
const serverType = ServerType.getTypeFromServerConfig(this.props.serverConfig); // true if we're waiting for the user to complete
this.state = {
busy: false,
errorText: null,
// We remember the values entered by the user because // We remember the values entered by the user because
// the registration form will be unmounted during the // the registration form will be unmounted during the
// course of registration, but if there's an error we // course of registration, but if there's an error we
@ -78,49 +84,67 @@ export default class Registration extends React.Component {
// values the user entered still in it. We can keep // values the user entered still in it. We can keep
// them in this component's state since this component // them in this component's state since this component
// persist for the duration of the registration process. // persist for the duration of the registration process.
formVals: { formVals: Record<string, string>;
email: this.props.email,
},
// true if we're waiting for the user to complete
// user-interactive auth // user-interactive auth
// If we've been given a session ID, we're resuming // If we've been given a session ID, we're resuming
// straight back into UI auth // straight back into UI auth
doingUIAuth: Boolean(this.props.sessionId), doingUIAuth: boolean;
serverType,
// Phase of the overall registration dialog.
phase: PHASE_REGISTRATION,
flows: null,
// If set, we've registered but are not going to log // If set, we've registered but are not going to log
// the user in to their new account automatically. // the user in to their new account automatically.
completedNoSignin: false, completedNoSignin: boolean;
serverType: ServerType.FREE | ServerType.PREMIUM | ServerType.ADVANCED;
// Phase of the overall registration dialog.
phase: Phase;
flows: {
stages: string[];
}[];
// We perform liveliness checks later, but for now suppress the errors. // We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so // We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may // that we can render it differently, and override any other error the user may
// be seeing. // be seeing.
serverIsAlive: true, serverIsAlive: boolean;
serverErrorIsFatal: false, serverErrorIsFatal: boolean;
serverDeadError: "", serverDeadError: string;
// Our matrix client - part of state because we can't render the UI auth // Our matrix client - part of state because we can't render the UI auth
// component without it. // component without it.
matrixClient: null, matrixClient?: MatrixClient;
// whether the HS requires an ID server to register with a threepid // whether the HS requires an ID server to register with a threepid
serverRequiresIdServer: null, serverRequiresIdServer?: boolean;
// The user ID we've just registered // The user ID we've just registered
registeredUsername: null, registeredUsername?: string;
// if a different user ID to the one we just registered is logged in, // if a different user ID to the one we just registered is logged in,
// this is the user ID that's logged in. // this is the user ID that's logged in.
differentLoggedInUserId: null, differentLoggedInUserId?: string;
}
// Enable phases for registration
const PHASES_ENABLED = true;
export default class Registration extends React.Component<IProps, IState> {
constructor(props) {
super(props);
const serverType = ServerType.getTypeFromServerConfig(this.props.serverConfig);
this.state = {
busy: false,
errorText: null,
formVals: {
email: this.props.email,
},
doingUIAuth: Boolean(this.props.sessionId),
serverType,
phase: Phase.Registration,
flows: null,
completedNoSignin: false,
serverIsAlive: true,
serverErrorIsFatal: false,
serverDeadError: "",
}; };
} }
componentDidMount() { componentDidMount() {
this._unmounted = false; this.replaceClient(this.props.serverConfig);
this._replaceClient();
} }
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
@ -129,7 +153,7 @@ export default class Registration extends React.Component {
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
this._replaceClient(newProps.serverConfig); this.replaceClient(newProps.serverConfig);
// Handle cases where the user enters "https://matrix.org" for their server // Handle cases where the user enters "https://matrix.org" for their server
// from the advanced option - we should default to FREE at that point. // from the advanced option - we should default to FREE at that point.
@ -138,25 +162,25 @@ export default class Registration extends React.Component {
// Reset the phase to default phase for the server type. // Reset the phase to default phase for the server type.
this.setState({ this.setState({
serverType, serverType,
phase: this.getDefaultPhaseForServerType(serverType), phase: Registration.getDefaultPhaseForServerType(serverType),
}); });
} }
} }
getDefaultPhaseForServerType(type) { private static getDefaultPhaseForServerType(type: IState["serverType"]) {
switch (type) { switch (type) {
case ServerType.FREE: { case ServerType.FREE: {
// Move directly to the registration phase since the server // Move directly to the registration phase since the server
// details are fixed. // details are fixed.
return PHASE_REGISTRATION; return Phase.Registration;
} }
case ServerType.PREMIUM: case ServerType.PREMIUM:
case ServerType.ADVANCED: case ServerType.ADVANCED:
return PHASE_SERVER_DETAILS; return Phase.ServerDetails;
} }
} }
onServerTypeChange = type => { private onServerTypeChange = (type: IState["serverType"]) => {
this.setState({ this.setState({
serverType: type, serverType: type,
}); });
@ -181,11 +205,11 @@ export default class Registration extends React.Component {
// Reset the phase to default phase for the server type. // Reset the phase to default phase for the server type.
this.setState({ this.setState({
phase: this.getDefaultPhaseForServerType(type), phase: Registration.getDefaultPhaseForServerType(type),
}); });
}; };
async _replaceClient(serverConfig) { private async replaceClient(serverConfig: ValidatedServerConfig) {
this.setState({ this.setState({
errorText: null, errorText: null,
serverDeadError: null, serverDeadError: null,
@ -194,7 +218,6 @@ export default class Registration extends React.Component {
// the UI auth component while we don't have a matrix client) // the UI auth component while we don't have a matrix client)
busy: true, busy: true,
}); });
if (!serverConfig) serverConfig = this.props.serverConfig;
// Do a liveliness check on the URLs // Do a liveliness check on the URLs
try { try {
@ -246,7 +269,7 @@ export default class Registration extends React.Component {
// do SSO instead. If we've already started the UI Auth process though, we don't // do SSO instead. If we've already started the UI Auth process though, we don't
// need to. // need to.
if (!this.state.doingUIAuth) { if (!this.state.doingUIAuth) {
await this._makeRegisterRequest(null); await this.makeRegisterRequest(null);
// This should never succeed since we specified no auth object. // This should never succeed since we specified no auth object.
console.log("Expecting 401 from register request but got success!"); console.log("Expecting 401 from register request but got success!");
} }
@ -287,7 +310,7 @@ export default class Registration extends React.Component {
} }
} }
onFormSubmit = formVals => { private onFormSubmit = formVals => {
this.setState({ this.setState({
errorText: "", errorText: "",
busy: true, busy: true,
@ -296,7 +319,7 @@ export default class Registration extends React.Component {
}); });
}; };
_requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => { private requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => {
return this.state.matrixClient.requestRegisterEmailToken( return this.state.matrixClient.requestRegisterEmailToken(
emailAddress, emailAddress,
clientSecret, clientSecret,
@ -310,28 +333,26 @@ export default class Registration extends React.Component {
); );
} }
_onUIAuthFinished = async (success, response, extra) => { private onUIAuthFinished = async (success, response, extra) => {
if (!success) { if (!success) {
let msg = response.message || response.toString(); let msg = response.message || response.toString();
// can we give a better error message? // can we give a better error message?
if (response.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { if (response.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
const errorTop = messageForResourceLimitError( const errorTop = messageForResourceLimitError(
response.data.limit_type, response.data.limit_type,
response.data.admin_contact, { response.data.admin_contact,
'monthly_active_user': _td( {
"This homeserver has hit its Monthly Active User limit.", 'monthly_active_user': _td("This homeserver has hit its Monthly Active User limit."),
), '': _td("This homeserver has exceeded one of its resource limits."),
'': _td( },
"This homeserver has exceeded one of its resource limits.", );
),
});
const errorDetail = messageForResourceLimitError( const errorDetail = messageForResourceLimitError(
response.data.limit_type, response.data.limit_type,
response.data.admin_contact, { response.data.admin_contact,
'': _td( {
"Please <a>contact your service administrator</a> to continue using this service.", '': _td("Please <a>contact your service administrator</a> to continue using this service."),
), },
}); );
msg = <div> msg = <div>
<p>{errorTop}</p> <p>{errorTop}</p>
<p>{errorDetail}</p> <p>{errorDetail}</p>
@ -339,7 +360,7 @@ export default class Registration extends React.Component {
} else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) { } else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) {
let msisdnAvailable = false; let msisdnAvailable = false;
for (const flow of response.available_flows) { for (const flow of response.available_flows) {
msisdnAvailable |= flow.stages.indexOf('m.login.msisdn') > -1; msisdnAvailable = msisdnAvailable || flow.stages.includes('m.login.msisdn');
} }
if (!msisdnAvailable) { if (!msisdnAvailable) {
msg = _t('This server does not support authentication with a phone number.'); msg = _t('This server does not support authentication with a phone number.');
@ -358,6 +379,10 @@ export default class Registration extends React.Component {
const newState = { const newState = {
doingUIAuth: false, doingUIAuth: false,
registeredUsername: response.user_id, registeredUsername: response.user_id,
differentLoggedInUserId: null,
completedNoSignin: false,
// we're still busy until we get unmounted: don't show the registration form again
busy: true,
}; };
// The user came in through an email validation link. To avoid overwriting // The user came in through an email validation link. To avoid overwriting
@ -372,8 +397,6 @@ export default class Registration extends React.Component {
`Found a session for ${sessionOwner} but ${response.userId} has just registered.`, `Found a session for ${sessionOwner} but ${response.userId} has just registered.`,
); );
newState.differentLoggedInUserId = sessionOwner; newState.differentLoggedInUserId = sessionOwner;
} else {
newState.differentLoggedInUserId = null;
} }
if (response.access_token) { if (response.access_token) {
@ -385,9 +408,7 @@ export default class Registration extends React.Component {
accessToken: response.access_token, accessToken: response.access_token,
}, this.state.formVals.password); }, this.state.formVals.password);
this._setupPushers(); this.setupPushers();
// we're still busy until we get unmounted: don't show the registration form again
newState.busy = true;
} else { } else {
newState.busy = false; newState.busy = false;
newState.completedNoSignin = true; newState.completedNoSignin = true;
@ -396,7 +417,7 @@ export default class Registration extends React.Component {
this.setState(newState); this.setState(newState);
}; };
_setupPushers() { private setupPushers() {
if (!this.props.brand) { if (!this.props.brand) {
return Promise.resolve(); return Promise.resolve();
} }
@ -419,38 +440,38 @@ export default class Registration extends React.Component {
}); });
} }
onLoginClick = ev => { private onLoginClick = ev => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this.props.onLoginClick(); this.props.onLoginClick();
}; };
onGoToFormClicked = ev => { private onGoToFormClicked = ev => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this._replaceClient(); this.replaceClient(this.props.serverConfig);
this.setState({ this.setState({
busy: false, busy: false,
doingUIAuth: false, doingUIAuth: false,
phase: PHASE_REGISTRATION, phase: Phase.Registration,
}); });
}; };
onServerDetailsNextPhaseClick = async () => { private onServerDetailsNextPhaseClick = async () => {
this.setState({ this.setState({
phase: PHASE_REGISTRATION, phase: Phase.Registration,
}); });
}; };
onEditServerDetailsClick = ev => { private onEditServerDetailsClick = ev => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this.setState({ this.setState({
phase: PHASE_SERVER_DETAILS, phase: Phase.ServerDetails,
}); });
}; };
_makeRegisterRequest = auth => { private makeRegisterRequest = auth => {
// We inhibit login if we're trying to register with an email address: this // We inhibit login if we're trying to register with an email address: this
// avoids a lot of complex race conditions that can occur if we try to log // avoids a lot of complex race conditions that can occur if we try to log
// the user in one one or both of the tabs they might end up with after // the user in one one or both of the tabs they might end up with after
@ -466,13 +487,15 @@ export default class Registration extends React.Component {
username: this.state.formVals.username, username: this.state.formVals.username,
password: this.state.formVals.password, password: this.state.formVals.password,
initial_device_display_name: this.props.defaultDeviceDisplayName, initial_device_display_name: this.props.defaultDeviceDisplayName,
auth: undefined,
inhibit_login: undefined,
}; };
if (auth) registerParams.auth = auth; if (auth) registerParams.auth = auth;
if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin; if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin;
return this.state.matrixClient.registerRequest(registerParams); return this.state.matrixClient.registerRequest(registerParams);
}; };
_getUIAuthInputs() { private getUIAuthInputs() {
return { return {
emailAddress: this.state.formVals.email, emailAddress: this.state.formVals.email,
phoneCountry: this.state.formVals.phoneCountry, phoneCountry: this.state.formVals.phoneCountry,
@ -483,7 +506,7 @@ export default class Registration extends React.Component {
// Links to the login page shown after registration is completed are routed through this // Links to the login page shown after registration is completed are routed through this
// which checks the user hasn't already logged in somewhere else (perhaps we should do // which checks the user hasn't already logged in somewhere else (perhaps we should do
// this more generally?) // this more generally?)
_onLoginClickWithCheck = async ev => { private onLoginClickWithCheck = async ev => {
ev.preventDefault(); ev.preventDefault();
const sessionLoaded = await Lifecycle.loadSession({ignoreGuest: true}); const sessionLoaded = await Lifecycle.loadSession({ignoreGuest: true});
@ -493,7 +516,7 @@ export default class Registration extends React.Component {
} }
}; };
renderServerComponent() { private renderServerComponent() {
const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector"); const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector");
const ServerConfig = sdk.getComponent("auth.ServerConfig"); const ServerConfig = sdk.getComponent("auth.ServerConfig");
const ModularServerConfig = sdk.getComponent("auth.ModularServerConfig"); const ModularServerConfig = sdk.getComponent("auth.ModularServerConfig");
@ -503,7 +526,7 @@ export default class Registration extends React.Component {
} }
// Hide the server picker once the user is doing UI Auth unless encountered a fatal server error // Hide the server picker once the user is doing UI Auth unless encountered a fatal server error
if (this.state.phase !== PHASE_SERVER_DETAILS && this.state.doingUIAuth && !this.state.serverErrorIsFatal) { if (this.state.phase !== Phase.ServerDetails && this.state.doingUIAuth && !this.state.serverErrorIsFatal) {
return null; return null;
} }
@ -511,7 +534,7 @@ export default class Registration extends React.Component {
// which is always shown if we allow custom URLs at all. // which is always shown if we allow custom URLs at all.
// (if there's a fatal server error, we need to show the full server // (if there's a fatal server error, we need to show the full server
// config as the user may need to change servers to resolve the error). // config as the user may need to change servers to resolve the error).
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS && !this.state.serverErrorIsFatal) { if (PHASES_ENABLED && this.state.phase !== Phase.ServerDetails && !this.state.serverErrorIsFatal) {
return <div> return <div>
<ServerTypeSelector <ServerTypeSelector
selected={this.state.serverType} selected={this.state.serverType}
@ -520,7 +543,7 @@ export default class Registration extends React.Component {
</div>; </div>;
} }
const serverDetailsProps = {}; const serverDetailsProps: ComponentProps<typeof ServerConfig> = {};
if (PHASES_ENABLED) { if (PHASES_ENABLED) {
serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick; serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick;
serverDetailsProps.submitText = _t("Next"); serverDetailsProps.submitText = _t("Next");
@ -559,8 +582,8 @@ export default class Registration extends React.Component {
</div>; </div>;
} }
renderRegisterComponent() { private renderRegisterComponent() {
if (PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) { if (PHASES_ENABLED && this.state.phase !== Phase.Registration) {
return null; return null;
} }
@ -571,10 +594,10 @@ export default class Registration extends React.Component {
if (this.state.matrixClient && this.state.doingUIAuth) { if (this.state.matrixClient && this.state.doingUIAuth) {
return <InteractiveAuth return <InteractiveAuth
matrixClient={this.state.matrixClient} matrixClient={this.state.matrixClient}
makeRequest={this._makeRegisterRequest} makeRequest={this.makeRegisterRequest}
onAuthFinished={this._onUIAuthFinished} onAuthFinished={this.onUIAuthFinished}
inputs={this._getUIAuthInputs()} inputs={this.getUIAuthInputs()}
requestEmailToken={this._requestEmailToken} requestEmailToken={this.requestEmailToken}
sessionId={this.props.sessionId} sessionId={this.props.sessionId}
clientSecret={this.props.clientSecret} clientSecret={this.props.clientSecret}
emailSid={this.props.idSid} emailSid={this.props.idSid}
@ -633,7 +656,7 @@ export default class Registration extends React.Component {
// Only show the 'go back' button if you're not looking at the form // Only show the 'go back' button if you're not looking at the form
let goBack; let goBack;
if ((PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) || this.state.doingUIAuth) { if ((PHASES_ENABLED && this.state.phase !== Phase.Registration) || this.state.doingUIAuth) {
goBack = <a className="mx_AuthBody_changeFlow" onClick={this.onGoToFormClicked} href="#"> goBack = <a className="mx_AuthBody_changeFlow" onClick={this.onGoToFormClicked} href="#">
{ _t('Go back') } { _t('Go back') }
</a>; </a>;
@ -651,7 +674,7 @@ export default class Registration extends React.Component {
loggedInUserId: this.state.differentLoggedInUserId, loggedInUserId: this.state.differentLoggedInUserId,
}, },
)}</p> )}</p>
<p><AccessibleButton element="span" className="mx_linkButton" onClick={this._onLoginClickWithCheck}> <p><AccessibleButton element="span" className="mx_linkButton" onClick={this.onLoginClickWithCheck}>
{_t("Continue with previous account")} {_t("Continue with previous account")}
</AccessibleButton></p> </AccessibleButton></p>
</div>; </div>;
@ -660,7 +683,7 @@ export default class Registration extends React.Component {
regDoneText = <h3>{_t( regDoneText = <h3>{_t(
"<a>Log in</a> to your new account.", {}, "<a>Log in</a> to your new account.", {},
{ {
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>, a: (sub) => <a href="#/login" onClick={this.onLoginClickWithCheck}>{sub}</a>,
}, },
)}</h3>; )}</h3>;
} else { } else {
@ -670,7 +693,7 @@ export default class Registration extends React.Component {
regDoneText = <h3>{_t( regDoneText = <h3>{_t(
"You can now close this window or <a>log in</a> to your new account.", {}, "You can now close this window or <a>log in</a> to your new account.", {},
{ {
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>, a: (sub) => <a href="#/login" onClick={this.onLoginClickWithCheck}>{sub}</a>,
}, },
)}</h3>; )}</h3>;
} }
@ -679,7 +702,7 @@ export default class Registration extends React.Component {
{ regDoneText } { regDoneText }
</div>; </div>;
} else { } else {
let yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', { let yourMatrixAccountText: ReactNode = _t('Create your Matrix account on %(serverName)s', {
serverName: this.props.serverConfig.hsName, serverName: this.props.serverConfig.hsName,
}); });
if (this.props.serverConfig.hsNameIsDifferent) { if (this.props.serverConfig.hsNameIsDifferent) {
@ -717,7 +740,7 @@ export default class Registration extends React.Component {
{ errorText } { errorText }
{ serverDeadSection } { serverDeadSection }
{ this.renderServerComponent() } { this.renderServerComponent() }
{ this.state.phase !== PHASE_SERVER_DETAILS && <h3> { this.state.phase !== Phase.ServerDetails && <h3>
{yourMatrixAccountText} {yourMatrixAccountText}
{editLink} {editLink}
</h3> } </h3> }

View file

@ -21,9 +21,9 @@ import zxcvbn from "zxcvbn";
import SdkConfig from "../../../SdkConfig"; import SdkConfig from "../../../SdkConfig";
import withValidation, {IFieldState, IValidationResult} from "../elements/Validation"; import withValidation, {IFieldState, IValidationResult} from "../elements/Validation";
import {_t, _td} from "../../../languageHandler"; import {_t, _td} from "../../../languageHandler";
import Field from "../elements/Field"; import Field, {IInputProps} from "../elements/Field";
interface IProps { interface IProps extends Omit<IInputProps, "onValidate"> {
autoFocus?: boolean; autoFocus?: boolean;
id?: string; id?: string;
className?: string; className?: string;

View file

@ -1,377 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2019 New Vector Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import AccessibleButton from "../elements/AccessibleButton";
import CountlyAnalytics from "../../../CountlyAnalytics";
/**
* A pure UI component which displays a username/password form.
*/
export default class PasswordLogin extends React.Component {
static propTypes = {
onSubmit: PropTypes.func.isRequired, // fn(username, password)
onError: PropTypes.func,
onEditServerDetailsClick: PropTypes.func,
onForgotPasswordClick: PropTypes.func, // fn()
initialUsername: PropTypes.string,
initialPhoneCountry: PropTypes.string,
initialPhoneNumber: PropTypes.string,
initialPassword: PropTypes.string,
onUsernameChanged: PropTypes.func,
onPhoneCountryChanged: PropTypes.func,
onPhoneNumberChanged: PropTypes.func,
onPasswordChanged: PropTypes.func,
loginIncorrect: PropTypes.bool,
disableSubmit: PropTypes.bool,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
busy: PropTypes.bool,
};
static defaultProps = {
onError: function() {},
onEditServerDetailsClick: null,
onUsernameChanged: function() {},
onUsernameBlur: function() {},
onPasswordChanged: function() {},
onPhoneCountryChanged: function() {},
onPhoneNumberChanged: function() {},
onPhoneNumberBlur: function() {},
initialUsername: "",
initialPhoneCountry: "",
initialPhoneNumber: "",
initialPassword: "",
loginIncorrect: false,
disableSubmit: false,
};
static LOGIN_FIELD_EMAIL = "login_field_email";
static LOGIN_FIELD_MXID = "login_field_mxid";
static LOGIN_FIELD_PHONE = "login_field_phone";
constructor(props) {
super(props);
this.state = {
username: this.props.initialUsername,
password: this.props.initialPassword,
phoneCountry: this.props.initialPhoneCountry,
phoneNumber: this.props.initialPhoneNumber,
loginType: PasswordLogin.LOGIN_FIELD_MXID,
};
this.onForgotPasswordClick = this.onForgotPasswordClick.bind(this);
this.onSubmitForm = this.onSubmitForm.bind(this);
this.onUsernameChanged = this.onUsernameChanged.bind(this);
this.onUsernameBlur = this.onUsernameBlur.bind(this);
this.onLoginTypeChange = this.onLoginTypeChange.bind(this);
this.onPhoneCountryChanged = this.onPhoneCountryChanged.bind(this);
this.onPhoneNumberChanged = this.onPhoneNumberChanged.bind(this);
this.onPhoneNumberBlur = this.onPhoneNumberBlur.bind(this);
this.onPasswordChanged = this.onPasswordChanged.bind(this);
this.isLoginEmpty = this.isLoginEmpty.bind(this);
}
onForgotPasswordClick(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onForgotPasswordClick();
}
onSubmitForm(ev) {
ev.preventDefault();
let username = ''; // XXX: Synapse breaks if you send null here:
let phoneCountry = null;
let phoneNumber = null;
let error;
switch (this.state.loginType) {
case PasswordLogin.LOGIN_FIELD_EMAIL:
username = this.state.username;
if (!username) {
error = _t('The email field must not be blank.');
}
break;
case PasswordLogin.LOGIN_FIELD_MXID:
username = this.state.username;
if (!username) {
error = _t('The username field must not be blank.');
}
break;
case PasswordLogin.LOGIN_FIELD_PHONE:
phoneCountry = this.state.phoneCountry;
phoneNumber = this.state.phoneNumber;
if (!phoneNumber) {
error = _t('The phone number field must not be blank.');
}
break;
}
if (error) {
this.props.onError(error);
return;
}
if (!this.state.password) {
this.props.onError(_t('The password field must not be blank.'));
return;
}
this.props.onSubmit(
username,
phoneCountry,
phoneNumber,
this.state.password,
);
}
onUsernameChanged(ev) {
this.setState({username: ev.target.value});
this.props.onUsernameChanged(ev.target.value);
}
onUsernameFocus() {
if (this.state.loginType === PasswordLogin.LOGIN_FIELD_MXID) {
CountlyAnalytics.instance.track("onboarding_login_mxid_focus");
} else {
CountlyAnalytics.instance.track("onboarding_login_email_focus");
}
}
onUsernameBlur(ev) {
if (this.state.loginType === PasswordLogin.LOGIN_FIELD_MXID) {
CountlyAnalytics.instance.track("onboarding_login_mxid_blur");
} else {
CountlyAnalytics.instance.track("onboarding_login_email_blur");
}
this.props.onUsernameBlur(ev.target.value);
}
onLoginTypeChange(ev) {
const loginType = ev.target.value;
this.props.onError(null); // send a null error to clear any error messages
this.setState({
loginType: loginType,
username: "", // Reset because email and username use the same state
});
CountlyAnalytics.instance.track("onboarding_login_type_changed", { loginType });
}
onPhoneCountryChanged(country) {
this.setState({
phoneCountry: country.iso2,
phonePrefix: country.prefix,
});
this.props.onPhoneCountryChanged(country.iso2);
}
onPhoneNumberChanged(ev) {
this.setState({phoneNumber: ev.target.value});
this.props.onPhoneNumberChanged(ev.target.value);
}
onPhoneNumberFocus() {
CountlyAnalytics.instance.track("onboarding_login_phone_number_focus");
}
onPhoneNumberBlur(ev) {
this.props.onPhoneNumberBlur(ev.target.value);
CountlyAnalytics.instance.track("onboarding_login_phone_number_blur");
}
onPasswordChanged(ev) {
this.setState({password: ev.target.value});
this.props.onPasswordChanged(ev.target.value);
}
renderLoginField(loginType, autoFocus) {
const Field = sdk.getComponent('elements.Field');
const classes = {};
switch (loginType) {
case PasswordLogin.LOGIN_FIELD_EMAIL:
classes.error = this.props.loginIncorrect && !this.state.username;
return <Field
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
key="email_input"
type="text"
label={_t("Email")}
placeholder="joe@example.com"
value={this.state.username}
onChange={this.onUsernameChanged}
onFocus={this.onUsernameFocus}
onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
/>;
case PasswordLogin.LOGIN_FIELD_MXID:
classes.error = this.props.loginIncorrect && !this.state.username;
return <Field
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
key="username_input"
type="text"
label={_t("Username")}
value={this.state.username}
onChange={this.onUsernameChanged}
onFocus={this.onUsernameFocus}
onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
/>;
case PasswordLogin.LOGIN_FIELD_PHONE: {
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
classes.error = this.props.loginIncorrect && !this.state.phoneNumber;
const phoneCountry = <CountryDropdown
value={this.state.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChanged}
/>;
return <Field
className={classNames(classes)}
name="phoneNumber"
key="phone_input"
type="text"
label={_t("Phone")}
value={this.state.phoneNumber}
prefixComponent={phoneCountry}
onChange={this.onPhoneNumberChanged}
onFocus={this.onPhoneNumberFocus}
onBlur={this.onPhoneNumberBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
/>;
}
}
}
isLoginEmpty() {
switch (this.state.loginType) {
case PasswordLogin.LOGIN_FIELD_EMAIL:
case PasswordLogin.LOGIN_FIELD_MXID:
return !this.state.username;
case PasswordLogin.LOGIN_FIELD_PHONE:
return !this.state.phoneCountry || !this.state.phoneNumber;
}
}
render() {
const Field = sdk.getComponent('elements.Field');
const SignInToText = sdk.getComponent('views.auth.SignInToText');
let forgotPasswordJsx;
if (this.props.onForgotPasswordClick) {
forgotPasswordJsx = <span>
{_t('Not sure of your password? <a>Set a new one</a>', {}, {
a: sub => (
<AccessibleButton
className="mx_Login_forgot"
disabled={this.props.busy}
kind="link"
onClick={this.onForgotPasswordClick}
>
{sub}
</AccessibleButton>
),
})}
</span>;
}
const pwFieldClass = classNames({
error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field
});
// If login is empty, autoFocus login, otherwise autoFocus password.
// this is for when auto server discovery remounts us when the user tries to tab from username to password
const autoFocusPassword = !this.isLoginEmpty();
const loginField = this.renderLoginField(this.state.loginType, !autoFocusPassword);
let loginType;
if (!SdkConfig.get().disable_3pid_login) {
loginType = (
<div className="mx_Login_type_container">
<label className="mx_Login_type_label">{ _t('Sign in with') }</label>
<Field
element="select"
value={this.state.loginType}
onChange={this.onLoginTypeChange}
disabled={this.props.disableSubmit}
>
<option
key={PasswordLogin.LOGIN_FIELD_MXID}
value={PasswordLogin.LOGIN_FIELD_MXID}
>
{_t('Username')}
</option>
<option
key={PasswordLogin.LOGIN_FIELD_EMAIL}
value={PasswordLogin.LOGIN_FIELD_EMAIL}
>
{_t('Email address')}
</option>
<option
key={PasswordLogin.LOGIN_FIELD_PHONE}
value={PasswordLogin.LOGIN_FIELD_PHONE}
>
{_t('Phone')}
</option>
</Field>
</div>
);
}
return (
<div>
<SignInToText serverConfig={this.props.serverConfig}
onEditServerDetailsClick={this.props.onEditServerDetailsClick} />
<form onSubmit={this.onSubmitForm}>
{loginType}
{loginField}
<Field
className={pwFieldClass}
type="password"
name="password"
label={_t('Password')}
value={this.state.password}
onChange={this.onPasswordChanged}
disabled={this.props.disableSubmit}
autoFocus={autoFocusPassword}
/>
{forgotPasswordJsx}
{ !this.props.busy && <input className="mx_Login_submit"
type="submit"
value={_t('Sign in')}
disabled={this.props.disableSubmit}
/> }
</form>
</div>
);
}
}

View file

@ -0,0 +1,495 @@
/*
Copyright 2015, 2016, 2017, 2019 New Vector Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import AccessibleButton from "../elements/AccessibleButton";
import CountlyAnalytics from "../../../CountlyAnalytics";
import withValidation from "../elements/Validation";
import * as Email from "../../../email";
import Field from "../elements/Field";
import CountryDropdown from "./CountryDropdown";
import SignInToText from "./SignInToText";
// For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
interface IProps {
username: string; // also used for email address
phoneCountry: string;
phoneNumber: string;
serverConfig: ValidatedServerConfig;
loginIncorrect?: boolean;
disableSubmit?: boolean;
busy?: boolean;
onSubmit(username: string, phoneCountry: void, phoneNumber: void, password: string): void;
onSubmit(username: void, phoneCountry: string, phoneNumber: string, password: string): void;
onUsernameChanged?(username: string): void;
onUsernameBlur?(username: string): void;
onPhoneCountryChanged?(phoneCountry: string): void;
onPhoneNumberChanged?(phoneNumber: string): void;
onEditServerDetailsClick?(): void;
onForgotPasswordClick?(): void;
}
interface IState {
fieldValid: Partial<Record<LoginField, boolean>>;
loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone,
password: "",
}
enum LoginField {
Email = "login_field_email",
MatrixId = "login_field_mxid",
Phone = "login_field_phone",
Password = "login_field_phone",
}
/*
* A pure UI component which displays a username/password form.
* The email/username/phone fields are fully-controlled, the password field is not.
*/
export default class PasswordLogin extends React.PureComponent<IProps, IState> {
static defaultProps = {
onEditServerDetailsClick: null,
onUsernameChanged: function() {},
onUsernameBlur: function() {},
onPhoneCountryChanged: function() {},
onPhoneNumberChanged: function() {},
loginIncorrect: false,
disableSubmit: false,
};
constructor(props) {
super(props);
this.state = {
// Field error codes by field ID
fieldValid: {},
loginType: LoginField.MatrixId,
password: "",
};
}
private onForgotPasswordClick = ev => {
ev.preventDefault();
ev.stopPropagation();
this.props.onForgotPasswordClick();
};
private onSubmitForm = async ev => {
ev.preventDefault();
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
if (!allFieldsValid) {
CountlyAnalytics.instance.track("onboarding_registration_submit_failed");
return;
}
let username = ''; // XXX: Synapse breaks if you send null here:
let phoneCountry = null;
let phoneNumber = null;
switch (this.state.loginType) {
case LoginField.Email:
case LoginField.MatrixId:
username = this.props.username;
break;
case LoginField.Phone:
phoneCountry = this.props.phoneCountry;
phoneNumber = this.props.phoneNumber;
break;
}
this.props.onSubmit(username, phoneCountry, phoneNumber, this.state.password);
};
private onUsernameChanged = ev => {
this.props.onUsernameChanged(ev.target.value);
};
private onUsernameFocus = () => {
if (this.state.loginType === LoginField.MatrixId) {
CountlyAnalytics.instance.track("onboarding_login_mxid_focus");
} else {
CountlyAnalytics.instance.track("onboarding_login_email_focus");
}
};
private onUsernameBlur = ev => {
if (this.state.loginType === LoginField.MatrixId) {
CountlyAnalytics.instance.track("onboarding_login_mxid_blur");
} else {
CountlyAnalytics.instance.track("onboarding_login_email_blur");
}
this.props.onUsernameBlur(ev.target.value);
};
private onLoginTypeChange = ev => {
const loginType = ev.target.value;
this.setState({ loginType });
this.props.onUsernameChanged(""); // Reset because email and username use the same state
CountlyAnalytics.instance.track("onboarding_login_type_changed", { loginType });
};
private onPhoneCountryChanged = country => {
this.props.onPhoneCountryChanged(country.iso2);
};
private onPhoneNumberChanged = ev => {
this.props.onPhoneNumberChanged(ev.target.value);
};
private onPhoneNumberFocus = () => {
CountlyAnalytics.instance.track("onboarding_login_phone_number_focus");
};
private onPhoneNumberBlur = ev => {
CountlyAnalytics.instance.track("onboarding_login_phone_number_blur");
};
private onPasswordChanged = ev => {
this.setState({password: ev.target.value});
};
private async verifyFieldsBeforeSubmit() {
// Blur the active element if any, so we first run its blur validation,
// which is less strict than the pass we're about to do below for all fields.
const activeElement = document.activeElement as HTMLElement;
if (activeElement) {
activeElement.blur();
}
const fieldIDsInDisplayOrder = [
this.state.loginType,
LoginField.Password,
];
// Run all fields with stricter validation that no longer allows empty
// values for required fields.
for (const fieldID of fieldIDsInDisplayOrder) {
const field = this[fieldID];
if (!field) {
continue;
}
// We must wait for these validations to finish before queueing
// up the setState below so our setState goes in the queue after
// all the setStates from these validate calls (that's how we
// know they've finished).
await field.validate({ allowEmpty: false });
}
// Validation and state updates are async, so we need to wait for them to complete
// first. Queue a `setState` callback and wait for it to resolve.
await new Promise(resolve => this.setState({}, resolve));
if (this.allFieldsValid()) {
return true;
}
const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder);
if (!invalidField) {
return true;
}
// Focus the first invalid field and show feedback in the stricter mode
// that no longer allows empty values for required fields.
invalidField.focus();
invalidField.validate({ allowEmpty: false, focused: true });
return false;
}
private allFieldsValid() {
const keys = Object.keys(this.state.fieldValid);
for (let i = 0; i < keys.length; ++i) {
if (!this.state.fieldValid[keys[i]]) {
return false;
}
}
return true;
}
private findFirstInvalidField(fieldIDs: LoginField[]) {
for (const fieldID of fieldIDs) {
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
return this[fieldID];
}
}
return null;
}
private markFieldValid(fieldID: LoginField, valid: boolean) {
const { fieldValid } = this.state;
fieldValid[fieldID] = valid;
this.setState({
fieldValid,
});
}
private validateUsernameRules = withValidation({
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => _t("Enter username"),
},
],
});
private onUsernameValidate = async (fieldState) => {
const result = await this.validateUsernameRules(fieldState);
this.markFieldValid(LoginField.MatrixId, result.valid);
return result;
};
private validateEmailRules = withValidation({
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => _t("Enter email address"),
}, {
key: "email",
test: ({ value }) => !value || Email.looksValid(value),
invalid: () => _t("Doesn't look like a valid email address"),
},
],
});
private onEmailValidate = async (fieldState) => {
const result = await this.validateEmailRules(fieldState);
this.markFieldValid(LoginField.Email, result.valid);
return result;
};
private validatePhoneNumberRules = withValidation({
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => _t("Enter phone number"),
}, {
key: "number",
test: ({ value }) => !value || PHONE_NUMBER_REGEX.test(value),
invalid: () => _t("Doesn't look like a valid phone number"),
},
],
});
private onPhoneNumberValidate = async (fieldState) => {
const result = await this.validatePhoneNumberRules(fieldState);
this.markFieldValid(LoginField.Password, result.valid);
return result;
};
private validatePasswordRules = withValidation({
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => _t("Enter password"),
},
],
});
private onPasswordValidate = async (fieldState) => {
const result = await this.validatePasswordRules(fieldState);
this.markFieldValid(LoginField.Password, result.valid);
return result;
}
private renderLoginField(loginType: IState["loginType"], autoFocus: boolean) {
const classes = {
error: false,
};
switch (loginType) {
case LoginField.Email:
classes.error = this.props.loginIncorrect && !this.props.username;
return <Field
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
key="email_input"
type="text"
label={_t("Email")}
placeholder="joe@example.com"
value={this.props.username}
onChange={this.onUsernameChanged}
onFocus={this.onUsernameFocus}
onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
onValidate={this.onEmailValidate}
ref={field => this[LoginField.Email] = field}
/>;
case LoginField.MatrixId:
classes.error = this.props.loginIncorrect && !this.props.username;
return <Field
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
key="username_input"
type="text"
label={_t("Username")}
value={this.props.username}
onChange={this.onUsernameChanged}
onFocus={this.onUsernameFocus}
onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
onValidate={this.onUsernameValidate}
ref={field => this[LoginField.MatrixId] = field}
/>;
case LoginField.Phone: {
classes.error = this.props.loginIncorrect && !this.props.phoneNumber;
const phoneCountry = <CountryDropdown
value={this.props.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChanged}
/>;
return <Field
className={classNames(classes)}
name="phoneNumber"
key="phone_input"
type="text"
label={_t("Phone")}
value={this.props.phoneNumber}
prefixComponent={phoneCountry}
onChange={this.onPhoneNumberChanged}
onFocus={this.onPhoneNumberFocus}
onBlur={this.onPhoneNumberBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
onValidate={this.onPhoneNumberValidate}
ref={field => this[LoginField.Password] = field}
/>;
}
}
}
private isLoginEmpty() {
switch (this.state.loginType) {
case LoginField.Email:
case LoginField.MatrixId:
return !this.props.username;
case LoginField.Phone:
return !this.props.phoneCountry || !this.props.phoneNumber;
}
}
render() {
let forgotPasswordJsx;
if (this.props.onForgotPasswordClick) {
forgotPasswordJsx = <span>
{_t('Not sure of your password? <a>Set a new one</a>', {}, {
a: sub => (
<AccessibleButton
className="mx_Login_forgot"
disabled={this.props.busy}
kind="link"
onClick={this.onForgotPasswordClick}
>
{sub}
</AccessibleButton>
),
})}
</span>;
}
const pwFieldClass = classNames({
error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field
});
// If login is empty, autoFocus login, otherwise autoFocus password.
// this is for when auto server discovery remounts us when the user tries to tab from username to password
const autoFocusPassword = !this.isLoginEmpty();
const loginField = this.renderLoginField(this.state.loginType, !autoFocusPassword);
let loginType;
if (!SdkConfig.get().disable_3pid_login) {
loginType = (
<div className="mx_Login_type_container">
<label className="mx_Login_type_label">{ _t('Sign in with') }</label>
<Field
element="select"
value={this.state.loginType}
onChange={this.onLoginTypeChange}
disabled={this.props.disableSubmit}
>
<option key={LoginField.MatrixId} value={LoginField.MatrixId}>
{_t('Username')}
</option>
<option
key={LoginField.Email}
value={LoginField.Email}
>
{_t('Email address')}
</option>
<option key={LoginField.Password} value={LoginField.Password}>
{_t('Phone')}
</option>
</Field>
</div>
);
}
return (
<div>
<SignInToText serverConfig={this.props.serverConfig}
onEditServerDetailsClick={this.props.onEditServerDetailsClick} />
<form onSubmit={this.onSubmitForm}>
{loginType}
{loginField}
<Field
className={pwFieldClass}
type="password"
name="password"
label={_t('Password')}
value={this.state.password}
onChange={this.onPasswordChanged}
disabled={this.props.disableSubmit}
autoFocus={autoFocusPassword}
onValidate={this.onPasswordValidate}
ref={field => this[LoginField.Password] = field}
/>
{forgotPasswordJsx}
{ !this.props.busy && <input className="mx_Login_submit"
type="submit"
value={_t('Sign in')}
disabled={this.props.disableSubmit}
/> }
</form>
</div>
);
}
}

View file

@ -1,8 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2015, 2016, 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -18,7 +16,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import * as Email from '../../../email'; import * as Email from '../../../email';
import { looksValid as phoneNumberLooksValid } from '../../../phonenumber'; import { looksValid as phoneNumberLooksValid } from '../../../phonenumber';
@ -31,32 +29,57 @@ import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import PassphraseField from "./PassphraseField"; import PassphraseField from "./PassphraseField";
import CountlyAnalytics from "../../../CountlyAnalytics"; import CountlyAnalytics from "../../../CountlyAnalytics";
const FIELD_EMAIL = 'field_email'; enum RegistrationField {
const FIELD_PHONE_NUMBER = 'field_phone_number'; Email = "field_email",
const FIELD_USERNAME = 'field_username'; PhoneNumber = "field_phone_number",
const FIELD_PASSWORD = 'field_password'; Username = "field_username",
const FIELD_PASSWORD_CONFIRM = 'field_password_confirm'; Password = "field_password",
PasswordConfirm = "field_password_confirm",
}
const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario. const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario.
interface IProps {
// Values pre-filled in the input boxes when the component loads
defaultEmail?: string;
defaultPhoneCountry?: string;
defaultPhoneNumber?: string;
defaultUsername?: string;
defaultPassword?: string;
flows: {
stages: string[];
}[];
serverConfig: ValidatedServerConfig;
canSubmit?: boolean;
serverRequiresIdServer?: boolean;
onRegisterClick(params: {
username: string;
password: string;
email?: string;
phoneCountry?: string;
phoneNumber?: string;
}): Promise<void>;
onEditServerDetailsClick?(): void;
}
interface IState {
// Field error codes by field ID
fieldValid: Partial<Record<RegistrationField, boolean>>;
// The ISO2 country code selected in the phone number entry
phoneCountry: string;
username: string;
email: string;
phoneNumber: string;
password: string;
passwordConfirm: string;
passwordComplexity?: number;
}
/* /*
* A pure UI component which displays a registration form. * A pure UI component which displays a registration form.
*/ */
export default class RegistrationForm extends React.Component { export default class RegistrationForm extends React.PureComponent<IProps, IState> {
static propTypes = {
// Values pre-filled in the input boxes when the component loads
defaultEmail: PropTypes.string,
defaultPhoneCountry: PropTypes.string,
defaultPhoneNumber: PropTypes.string,
defaultUsername: PropTypes.string,
defaultPassword: PropTypes.string,
onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise
flows: PropTypes.arrayOf(PropTypes.object).isRequired,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
canSubmit: PropTypes.bool,
serverRequiresIdServer: PropTypes.bool,
};
static defaultProps = { static defaultProps = {
onValidationChange: console.error, onValidationChange: console.error,
canSubmit: true, canSubmit: true,
@ -66,9 +89,7 @@ export default class RegistrationForm extends React.Component {
super(props); super(props);
this.state = { this.state = {
// Field error codes by field ID
fieldValid: {}, fieldValid: {},
// The ISO2 country code selected in the phone number entry
phoneCountry: this.props.defaultPhoneCountry, phoneCountry: this.props.defaultPhoneCountry,
username: this.props.defaultUsername || "", username: this.props.defaultUsername || "",
email: this.props.defaultEmail || "", email: this.props.defaultEmail || "",
@ -81,7 +102,7 @@ export default class RegistrationForm extends React.Component {
CountlyAnalytics.instance.track("onboarding_registration_begin"); CountlyAnalytics.instance.track("onboarding_registration_begin");
} }
onSubmit = async ev => { private onSubmit = async ev => {
ev.preventDefault(); ev.preventDefault();
if (!this.props.canSubmit) return; if (!this.props.canSubmit) return;
@ -92,7 +113,6 @@ export default class RegistrationForm extends React.Component {
return; return;
} }
const self = this;
if (this.state.email === '') { if (this.state.email === '') {
const haveIs = Boolean(this.props.serverConfig.isUrl); const haveIs = Boolean(this.props.serverConfig.isUrl);
@ -102,14 +122,14 @@ export default class RegistrationForm extends React.Component {
"No identity server is configured so you cannot add an email address in order to " + "No identity server is configured so you cannot add an email address in order to " +
"reset your password in the future.", "reset your password in the future.",
); );
} else if (this._showEmail()) { } else if (this.showEmail()) {
desc = _t( desc = _t(
"If you don't specify an email address, you won't be able to reset your password. " + "If you don't specify an email address, you won't be able to reset your password. " +
"Are you sure?", "Are you sure?",
); );
} else { } else {
// user can't set an e-mail so don't prompt them to // user can't set an e-mail so don't prompt them to
self._doSubmit(ev); this.doSubmit(ev);
return; return;
} }
@ -120,18 +140,18 @@ export default class RegistrationForm extends React.Component {
title: _t("Warning!"), title: _t("Warning!"),
description: desc, description: desc,
button: _t("Continue"), button: _t("Continue"),
onFinished(confirmed) { onFinished: (confirmed) => {
if (confirmed) { if (confirmed) {
self._doSubmit(ev); this.doSubmit(ev);
} }
}, },
}); });
} else { } else {
self._doSubmit(ev); this.doSubmit(ev);
} }
}; };
_doSubmit(ev) { private doSubmit(ev) {
const email = this.state.email.trim(); const email = this.state.email.trim();
CountlyAnalytics.instance.track("onboarding_registration_submit_ok", { CountlyAnalytics.instance.track("onboarding_registration_submit_ok", {
@ -154,20 +174,20 @@ export default class RegistrationForm extends React.Component {
} }
} }
async verifyFieldsBeforeSubmit() { private async verifyFieldsBeforeSubmit() {
// Blur the active element if any, so we first run its blur validation, // Blur the active element if any, so we first run its blur validation,
// which is less strict than the pass we're about to do below for all fields. // which is less strict than the pass we're about to do below for all fields.
const activeElement = document.activeElement; const activeElement = document.activeElement as HTMLElement;
if (activeElement) { if (activeElement) {
activeElement.blur(); activeElement.blur();
} }
const fieldIDsInDisplayOrder = [ const fieldIDsInDisplayOrder = [
FIELD_USERNAME, RegistrationField.Username,
FIELD_PASSWORD, RegistrationField.Password,
FIELD_PASSWORD_CONFIRM, RegistrationField.PasswordConfirm,
FIELD_EMAIL, RegistrationField.Email,
FIELD_PHONE_NUMBER, RegistrationField.PhoneNumber,
]; ];
// Run all fields with stricter validation that no longer allows empty // Run all fields with stricter validation that no longer allows empty
@ -208,7 +228,7 @@ export default class RegistrationForm extends React.Component {
/** /**
* @returns {boolean} true if all fields were valid last time they were validated. * @returns {boolean} true if all fields were valid last time they were validated.
*/ */
allFieldsValid() { private allFieldsValid() {
const keys = Object.keys(this.state.fieldValid); const keys = Object.keys(this.state.fieldValid);
for (let i = 0; i < keys.length; ++i) { for (let i = 0; i < keys.length; ++i) {
if (!this.state.fieldValid[keys[i]]) { if (!this.state.fieldValid[keys[i]]) {
@ -218,7 +238,7 @@ export default class RegistrationForm extends React.Component {
return true; return true;
} }
findFirstInvalidField(fieldIDs) { private findFirstInvalidField(fieldIDs: RegistrationField[]) {
for (const fieldID of fieldIDs) { for (const fieldID of fieldIDs) {
if (!this.state.fieldValid[fieldID] && this[fieldID]) { if (!this.state.fieldValid[fieldID] && this[fieldID]) {
return this[fieldID]; return this[fieldID];
@ -227,7 +247,7 @@ export default class RegistrationForm extends React.Component {
return null; return null;
} }
markFieldValid(fieldID, valid) { private markFieldValid(fieldID: RegistrationField, valid: boolean) {
const { fieldValid } = this.state; const { fieldValid } = this.state;
fieldValid[fieldID] = valid; fieldValid[fieldID] = valid;
this.setState({ this.setState({
@ -235,26 +255,26 @@ export default class RegistrationForm extends React.Component {
}); });
} }
onEmailChange = ev => { private onEmailChange = ev => {
this.setState({ this.setState({
email: ev.target.value, email: ev.target.value,
}); });
}; };
onEmailValidate = async fieldState => { private onEmailValidate = async fieldState => {
const result = await this.validateEmailRules(fieldState); const result = await this.validateEmailRules(fieldState);
this.markFieldValid(FIELD_EMAIL, result.valid); this.markFieldValid(RegistrationField.Email, result.valid);
return result; return result;
}; };
validateEmailRules = withValidation({ private validateEmailRules = withValidation({
description: () => _t("Use an email address to recover your account"), description: () => _t("Use an email address to recover your account"),
hideDescriptionIfValid: true, hideDescriptionIfValid: true,
rules: [ rules: [
{ {
key: "required", key: "required",
test({ value, allowEmpty }) { test(this: RegistrationForm, { value, allowEmpty }) {
return allowEmpty || !this._authStepIsRequired('m.login.email.identity') || !!value; return allowEmpty || !this.authStepIsRequired('m.login.email.identity') || !!value;
}, },
invalid: () => _t("Enter email address (required on this homeserver)"), invalid: () => _t("Enter email address (required on this homeserver)"),
}, },
@ -266,29 +286,29 @@ export default class RegistrationForm extends React.Component {
], ],
}); });
onPasswordChange = ev => { private onPasswordChange = ev => {
this.setState({ this.setState({
password: ev.target.value, password: ev.target.value,
}); });
}; };
onPasswordValidate = result => { private onPasswordValidate = result => {
this.markFieldValid(FIELD_PASSWORD, result.valid); this.markFieldValid(RegistrationField.Password, result.valid);
}; };
onPasswordConfirmChange = ev => { private onPasswordConfirmChange = ev => {
this.setState({ this.setState({
passwordConfirm: ev.target.value, passwordConfirm: ev.target.value,
}); });
}; };
onPasswordConfirmValidate = async fieldState => { private onPasswordConfirmValidate = async fieldState => {
const result = await this.validatePasswordConfirmRules(fieldState); const result = await this.validatePasswordConfirmRules(fieldState);
this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid); this.markFieldValid(RegistrationField.PasswordConfirm, result.valid);
return result; return result;
}; };
validatePasswordConfirmRules = withValidation({ private validatePasswordConfirmRules = withValidation({
rules: [ rules: [
{ {
key: "required", key: "required",
@ -297,7 +317,7 @@ export default class RegistrationForm extends React.Component {
}, },
{ {
key: "match", key: "match",
test({ value }) { test(this: RegistrationForm, { value }) {
return !value || value === this.state.password; return !value || value === this.state.password;
}, },
invalid: () => _t("Passwords don't match"), invalid: () => _t("Passwords don't match"),
@ -305,33 +325,32 @@ export default class RegistrationForm extends React.Component {
], ],
}); });
onPhoneCountryChange = newVal => { private onPhoneCountryChange = newVal => {
this.setState({ this.setState({
phoneCountry: newVal.iso2, phoneCountry: newVal.iso2,
phonePrefix: newVal.prefix,
}); });
}; };
onPhoneNumberChange = ev => { private onPhoneNumberChange = ev => {
this.setState({ this.setState({
phoneNumber: ev.target.value, phoneNumber: ev.target.value,
}); });
}; };
onPhoneNumberValidate = async fieldState => { private onPhoneNumberValidate = async fieldState => {
const result = await this.validatePhoneNumberRules(fieldState); const result = await this.validatePhoneNumberRules(fieldState);
this.markFieldValid(FIELD_PHONE_NUMBER, result.valid); this.markFieldValid(RegistrationField.PhoneNumber, result.valid);
return result; return result;
}; };
validatePhoneNumberRules = withValidation({ private validatePhoneNumberRules = withValidation({
description: () => _t("Other users can invite you to rooms using your contact details"), description: () => _t("Other users can invite you to rooms using your contact details"),
hideDescriptionIfValid: true, hideDescriptionIfValid: true,
rules: [ rules: [
{ {
key: "required", key: "required",
test({ value, allowEmpty }) { test(this: RegistrationForm, { value, allowEmpty }) {
return allowEmpty || !this._authStepIsRequired('m.login.msisdn') || !!value; return allowEmpty || !this.authStepIsRequired('m.login.msisdn') || !!value;
}, },
invalid: () => _t("Enter phone number (required on this homeserver)"), invalid: () => _t("Enter phone number (required on this homeserver)"),
}, },
@ -343,19 +362,19 @@ export default class RegistrationForm extends React.Component {
], ],
}); });
onUsernameChange = ev => { private onUsernameChange = ev => {
this.setState({ this.setState({
username: ev.target.value, username: ev.target.value,
}); });
}; };
onUsernameValidate = async fieldState => { private onUsernameValidate = async fieldState => {
const result = await this.validateUsernameRules(fieldState); const result = await this.validateUsernameRules(fieldState);
this.markFieldValid(FIELD_USERNAME, result.valid); this.markFieldValid(RegistrationField.Username, result.valid);
return result; return result;
}; };
validateUsernameRules = withValidation({ private validateUsernameRules = withValidation({
description: () => _t("Use lowercase letters, numbers, dashes and underscores only"), description: () => _t("Use lowercase letters, numbers, dashes and underscores only"),
hideDescriptionIfValid: true, hideDescriptionIfValid: true,
rules: [ rules: [
@ -378,7 +397,7 @@ export default class RegistrationForm extends React.Component {
* @param {string} step A stage name to check * @param {string} step A stage name to check
* @returns {boolean} Whether it is required * @returns {boolean} Whether it is required
*/ */
_authStepIsRequired(step) { private authStepIsRequired(step: string) {
return this.props.flows.every((flow) => { return this.props.flows.every((flow) => {
return flow.stages.includes(step); return flow.stages.includes(step);
}); });
@ -390,46 +409,46 @@ export default class RegistrationForm extends React.Component {
* @param {string} step A stage name to check * @param {string} step A stage name to check
* @returns {boolean} Whether it is used * @returns {boolean} Whether it is used
*/ */
_authStepIsUsed(step) { private authStepIsUsed(step: string) {
return this.props.flows.some((flow) => { return this.props.flows.some((flow) => {
return flow.stages.includes(step); return flow.stages.includes(step);
}); });
} }
_showEmail() { private showEmail() {
const haveIs = Boolean(this.props.serverConfig.isUrl); const haveIs = Boolean(this.props.serverConfig.isUrl);
if ( if (
(this.props.serverRequiresIdServer && !haveIs) || (this.props.serverRequiresIdServer && !haveIs) ||
!this._authStepIsUsed('m.login.email.identity') !this.authStepIsUsed('m.login.email.identity')
) { ) {
return false; return false;
} }
return true; return true;
} }
_showPhoneNumber() { private showPhoneNumber() {
const threePidLogin = !SdkConfig.get().disable_3pid_login; const threePidLogin = !SdkConfig.get().disable_3pid_login;
const haveIs = Boolean(this.props.serverConfig.isUrl); const haveIs = Boolean(this.props.serverConfig.isUrl);
if ( if (
!threePidLogin || !threePidLogin ||
(this.props.serverRequiresIdServer && !haveIs) || (this.props.serverRequiresIdServer && !haveIs) ||
!this._authStepIsUsed('m.login.msisdn') !this.authStepIsUsed('m.login.msisdn')
) { ) {
return false; return false;
} }
return true; return true;
} }
renderEmail() { private renderEmail() {
if (!this._showEmail()) { if (!this.showEmail()) {
return null; return null;
} }
const Field = sdk.getComponent('elements.Field'); const Field = sdk.getComponent('elements.Field');
const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ? const emailPlaceholder = this.authStepIsRequired('m.login.email.identity') ?
_t("Email") : _t("Email") :
_t("Email (optional)"); _t("Email (optional)");
return <Field return <Field
ref={field => this[FIELD_EMAIL] = field} ref={field => this[RegistrationField.Email] = field}
type="text" type="text"
label={emailPlaceholder} label={emailPlaceholder}
value={this.state.email} value={this.state.email}
@ -440,10 +459,10 @@ export default class RegistrationForm extends React.Component {
/>; />;
} }
renderPassword() { private renderPassword() {
return <PassphraseField return <PassphraseField
id="mx_RegistrationForm_password" id="mx_RegistrationForm_password"
fieldRef={field => this[FIELD_PASSWORD] = field} fieldRef={field => this[RegistrationField.Password] = field}
minScore={PASSWORD_MIN_SCORE} minScore={PASSWORD_MIN_SCORE}
value={this.state.password} value={this.state.password}
onChange={this.onPasswordChange} onChange={this.onPasswordChange}
@ -457,7 +476,7 @@ export default class RegistrationForm extends React.Component {
const Field = sdk.getComponent('elements.Field'); const Field = sdk.getComponent('elements.Field');
return <Field return <Field
id="mx_RegistrationForm_passwordConfirm" id="mx_RegistrationForm_passwordConfirm"
ref={field => this[FIELD_PASSWORD_CONFIRM] = field} ref={field => this[RegistrationField.PasswordConfirm] = field}
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
label={_t("Confirm password")} label={_t("Confirm password")}
@ -470,12 +489,12 @@ export default class RegistrationForm extends React.Component {
} }
renderPhoneNumber() { renderPhoneNumber() {
if (!this._showPhoneNumber()) { if (!this.showPhoneNumber()) {
return null; return null;
} }
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown'); const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
const Field = sdk.getComponent('elements.Field'); const Field = sdk.getComponent('elements.Field');
const phoneLabel = this._authStepIsRequired('m.login.msisdn') ? const phoneLabel = this.authStepIsRequired('m.login.msisdn') ?
_t("Phone") : _t("Phone") :
_t("Phone (optional)"); _t("Phone (optional)");
const phoneCountry = <CountryDropdown const phoneCountry = <CountryDropdown
@ -485,7 +504,7 @@ export default class RegistrationForm extends React.Component {
onOptionChange={this.onPhoneCountryChange} onOptionChange={this.onPhoneCountryChange}
/>; />;
return <Field return <Field
ref={field => this[FIELD_PHONE_NUMBER] = field} ref={field => this[RegistrationField.PhoneNumber] = field}
type="text" type="text"
label={phoneLabel} label={phoneLabel}
value={this.state.phoneNumber} value={this.state.phoneNumber}
@ -499,7 +518,7 @@ export default class RegistrationForm extends React.Component {
const Field = sdk.getComponent('elements.Field'); const Field = sdk.getComponent('elements.Field');
return <Field return <Field
id="mx_RegistrationForm_username" id="mx_RegistrationForm_username"
ref={field => this[FIELD_USERNAME] = field} ref={field => this[RegistrationField.Username] = field}
type="text" type="text"
autoFocus={true} autoFocus={true}
label={_t("Username")} label={_t("Username")}
@ -517,8 +536,8 @@ export default class RegistrationForm extends React.Component {
); );
let emailHelperText = null; let emailHelperText = null;
if (this._showEmail()) { if (this.showEmail()) {
if (this._showPhoneNumber()) { if (this.showPhoneNumber()) {
emailHelperText = <div> emailHelperText = <div>
{_t( {_t(
"Set an email for account recovery. " + "Set an email for account recovery. " +

View file

@ -1,28 +0,0 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
interface IProps {
}
const PulsedAvatar: React.FC<IProps> = (props) => {
return <div className="mx_PulsedAvatar">
{props.children}
</div>;
};
export default PulsedAvatar;

View file

@ -38,6 +38,7 @@ import {MatrixClientPeg} from "../../../MatrixClientPeg";
import RoomViewStore from "../../../stores/RoomViewStore"; import RoomViewStore from "../../../stores/RoomViewStore";
import {OwnProfileStore} from "../../../stores/OwnProfileStore"; import {OwnProfileStore} from "../../../stores/OwnProfileStore";
import { arrayFastClone } from "../../../utils/arrays"; import { arrayFastClone } from "../../../utils/arrays";
import { ElementWidget } from "../../../stores/widgets/StopGapWidget";
interface IProps { interface IProps {
widgetDefinition: IModalWidgetOpenRequestData; widgetDefinition: IModalWidgetOpenRequestData;
@ -64,7 +65,7 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
constructor(props) { constructor(props) {
super(props); super(props);
this.widget = new Widget({ this.widget = new ElementWidget({
...this.props.widgetDefinition, ...this.props.widgetDefinition,
creatorUserId: MatrixClientPeg.get().getUserId(), creatorUserId: MatrixClientPeg.get().getUserId(),
id: `modal_${this.props.sourceWidgetId}`, id: `modal_${this.props.sourceWidgetId}`,
@ -161,7 +162,9 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
this.state.messaging.notifyModalWidgetButtonClicked(def.id); this.state.messaging.notifyModalWidgetButtonClicked(def.id);
}; };
return <AccessibleButton key={def.id} kind={kind} onClick={onClick}> const isDisabled = this.state.disabledButtonIds.includes(def.id);
return <AccessibleButton key={def.id} kind={kind} onClick={onClick} disabled={isDisabled}>
{ def.label } { def.label }
</AccessibleButton>; </AccessibleButton>;
}); });

View file

@ -17,18 +17,17 @@ limitations under the License.
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {_t} from "../../../languageHandler"; import {_t} from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import * as sdk from "../../../index"; import * as sdk from "../../../index";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import WidgetUtils from "../../../utils/WidgetUtils"; import {Widget} from "matrix-widget-api";
import {SettingLevel} from "../../../settings/SettingLevel"; import {OIDCState, WidgetPermissionStore} from "../../../stores/widgets/WidgetPermissionStore";
export default class WidgetOpenIDPermissionsDialog extends React.Component { export default class WidgetOpenIDPermissionsDialog extends React.Component {
static propTypes = { static propTypes = {
onFinished: PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
widgetUrl: PropTypes.string.isRequired, widget: PropTypes.objectOf(Widget).isRequired,
widgetId: PropTypes.string.isRequired, widgetKind: PropTypes.string.isRequired, // WidgetKind from widget-api
isUserWidget: PropTypes.bool.isRequired, inRoomId: PropTypes.string,
}; };
constructor() { constructor() {
@ -51,16 +50,10 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component {
if (this.state.rememberSelection) { if (this.state.rememberSelection) {
console.log(`Remembering ${this.props.widgetId} as allowed=${allowed} for OpenID`); console.log(`Remembering ${this.props.widgetId} as allowed=${allowed} for OpenID`);
const currentValues = SettingsStore.getValue("widgetOpenIDPermissions"); WidgetPermissionStore.instance.setOIDCState(
if (!currentValues.allow) currentValues.allow = []; this.props.widget, this.props.widgetKind, this.props.inRoomId,
if (!currentValues.deny) currentValues.deny = []; allowed ? OIDCState.Allowed : OIDCState.Denied,
);
const securityKey = WidgetUtils.getWidgetSecurityKey(
this.props.widgetId,
this.props.widgetUrl,
this.props.isUserWidget);
(allowed ? currentValues.allow : currentValues.deny).push(securityKey);
SettingsStore.setValue("widgetOpenIDPermissions", null, SettingLevel.DEVICE, currentValues);
} }
this.props.onFinished(allowed); this.props.onFinished(allowed);
@ -84,7 +77,7 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component {
"A widget located at %(widgetUrl)s would like to verify your identity. " + "A widget located at %(widgetUrl)s would like to verify your identity. " +
"By allowing this, the widget will be able to verify your user ID, but not " + "By allowing this, the widget will be able to verify your user ID, but not " +
"perform actions as you.", { "perform actions as you.", {
widgetUrl: this.props.widgetUrl.split("?")[0], widgetUrl: this.props.widget.templateUrl.split("?")[0],
}, },
)} )}
</p> </p>

View file

@ -23,7 +23,6 @@ import PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../MatrixClientPeg';
import AccessibleButton from './AccessibleButton'; import AccessibleButton from './AccessibleButton';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import AppPermission from './AppPermission'; import AppPermission from './AppPermission';
import AppWarning from './AppWarning'; import AppWarning from './AppWarning';
import Spinner from './Spinner'; import Spinner from './Spinner';
@ -375,11 +374,11 @@ export default class AppTile extends React.Component {
/> />
</div> </div>
); );
// if the widget would be allowed to remain on screen, we must put it in
// a PersistedElement from the get-go, otherwise the iframe will be // all widgets can theoretically be allowed to remain on screen, so we wrap
// re-mounted later when we do. // them all in a PersistedElement from the get-go. If we wait, the iframe will
if (this.props.whitelistCapabilities.includes('m.always_on_screen')) { // be re-mounted later, which means the widget has to start over, which is bad.
const PersistedElement = sdk.getComponent("elements.PersistedElement");
// Also wrap the PersistedElement in a div to fix the height, otherwise // Also wrap the PersistedElement in a div to fix the height, otherwise
// AppTile's border is in the wrong place // AppTile's border is in the wrong place
appTileBody = <div className="mx_AppTile_persistedWrapper"> appTileBody = <div className="mx_AppTile_persistedWrapper">
@ -389,7 +388,6 @@ export default class AppTile extends React.Component {
</div>; </div>;
} }
} }
}
let appTileClasses; let appTileClasses;
if (this.props.miniMode) { if (this.props.miniMode) {
@ -474,10 +472,6 @@ AppTile.propTypes = {
handleMinimisePointerEvents: PropTypes.bool, handleMinimisePointerEvents: PropTypes.bool,
// Optionally hide the popout widget icon // Optionally hide the popout widget icon
showPopout: PropTypes.bool, showPopout: PropTypes.bool,
// Widget capabilities to allow by default (without user confirmation)
// NOTE -- Use with caution. This is intended to aid better integration / UX
// basic widget capabilities, e.g. injecting sticker message events.
whitelistCapabilities: PropTypes.array,
// Is this an instance of a user widget // Is this an instance of a user widget
userWidget: PropTypes.bool, userWidget: PropTypes.bool,
}; };
@ -488,7 +482,6 @@ AppTile.defaultProps = {
showTitle: true, showTitle: true,
showPopout: true, showPopout: true,
handleMinimisePointerEvents: false, handleMinimisePointerEvents: false,
whitelistCapabilities: [],
userWidget: false, userWidget: false,
miniMode: false, miniMode: false,
}; };

View file

@ -64,7 +64,7 @@ interface IProps {
// All other props pass through to the <input>. // All other props pass through to the <input>.
} }
interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> { export interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
// The element to create. Defaults to "input". // The element to create. Defaults to "input".
element?: "input"; element?: "input";
// The input's value. This is a controlled component, so the value is required. // The input's value. This is a controlled component, so the value is required.

View file

@ -71,7 +71,6 @@ export default class PersistentApp extends React.Component {
appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(), appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(),
persistentWidgetInRoomId, appEvent.getId(), persistentWidgetInRoomId, appEvent.getId(),
); );
const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, persistentWidgetInRoomId);
const AppTile = sdk.getComponent('elements.AppTile'); const AppTile = sdk.getComponent('elements.AppTile');
return <AppTile return <AppTile
key={app.id} key={app.id}
@ -82,7 +81,6 @@ export default class PersistentApp extends React.Component {
creatorUserId={app.creatorUserId} creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)} widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad} waitForIframeLoad={app.waitForIframeLoad}
whitelistCapabilities={capWhitelist}
miniMode={true} miniMode={true}
showMenubar={false} showMenubar={false}
/>; />;

View file

@ -32,7 +32,7 @@ interface IRule<T, D = void> {
interface IArgs<T, D = void> { interface IArgs<T, D = void> {
rules: IRule<T, D>[]; rules: IRule<T, D>[];
description(this: T, derivedData: D): React.ReactChild; description?(this: T, derivedData: D): React.ReactChild;
hideDescriptionIfValid?: boolean; hideDescriptionIfValid?: boolean;
deriveData?(data: Data): Promise<D>; deriveData?(data: Data): Promise<D>;
} }

View file

@ -103,7 +103,6 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
creatorUserId={app.creatorUserId} creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)} widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad} waitForIframeLoad={app.waitForIframeLoad}
whitelistCapabilities={WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, room.roomId)}
/> />
</BaseCard>; </BaseCard>;
}; };

View file

@ -210,8 +210,6 @@ export default class AppsDrawer extends React.Component {
if (!this.props.showApps) return <div />; if (!this.props.showApps) return <div />;
const apps = this.state.apps.map((app, index, arr) => { const apps = this.state.apps.map((app, index, arr) => {
const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, this.props.room.roomId);
return (<AppTile return (<AppTile
key={app.id} key={app.id}
app={app} app={app}
@ -221,7 +219,6 @@ export default class AppsDrawer extends React.Component {
creatorUserId={app.creatorUserId} creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)} widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad} waitForIframeLoad={app.waitForIframeLoad}
whitelistCapabilities={capWhitelist}
/>); />);
}); });

View file

@ -280,7 +280,6 @@ export default class Stickerpicker extends React.Component {
showPopout={false} showPopout={false}
onMinimiseClick={this._onHideStickersClick} onMinimiseClick={this._onHideStickersClick}
handleMinimisePointerEvents={true} handleMinimisePointerEvents={true}
whitelistCapabilities={['m.sticker', 'visibility']}
userWidget={true} userWidget={true}
/> />
</PersistedElement> </PersistedElement>

View file

@ -26,6 +26,15 @@ import PersistentApp from "../elements/PersistentApp";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import { CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
const SHOW_CALL_IN_STATES = [
CallState.Connected,
CallState.InviteSent,
CallState.Connecting,
CallState.CreateAnswer,
CallState.CreateOffer,
CallState.WaitLocalMedia,
];
interface IProps { interface IProps {
} }
@ -94,14 +103,13 @@ export default class CallPreview extends React.Component<IProps, IState> {
const callForRoom = CallHandler.sharedInstance().getCallForRoom(this.state.roomId); const callForRoom = CallHandler.sharedInstance().getCallForRoom(this.state.roomId);
const showCall = ( const showCall = (
this.state.activeCall && this.state.activeCall &&
this.state.activeCall.state === CallState.Connected && SHOW_CALL_IN_STATES.includes(this.state.activeCall.state) &&
!callForRoom !callForRoom
); );
if (showCall) { if (showCall) {
return ( return (
<CallView <CallView
className="mx_CallPreview"
onClick={this.onCallViewClick} onClick={this.onCallViewClick}
showHangup={true} showHangup={true}
/> />

View file

@ -21,12 +21,13 @@ import dis from '../../../dispatcher/dispatcher';
import CallHandler from '../../../CallHandler'; import CallHandler from '../../../CallHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton';
import VideoFeed, { VideoFeedType } from "./VideoFeed"; import VideoFeed, { VideoFeedType } from "./VideoFeed";
import RoomAvatar from "../avatars/RoomAvatar"; import RoomAvatar from "../avatars/RoomAvatar";
import PulsedAvatar from '../avatars/PulsedAvatar';
import { CallState, CallType, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import { CallState, CallType, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import { CallEvent } from 'matrix-js-sdk/src/webrtc/call'; import { CallEvent } from 'matrix-js-sdk/src/webrtc/call';
import classNames from 'classnames';
import AccessibleButton from '../elements/AccessibleButton';
import {isOnlyCtrlOrCmdKeyEvent, Key} from '../../../Keyboard';
interface IProps { interface IProps {
// js-sdk room object. If set, we will only show calls for the given // js-sdk room object. If set, we will only show calls for the given
@ -43,9 +44,6 @@ interface IProps {
// in a way that is likely to cause a resize. // in a way that is likely to cause a resize.
onResize?: any; onResize?: any;
// classname applied to view,
className?: string;
// Whether to show the hang up icon:W // Whether to show the hang up icon:W
showHangup?: boolean; showHangup?: boolean;
} }
@ -53,6 +51,10 @@ interface IProps {
interface IState { interface IState {
call: MatrixCall; call: MatrixCall;
isLocalOnHold: boolean, isLocalOnHold: boolean,
micMuted: boolean,
vidMuted: boolean,
callState: CallState,
controlsVisible: boolean,
} }
function getFullScreenElement() { function getFullScreenElement() {
@ -83,10 +85,15 @@ function exitFullscreen() {
if (exitMethod) exitMethod.call(document); if (exitMethod) exitMethod.call(document);
} }
const CONTROLS_HIDE_DELAY = 1000;
// Height of the header duplicated from CSS because we need to subtract it from our max
// height to get the max height of the video
const HEADER_HEIGHT = 44;
export default class CallView extends React.Component<IProps, IState> { export default class CallView extends React.Component<IProps, IState> {
private dispatcherRef: string; private dispatcherRef: string;
private container = createRef<HTMLDivElement>(); private contentRef = createRef<HTMLDivElement>();
private controlsHideTimer: number = null;
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
@ -94,6 +101,10 @@ export default class CallView extends React.Component<IProps, IState> {
this.state = { this.state = {
call, call,
isLocalOnHold: call ? call.isLocalOnHold() : null, isLocalOnHold: call ? call.isLocalOnHold() : null,
micMuted: call ? call.isMicrophoneMuted() : null,
vidMuted: call ? call.isLocalVideoMuted() : null,
callState: call ? call.state : null,
controlsVisible: true,
} }
this.updateCallListeners(null, call); this.updateCallListeners(null, call);
@ -101,9 +112,11 @@ export default class CallView extends React.Component<IProps, IState> {
public componentDidMount() { public componentDidMount() {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
document.addEventListener('keydown', this.onNativeKeyDown);
} }
public componentWillUnmount() { public componentWillUnmount() {
document.removeEventListener("keydown", this.onNativeKeyDown);
this.updateCallListeners(this.state.call, null); this.updateCallListeners(this.state.call, null);
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
} }
@ -111,11 +124,11 @@ export default class CallView extends React.Component<IProps, IState> {
private onAction = (payload) => { private onAction = (payload) => {
switch (payload.action) { switch (payload.action) {
case 'video_fullscreen': { case 'video_fullscreen': {
if (!this.container.current) { if (!this.contentRef.current) {
return; return;
} }
if (payload.fullscreen) { if (payload.fullscreen) {
requestFullscreen(this.container.current); requestFullscreen(this.contentRef.current);
} else if (getFullScreenElement()) { } else if (getFullScreenElement()) {
exitFullscreen(); exitFullscreen();
} }
@ -125,9 +138,21 @@ export default class CallView extends React.Component<IProps, IState> {
const newCall = this.getCall(); const newCall = this.getCall();
if (newCall !== this.state.call) { if (newCall !== this.state.call) {
this.updateCallListeners(this.state.call, newCall); this.updateCallListeners(this.state.call, newCall);
let newControlsVisible = this.state.controlsVisible;
if (newCall && !this.state.call) {
newControlsVisible = true;
if (this.controlsHideTimer !== null) {
clearTimeout(this.controlsHideTimer);
}
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}
this.setState({ this.setState({
call: newCall, call: newCall,
isLocalOnHold: newCall ? newCall.isLocalOnHold() : null, isLocalOnHold: newCall ? newCall.isLocalOnHold() : null,
micMuted: newCall ? newCall.isMicrophoneMuted() : null,
vidMuted: newCall ? newCall.isLocalVideoMuted() : null,
callState: newCall ? newCall.state : null,
controlsVisible: newControlsVisible,
}); });
} }
if (!newCall && getFullScreenElement()) { if (!newCall && getFullScreenElement()) {
@ -144,11 +169,6 @@ export default class CallView extends React.Component<IProps, IState> {
if (this.props.room) { if (this.props.room) {
const roomId = this.props.room.roomId; const roomId = this.props.room.roomId;
call = CallHandler.sharedInstance().getCallForRoom(roomId); call = CallHandler.sharedInstance().getCallForRoom(roomId);
// We don't currently show voice calls in this view when in the room:
// they're represented in the room status bar at the bottom instead
// (but this will all change with the new designs)
if (call && call.type == CallType.Voice) call = null;
} else { } else {
call = CallHandler.sharedInstance().getAnyActiveCall(); call = CallHandler.sharedInstance().getAnyActiveCall();
// Ignore calls if we can't get the room associated with them. // Ignore calls if we can't get the room associated with them.
@ -160,7 +180,7 @@ export default class CallView extends React.Component<IProps, IState> {
} }
} }
if (call && call.state == CallState.Ended) return null; if (call && [CallState.Ended, CallState.Ringing].includes(call.state)) return null;
return call; return call;
} }
@ -177,67 +197,240 @@ export default class CallView extends React.Component<IProps, IState> {
}); });
}; };
public render() { private onFullscreenClick = () => {
let view: React.ReactNode; dis.dispatch({
action: 'video_fullscreen',
fullscreen: true,
});
};
private onExpandClick = () => {
dis.dispatch({
action: 'view_room',
room_id: this.state.call.roomId,
});
};
private onControlsHideTimer = () => {
this.controlsHideTimer = null;
this.setState({
controlsVisible: false,
});
}
private onMouseMove = () => {
this.showControls();
}
private showControls() {
if (!this.state.controlsVisible) {
this.setState({
controlsVisible: true,
});
}
if (this.controlsHideTimer !== null) {
clearTimeout(this.controlsHideTimer);
}
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}
private onMicMuteClick = () => {
if (!this.state.call) return;
const newVal = !this.state.micMuted;
this.state.call.setMicrophoneMuted(newVal);
this.setState({micMuted: newVal});
}
private onVidMuteClick = () => {
if (!this.state.call) return;
const newVal = !this.state.vidMuted;
this.state.call.setLocalVideoMuted(newVal);
this.setState({vidMuted: newVal});
}
// we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire
// Note that this assumes we always have a callview on screen at any given time
// CallHandler would probably be a better place for this
private onNativeKeyDown = ev => {
let handled = false;
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
switch (ev.key) {
case Key.D:
if (ctrlCmdOnly) {
this.onMicMuteClick();
// show the controls to give feedback
this.showControls();
handled = true;
}
break;
case Key.E:
if (ctrlCmdOnly) {
this.onVidMuteClick();
// show the controls to give feedback
this.showControls();
handled = true;
}
break;
}
if (handled) {
ev.stopPropagation();
ev.preventDefault();
}
};
private onRoomAvatarClick = () => {
dis.dispatch({
action: 'view_room',
room_id: this.state.call.roomId,
});
}
public render() {
if (!this.state.call) return null;
if (this.state.call) {
if (this.state.call.type === "voice") {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const callRoom = client.getRoom(this.state.call.roomId); const callRoom = client.getRoom(this.state.call.roomId);
let caption = _t("Active call"); let callControls;
if (this.state.isLocalOnHold) { if (this.props.room) {
// we currently have no UI for holding / unholding a call (apart from slash const micClasses = classNames({
// commands) so we don't disintguish between when we've put the call on hold mx_CallView_callControls_button: true,
// (ie. we'd show an unhold button) and when the other side has put us on hold mx_CallView_callControls_button_micOn: !this.state.micMuted,
// (where obviously we would not show such a button). mx_CallView_callControls_button_micOff: this.state.micMuted,
caption = _t("Call Paused"); });
}
view = <AccessibleButton className="mx_CallView_voice" onClick={this.props.onClick}> const vidClasses = classNames({
<PulsedAvatar> mx_CallView_callControls_button: true,
<RoomAvatar mx_CallView_callControls_button_vidOn: !this.state.vidMuted,
room={callRoom} mx_CallView_callControls_button_vidOff: this.state.vidMuted,
height={35} });
width={35}
// Put the other states of the mic/video icons in the document to make sure they're cached
// (otherwise the icon disappears briefly when toggled)
const micCacheClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_micOn: this.state.micMuted,
mx_CallView_callControls_button_micOff: !this.state.micMuted,
mx_CallView_callControls_button_invisible: true,
});
const vidCacheClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_vidOn: this.state.micMuted,
mx_CallView_callControls_button_vidOff: !this.state.micMuted,
mx_CallView_callControls_button_invisible: true,
});
const callControlsClasses = classNames({
mx_CallView_callControls: true,
mx_CallView_callControls_hidden: !this.state.controlsVisible,
});
const vidMuteButton = this.state.call.type === CallType.Video ? <div
className={vidClasses}
onClick={this.onVidMuteClick}
/> : null;
callControls = <div className={callControlsClasses}>
<div
className={micClasses}
onClick={this.onMicMuteClick}
/> />
</PulsedAvatar> <div
<div> className="mx_CallView_callControls_button mx_CallView_callControls_button_hangup"
<h1>{callRoom.name}</h1>
<p>{ caption }</p>
</div>
</AccessibleButton>;
} else {
// For video calls, we currently ignore the call hold state altogether
// (the video will just go black)
// if we're fullscreen, we don't want to set a maxHeight on the video element.
const maxVideoHeight = getFullScreenElement() ? null : this.props.maxVideoHeight;
view = <div className="mx_CallView_video" onClick={this.props.onClick}>
<VideoFeed type={VideoFeedType.Remote} call={this.state.call} onResize={this.props.onResize}
maxHeight={maxVideoHeight}
/>
<VideoFeed type={VideoFeedType.Local} call={this.state.call} />
</div>;
}
}
let hangup: React.ReactNode;
if (this.props.showHangup) {
hangup = <div
className="mx_CallView_hangup"
onClick={() => { onClick={() => {
dis.dispatch({ dis.dispatch({
action: 'hangup', action: 'hangup',
room_id: this.state.call.roomId, room_id: this.state.call.roomId,
}); });
}} }}
/>
{vidMuteButton}
<div className={micCacheClasses} />
<div className={vidCacheClasses} />
</div>;
}
// The 'content' for the call, ie. the videos for a video call and profile picture
// for voice calls (fills the bg)
let contentView: React.ReactNode;
if (this.state.call.type === CallType.Video) {
// if we're fullscreen, we don't want to set a maxHeight on the video element.
const maxVideoHeight = getFullScreenElement() ? null : this.props.maxVideoHeight - HEADER_HEIGHT;
contentView = <div className="mx_CallView_video" ref={this.contentRef} onMouseMove={this.onMouseMove}>
<VideoFeed type={VideoFeedType.Remote} call={this.state.call} onResize={this.props.onResize}
maxHeight={maxVideoHeight}
/>
<VideoFeed type={VideoFeedType.Local} call={this.state.call} />
{callControls}
</div>;
} else {
const avatarSize = this.props.room ? 200 : 75;
contentView = <div className="mx_CallView_voice" onMouseMove={this.onMouseMove}>
<RoomAvatar
room={callRoom}
height={avatarSize}
width={avatarSize}
/>
{callControls}
</div>;
}
const callTypeText = this.state.call.type === CallType.Video ? _t("Video Call") : _t("Voice Call");
let myClassName;
let fullScreenButton;
if (this.state.call.type === CallType.Video && this.props.room) {
fullScreenButton = <div className="mx_CallView_header_button mx_CallView_header_button_fullscreen"
onClick={this.onFullscreenClick} title={_t("Fill Screen")}
/>; />;
} }
return <div className={this.props.className} ref={this.container}> let expandButton;
{view} if (!this.props.room) {
{hangup} expandButton = <div className="mx_CallView_header_button mx_CallView_header_button_expand"
onClick={this.onExpandClick} title={_t("Return to call")}
/>;
}
const headerControls = <div className="mx_CallView_header_controls">
{fullScreenButton}
{expandButton}
</div>;
let header: React.ReactNode;
if (this.props.room) {
header = <div className="mx_CallView_header">
<div className="mx_CallView_header_phoneIcon"></div>
<span className="mx_CallView_header_callType">{callTypeText}</span>
{headerControls}
</div>;
myClassName = 'mx_CallView_large';
} else {
header = <div className="mx_CallView_header">
<AccessibleButton onClick={this.onRoomAvatarClick}>
<RoomAvatar room={callRoom} height={32} width={32} />
</AccessibleButton>
<div>
<div className="mx_CallView_header_roomName">{callRoom.name}</div>
<div className="mx_CallView_header_callTypeSmall">{callTypeText}</div>
</div>
{headerControls}
</div>;
myClassName = 'mx_CallView_pip';
}
return <div className={"mx_CallView " + myClassName}>
{header}
{contentView}
</div>; </div>;
} }
} }

View file

@ -22,7 +22,6 @@ import dis from '../../../dispatcher/dispatcher';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { ActionPayload } from '../../../dispatcher/payloads'; import { ActionPayload } from '../../../dispatcher/payloads';
import CallHandler from '../../../CallHandler'; import CallHandler from '../../../CallHandler';
import PulsedAvatar from '../avatars/PulsedAvatar';
import RoomAvatar from '../avatars/RoomAvatar'; import RoomAvatar from '../avatars/RoomAvatar';
import FormButton from '../elements/FormButton'; import FormButton from '../elements/FormButton';
import { CallState } from 'matrix-js-sdk/lib/webrtc/call'; import { CallState } from 'matrix-js-sdk/lib/webrtc/call';
@ -108,13 +107,11 @@ export default class IncomingCallBox extends React.Component<IProps, IState> {
return <div className="mx_IncomingCallBox"> return <div className="mx_IncomingCallBox">
<div className="mx_IncomingCallBox_CallerInfo"> <div className="mx_IncomingCallBox_CallerInfo">
<PulsedAvatar>
<RoomAvatar <RoomAvatar
room={room} room={room}
height={32} height={32}
width={32} width={32}
/> />
</PulsedAvatar>
<div> <div>
<h1>{caller}</h1> <h1>{caller}</h1>
<p>{incomingCallText}</p> <p>{incomingCallText}</p>

View file

@ -73,8 +73,6 @@ export default class VideoFeed extends React.Component<IProps> {
let videoStyle = {}; let videoStyle = {};
if (this.props.maxHeight) videoStyle = { maxHeight: this.props.maxHeight }; if (this.props.maxHeight) videoStyle = { maxHeight: this.props.maxHeight };
return <div className={classnames(videoClasses)}> return <video className={classnames(videoClasses)} ref={this.vid} style={videoStyle} />;
<video ref={this.vid} style={videoStyle}></video>
</div>;
} }
} }

View file

@ -0,0 +1,30 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
function onLoggedOutAndStorageCleared(): void {
// E.g. redirect user or call other APIs after logout
}
// This interface summarises all available customisation points and also marks
// them all as optional. This allows customisers to only define and export the
// customisations they need while still maintaining type safety.
export interface ILifecycleCustomisations {
onLoggedOutAndStorageCleared?: typeof onLoggedOutAndStorageCleared;
}
// A real customisation module will define and export one or more of the
// customisation points that make up `ILifecycleCustomisations`.
export default {} as ILifecycleCustomisations;

View file

@ -67,24 +67,13 @@ function setupEncryptionNeeded(kind: SetupEncryptionKind): boolean {
// them all as optional. This allows customisers to only define and export the // them all as optional. This allows customisers to only define and export the
// customisations they need while still maintaining type safety. // customisations they need while still maintaining type safety.
export interface ISecurityCustomisations { export interface ISecurityCustomisations {
examineLoginResponse?: ( examineLoginResponse?: typeof examineLoginResponse;
response: any, persistCredentials?: typeof persistCredentials;
credentials: IMatrixClientCreds, createSecretStorageKey?: typeof createSecretStorageKey,
) => void; getSecretStorageKey?: typeof getSecretStorageKey,
persistCredentials?: ( catchAccessSecretStorageError?: typeof catchAccessSecretStorageError,
credentials: IMatrixClientCreds, setupEncryptionNeeded?: typeof setupEncryptionNeeded,
) => void; getDehydrationKey?: typeof getDehydrationKey,
createSecretStorageKey?: () => Uint8Array,
getSecretStorageKey?: () => Uint8Array,
catchAccessSecretStorageError?: (
e: Error,
) => void,
setupEncryptionNeeded?: (
kind: SetupEncryptionKind,
) => boolean,
getDehydrationKey?: (
keyInfo: ISecretStorageKeyInfo,
) => Promise<Uint8Array>,
} }
// A real customisation module will define and export one or more of the // A real customisation module will define and export one or more of the

View file

@ -0,0 +1,48 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Populate this class with the details of your customisations when copying it.
import { Capability, Widget } from "matrix-widget-api";
/**
* Approves the widget for capabilities that it requested, if any can be
* approved. Typically this will be used to give certain widgets capabilities
* without having to prompt the user to approve them. This cannot reject
* capabilities that Element will be automatically granting, such as the
* ability for Jitsi widgets to stay on screen - those will be approved
* regardless.
* @param {Widget} widget The widget to approve capabilities for.
* @param {Set<Capability>} requestedCapabilities The capabilities the widget requested.
* @returns {Set<Capability>} Resolves to the capabilities that are approved for use
* by the widget. If none are approved, this should return an empty Set.
*/
async function preapproveCapabilities(
widget: Widget,
requestedCapabilities: Set<Capability>,
): Promise<Set<Capability>> {
return new Set(); // no additional capabilities approved
}
// This interface summarises all available customisation points and also marks
// them all as optional. This allows customisers to only define and export the
// customisations they need while still maintaining type safety.
export interface IWidgetPermissionCustomisations {
preapproveCapabilities?: typeof preapproveCapabilities;
}
// A real customisation module will define and export one or more of the
// customisation points that make up the interface above.
export const WidgetPermissionCustomisations: IWidgetPermissionCustomisations = {};

View file

@ -21,6 +21,7 @@ import { walkDOMDepthFirst } from "./dom";
import { checkBlockNode } from "../HtmlUtils"; import { checkBlockNode } from "../HtmlUtils";
import { getPrimaryPermalinkEntity } from "../utils/permalinks/Permalinks"; import { getPrimaryPermalinkEntity } from "../utils/permalinks/Permalinks";
import { PartCreator } from "./parts"; import { PartCreator } from "./parts";
import SdkConfig from "../SdkConfig";
function parseAtRoomMentions(text: string, partCreator: PartCreator) { function parseAtRoomMentions(text: string, partCreator: PartCreator) {
const ATROOM = "@room"; const ATROOM = "@room";
@ -130,6 +131,23 @@ function parseElement(n: HTMLElement, partCreator: PartCreator, lastNode: HTMLEl
} }
break; break;
} }
case "DIV":
case "SPAN": {
// math nodes are translated back into delimited latex strings
if (n.hasAttribute("data-mx-maths")) {
const delimLeft = (n.nodeName == "SPAN") ?
(SdkConfig.get()['latex_maths_delims'] || {})['inline_left'] || "$" :
(SdkConfig.get()['latex_maths_delims'] || {})['display_left'] || "$$";
const delimRight = (n.nodeName == "SPAN") ?
(SdkConfig.get()['latex_maths_delims'] || {})['inline_right'] || "$" :
(SdkConfig.get()['latex_maths_delims'] || {})['display_right'] || "$$";
const tex = n.getAttribute("data-mx-maths");
return partCreator.plain(delimLeft + tex + delimRight);
} else if (!checkDescendInto(n)) {
return partCreator.plain(n.textContent);
}
break;
}
case "OL": case "OL":
state.listIndex.push((<HTMLOListElement>n).start || 1); state.listIndex.push((<HTMLOListElement>n).start || 1);
/* falls through */ /* falls through */

View file

@ -18,6 +18,10 @@ limitations under the License.
import Markdown from '../Markdown'; import Markdown from '../Markdown';
import {makeGenericPermalink} from "../utils/permalinks/Permalinks"; import {makeGenericPermalink} from "../utils/permalinks/Permalinks";
import EditorModel from "./model"; import EditorModel from "./model";
import { AllHtmlEntities } from 'html-entities';
import SettingsStore from '../settings/SettingsStore';
import SdkConfig from '../SdkConfig';
import cheerio from 'cheerio';
export function mdSerialize(model: EditorModel) { export function mdSerialize(model: EditorModel) {
return model.parts.reduce((html, part) => { return model.parts.reduce((html, part) => {
@ -38,10 +42,43 @@ export function mdSerialize(model: EditorModel) {
} }
export function htmlSerializeIfNeeded(model: EditorModel, {forceHTML = false} = {}) { export function htmlSerializeIfNeeded(model: EditorModel, {forceHTML = false} = {}) {
const md = mdSerialize(model); let md = mdSerialize(model);
if (SettingsStore.getValue("feature_latex_maths")) {
const displayPattern = (SdkConfig.get()['latex_maths_delims'] || {})['display_pattern'] ||
"\\$\\$(([^$]|\\\\\\$)*)\\$\\$";
const inlinePattern = (SdkConfig.get()['latex_maths_delims'] || {})['inline_pattern'] ||
"\\$(([^$]|\\\\\\$)*)\\$";
md = md.replace(RegExp(displayPattern, "gm"), function(m, p1) {
const p1e = AllHtmlEntities.encode(p1);
return `<div data-mx-maths="${p1e}">\n\n</div>\n\n`;
});
md = md.replace(RegExp(inlinePattern, "gm"), function(m, p1) {
const p1e = AllHtmlEntities.encode(p1);
return `<span data-mx-maths="${p1e}"></span>`;
});
// make sure div tags always start on a new line, otherwise it will confuse
// the markdown parser
md = md.replace(/(.)<div/g, function(m, p1) { return `${p1}\n<div`; });
}
const parser = new Markdown(md); const parser = new Markdown(md);
if (!parser.isPlainText() || forceHTML) { if (!parser.isPlainText() || forceHTML) {
return parser.toHTML(); // feed Markdown output to HTML parser
const phtml = cheerio.load(parser.toHTML(),
{ _useHtmlParser2: true, decodeEntities: false })
// add fallback output for latex math, which should not be interpreted as markdown
phtml('div, span').each(function(i, e) {
const tex = phtml(e).attr('data-mx-maths')
if (tex) {
phtml(e).html(`<code>${tex}</code>`)
}
});
return phtml.html();
} }
// ensure removal of escape backslashes in non-Markdown messages // ensure removal of escape backslashes in non-Markdown messages
if (md.indexOf("\\") > -1) { if (md.indexOf("\\") > -1) {

View file

@ -755,6 +755,7 @@
"%(senderName)s: %(reaction)s": "%(senderName)s: %(reaction)s", "%(senderName)s: %(reaction)s": "%(senderName)s: %(reaction)s",
"%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s", "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s",
"Change notification settings": "Change notification settings", "Change notification settings": "Change notification settings",
"Render LaTeX maths in messages": "Render LaTeX maths in messages",
"Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.", "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.",
"New spinner design": "New spinner design", "New spinner design": "New spinner design",
"Message Pinning": "Message Pinning", "Message Pinning": "Message Pinning",
@ -836,8 +837,10 @@
"When rooms are upgraded": "When rooms are upgraded", "When rooms are upgraded": "When rooms are upgraded",
"My Ban List": "My Ban List", "My Ban List": "My Ban List",
"This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!", "This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!",
"Active call": "Active call", "Video Call": "Video Call",
"Call Paused": "Call Paused", "Voice Call": "Voice Call",
"Fill Screen": "Fill Screen",
"Return to call": "Return to call",
"Unknown caller": "Unknown caller", "Unknown caller": "Unknown caller",
"Incoming voice call": "Incoming voice call", "Incoming voice call": "Incoming voice call",
"Incoming video call": "Incoming video call", "Incoming video call": "Incoming video call",
@ -2286,10 +2289,11 @@
"Nice, strong password!": "Nice, strong password!", "Nice, strong password!": "Nice, strong password!",
"Password is allowed, but unsafe": "Password is allowed, but unsafe", "Password is allowed, but unsafe": "Password is allowed, but unsafe",
"Keep going...": "Keep going...", "Keep going...": "Keep going...",
"The email field must not be blank.": "The email field must not be blank.", "Enter username": "Enter username",
"The username field must not be blank.": "The username field must not be blank.", "Enter email address": "Enter email address",
"The phone number field must not be blank.": "The phone number field must not be blank.", "Doesn't look like a valid email address": "Doesn't look like a valid email address",
"The password field must not be blank.": "The password field must not be blank.", "Enter phone number": "Enter phone number",
"Doesn't look like a valid phone number": "Doesn't look like a valid phone number",
"Email": "Email", "Email": "Email",
"Username": "Username", "Username": "Username",
"Phone": "Phone", "Phone": "Phone",
@ -2300,12 +2304,9 @@
"If you don't specify an email address, you won't be able to reset your password. Are you sure?": "If you don't specify an email address, you won't be able to reset your password. Are you sure?", "If you don't specify an email address, you won't be able to reset your password. Are you sure?": "If you don't specify an email address, you won't be able to reset your password. Are you sure?",
"Use an email address to recover your account": "Use an email address to recover your account", "Use an email address to recover your account": "Use an email address to recover your account",
"Enter email address (required on this homeserver)": "Enter email address (required on this homeserver)", "Enter email address (required on this homeserver)": "Enter email address (required on this homeserver)",
"Doesn't look like a valid email address": "Doesn't look like a valid email address",
"Other users can invite you to rooms using your contact details": "Other users can invite you to rooms using your contact details", "Other users can invite you to rooms using your contact details": "Other users can invite you to rooms using your contact details",
"Enter phone number (required on this homeserver)": "Enter phone number (required on this homeserver)", "Enter phone number (required on this homeserver)": "Enter phone number (required on this homeserver)",
"Doesn't look like a valid phone number": "Doesn't look like a valid phone number",
"Use lowercase letters, numbers, dashes and underscores only": "Use lowercase letters, numbers, dashes and underscores only", "Use lowercase letters, numbers, dashes and underscores only": "Use lowercase letters, numbers, dashes and underscores only",
"Enter username": "Enter username",
"Email (optional)": "Email (optional)", "Email (optional)": "Email (optional)",
"Phone (optional)": "Phone (optional)", "Phone (optional)": "Phone (optional)",
"Register": "Register", "Register": "Register",
@ -2440,10 +2441,6 @@
"%(count)s of your messages have not been sent.|one": "Your message was not sent.", "%(count)s of your messages have not been sent.|one": "Your message was not sent.",
"%(count)s <resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. You can also select individual messages to resend or cancel.|other": "<resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. You can also select individual messages to resend or cancel.", "%(count)s <resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. You can also select individual messages to resend or cancel.|other": "<resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. You can also select individual messages to resend or cancel.",
"%(count)s <resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. You can also select individual messages to resend or cancel.|one": "<resendText>Resend message</resendText> or <cancelText>cancel message</cancelText> now.", "%(count)s <resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. You can also select individual messages to resend or cancel.|one": "<resendText>Resend message</resendText> or <cancelText>cancel message</cancelText> now.",
"Calling...": "Calling...",
"Call connecting...": "Call connecting...",
"Starting camera...": "Starting camera...",
"Starting microphone...": "Starting microphone...",
"Connectivity to the server has been lost.": "Connectivity to the server has been lost.", "Connectivity to the server has been lost.": "Connectivity to the server has been lost.",
"Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.", "Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.",
"You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?", "You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?",
@ -2455,11 +2452,6 @@
"Failed to reject invite": "Failed to reject invite", "Failed to reject invite": "Failed to reject invite",
"You have %(count)s unread notifications in a prior version of this room.|other": "You have %(count)s unread notifications in a prior version of this room.", "You have %(count)s unread notifications in a prior version of this room.|other": "You have %(count)s unread notifications in a prior version of this room.",
"You have %(count)s unread notifications in a prior version of this room.|one": "You have %(count)s unread notification in a prior version of this room.", "You have %(count)s unread notifications in a prior version of this room.|one": "You have %(count)s unread notification in a prior version of this room.",
"Fill screen": "Fill screen",
"Click to unmute video": "Click to unmute video",
"Click to mute video": "Click to mute video",
"Click to unmute audio": "Click to unmute audio",
"Click to mute audio": "Click to mute audio",
"Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.", "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.",
"Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.", "Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.",
"Failed to load timeline position": "Failed to load timeline position", "Failed to load timeline position": "Failed to load timeline position",
@ -2467,6 +2459,8 @@
"Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s", "Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s",
"Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other", "Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other",
"Failed to find the general chat for this community": "Failed to find the general chat for this community", "Failed to find the general chat for this community": "Failed to find the general chat for this community",
"Got an account? <a>Sign in</a>": "Got an account? <a>Sign in</a>",
"New here? <a>Create an account</a>": "New here? <a>Create an account</a>",
"Notification settings": "Notification settings", "Notification settings": "Notification settings",
"Security & privacy": "Security & privacy", "Security & privacy": "Security & privacy",
"All settings": "All settings", "All settings": "All settings",
@ -2512,7 +2506,6 @@
"Incorrect username and/or password.": "Incorrect username and/or password.", "Incorrect username and/or password.": "Incorrect username and/or password.",
"Please note you are logging into the %(hs)s server, not matrix.org.": "Please note you are logging into the %(hs)s server, not matrix.org.", "Please note you are logging into the %(hs)s server, not matrix.org.": "Please note you are logging into the %(hs)s server, not matrix.org.",
"Failed to perform homeserver discovery": "Failed to perform homeserver discovery", "Failed to perform homeserver discovery": "Failed to perform homeserver discovery",
"The phone number entered looks invalid": "The phone number entered looks invalid",
"This homeserver doesn't offer any login flows which are supported by this client.": "This homeserver doesn't offer any login flows which are supported by this client.", "This homeserver doesn't offer any login flows which are supported by this client.": "This homeserver doesn't offer any login flows which are supported by this client.",
"Error: Problem communicating with the given homeserver.": "Error: Problem communicating with the given homeserver.", "Error: Problem communicating with the given homeserver.": "Error: Problem communicating with the given homeserver.",
"Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or <a>enable unsafe scripts</a>.": "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or <a>enable unsafe scripts</a>.", "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or <a>enable unsafe scripts</a>.": "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or <a>enable unsafe scripts</a>.",

View file

@ -117,6 +117,12 @@ export interface ISetting {
} }
export const SETTINGS: {[setting: string]: ISetting} = { export const SETTINGS: {[setting: string]: ISetting} = {
"feature_latex_maths": {
isFeature: true,
displayName: _td("Render LaTeX maths in messages"),
supportedLevels: LEVELS_FEATURE,
default: false,
},
"feature_communities_v2_prototypes": { "feature_communities_v2_prototypes": {
isFeature: true, isFeature: true,
displayName: _td( displayName: _td(

View file

@ -64,7 +64,7 @@ export class ModalWidgetStore extends AsyncStoreWithClient<IState> {
this.openSourceWidgetId = null; this.openSourceWidgetId = null;
this.modalInstance = null; this.modalInstance = null;
}, },
}); }, null, /* priority = */ false, /* static = */ true);
}; };
public closeModalWidget = (sourceWidget: Widget, data?: IModalWidgetReturnData) => { public closeModalWidget = (sourceWidget: Widget, data?: IModalWidgetReturnData) => {

View file

@ -17,8 +17,6 @@
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { import {
ClientWidgetApi, ClientWidgetApi,
IGetOpenIDActionRequest,
IGetOpenIDActionResponseData,
IStickerActionRequest, IStickerActionRequest,
IStickyActionRequest, IStickyActionRequest,
ITemplateParams, ITemplateParams,
@ -27,10 +25,8 @@ import {
IWidgetApiRequestEmptyData, IWidgetApiRequestEmptyData,
IWidgetData, IWidgetData,
MatrixCapabilities, MatrixCapabilities,
OpenIDRequestState,
runTemplate, runTemplate,
Widget, Widget,
WidgetApiToWidgetAction,
WidgetApiFromWidgetAction, WidgetApiFromWidgetAction,
IModalWidgetOpenRequest, IModalWidgetOpenRequest,
IWidgetApiErrorResponseData, IWidgetApiErrorResponseData,
@ -50,8 +46,6 @@ import ActiveWidgetStore from "../ActiveWidgetStore";
import { objectShallowClone } from "../../utils/objects"; import { objectShallowClone } from "../../utils/objects";
import defaultDispatcher from "../../dispatcher/dispatcher"; import defaultDispatcher from "../../dispatcher/dispatcher";
import { ElementWidgetActions, IViewRoomApiRequest } from "./ElementWidgetActions"; import { ElementWidgetActions, IViewRoomApiRequest } from "./ElementWidgetActions";
import Modal from "../../Modal";
import WidgetOpenIDPermissionsDialog from "../../components/views/dialogs/WidgetOpenIDPermissionsDialog";
import {ModalWidgetStore} from "../ModalWidgetStore"; import {ModalWidgetStore} from "../ModalWidgetStore";
import ThemeWatcher from "../../settings/watchers/ThemeWatcher"; import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
import {getCustomTheme} from "../../theme"; import {getCustomTheme} from "../../theme";
@ -74,9 +68,9 @@ interface IAppTileProps {
} }
// TODO: Don't use this because it's wrong // TODO: Don't use this because it's wrong
class ElementWidget extends Widget { export class ElementWidget extends Widget {
constructor(w) { constructor(private rawDefinition: IWidget) {
super(w); super(rawDefinition);
} }
public get templateUrl(): string { public get templateUrl(): string {
@ -137,12 +131,7 @@ class ElementWidget extends Widget {
public getCompleteUrl(params: ITemplateParams, asPopout=false): string { public getCompleteUrl(params: ITemplateParams, asPopout=false): string {
return runTemplate(asPopout ? this.popoutTemplateUrl : this.templateUrl, { return runTemplate(asPopout ? this.popoutTemplateUrl : this.templateUrl, {
// we need to supply a whole widget to the template, but don't have ...this.rawDefinition,
// easy access to the definition the superclass is using, so be sad
// and gutwrench it.
// This isn't a problem when the widget architecture is fixed and this
// subclass gets deleted.
...super['definition'], // XXX: Private member access
data: this.rawData, data: this.rawData,
}, params); }, params);
} }
@ -240,55 +229,6 @@ export class StopGapWidget extends EventEmitter {
return this.messaging.widget.id; return this.messaging.widget.id;
} }
private onOpenIdReq = async (ev: CustomEvent<IGetOpenIDActionRequest>) => {
ev.preventDefault();
const rawUrl = this.appTileProps.app.url;
const widgetSecurityKey = WidgetUtils.getWidgetSecurityKey(this.widgetId, rawUrl, this.appTileProps.userWidget);
const settings = SettingsStore.getValue("widgetOpenIDPermissions");
if (settings.deny && settings.deny.includes(widgetSecurityKey)) {
this.messaging.transport.reply(ev.detail, <IGetOpenIDActionResponseData>{
state: OpenIDRequestState.Blocked,
});
return;
}
if (settings.allow && settings.allow.includes(widgetSecurityKey)) {
const credentials = await MatrixClientPeg.get().getOpenIdToken();
this.messaging.transport.reply(ev.detail, <IGetOpenIDActionResponseData>{
state: OpenIDRequestState.Allowed,
...credentials,
});
return;
}
// Confirm that we received the request
this.messaging.transport.reply(ev.detail, <IGetOpenIDActionResponseData>{
state: OpenIDRequestState.PendingUserConfirmation,
});
// Actually ask for permission to send the user's data
Modal.createTrackedDialog("OpenID widget permissions", '', WidgetOpenIDPermissionsDialog, {
widgetUrl: rawUrl,
widgetId: this.widgetId,
isUserWidget: this.appTileProps.userWidget,
onFinished: async (confirm) => {
const responseBody: IGetOpenIDActionResponseData = {
state: confirm ? OpenIDRequestState.Allowed : OpenIDRequestState.Blocked,
original_request_id: ev.detail.requestId, // eslint-disable-line camelcase
};
if (confirm) {
const credentials = await MatrixClientPeg.get().getOpenIdToken();
Object.assign(responseBody, credentials);
}
this.messaging.transport.send(WidgetApiToWidgetAction.OpenIDCredentials, responseBody).catch(error => {
console.error("Failed to send OpenID credentials: ", error);
});
},
});
};
private onOpenModal = async (ev: CustomEvent<IModalWidgetOpenRequest>) => { private onOpenModal = async (ev: CustomEvent<IModalWidgetOpenRequest>) => {
ev.preventDefault(); ev.preventDefault();
if (ModalWidgetStore.instance.canOpenModalWidget()) { if (ModalWidgetStore.instance.canOpenModalWidget()) {
@ -306,11 +246,10 @@ export class StopGapWidget extends EventEmitter {
public start(iframe: HTMLIFrameElement) { public start(iframe: HTMLIFrameElement) {
if (this.started) return; if (this.started) return;
const allowedCapabilities = this.appTileProps.whitelistCapabilities || []; const allowedCapabilities = this.appTileProps.whitelistCapabilities || [];
const driver = new StopGapWidgetDriver( allowedCapabilities, this.mockWidget, this.kind); const driver = new StopGapWidgetDriver(allowedCapabilities, this.mockWidget, this.kind, this.roomId);
this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver); this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver);
this.messaging.on("preparing", () => this.emit("preparing")); this.messaging.on("preparing", () => this.emit("preparing"));
this.messaging.on("ready", () => this.emit("ready")); this.messaging.on("ready", () => this.emit("ready"));
this.messaging.on(`action:${WidgetApiFromWidgetAction.GetOpenIDCredentials}`, this.onOpenIdReq);
this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal); this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal);
WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.messaging); WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.messaging);
@ -351,18 +290,39 @@ export class StopGapWidget extends EventEmitter {
MatrixClientPeg.get().on('event', this.onEvent); MatrixClientPeg.get().on('event', this.onEvent);
MatrixClientPeg.get().on('Event.decrypted', this.onEventDecrypted); MatrixClientPeg.get().on('Event.decrypted', this.onEventDecrypted);
if (WidgetType.JITSI.matches(this.mockWidget.type)) { this.messaging.on(`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`,
this.messaging.on("action:set_always_on_screen",
(ev: CustomEvent<IStickyActionRequest>) => { (ev: CustomEvent<IStickyActionRequest>) => {
if (this.messaging.hasCapability(MatrixCapabilities.AlwaysOnScreen)) { if (this.messaging.hasCapability(MatrixCapabilities.AlwaysOnScreen)) {
if (WidgetType.JITSI.matches(this.mockWidget.type)) {
CountlyAnalytics.instance.trackJoinCall(this.appTileProps.room.roomId, true, true); CountlyAnalytics.instance.trackJoinCall(this.appTileProps.room.roomId, true, true);
}
ActiveWidgetStore.setWidgetPersistence(this.mockWidget.id, ev.detail.data.value); ActiveWidgetStore.setWidgetPersistence(this.mockWidget.id, ev.detail.data.value);
ev.preventDefault(); ev.preventDefault();
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); // ack this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); // ack
} }
}, },
); );
} else if (WidgetType.STICKERPICKER.matches(this.mockWidget.type)) {
// TODO: Replace this event listener with appropriate driver functionality once the API
// establishes a sane way to send events back and forth.
this.messaging.on(`action:${WidgetApiFromWidgetAction.SendSticker}`,
(ev: CustomEvent<IStickerActionRequest>) => {
if (this.messaging.hasCapability(MatrixCapabilities.StickerSending)) {
// Acknowledge first
ev.preventDefault();
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{});
// Send the sticker
defaultDispatcher.dispatch({
action: 'm.sticker',
data: ev.detail.data,
widgetId: this.mockWidget.id,
});
}
},
);
if (WidgetType.STICKERPICKER.matches(this.mockWidget.type)) {
this.messaging.on(`action:${ElementWidgetActions.OpenIntegrationManager}`, this.messaging.on(`action:${ElementWidgetActions.OpenIntegrationManager}`,
(ev: CustomEvent<IWidgetApiRequest>) => { (ev: CustomEvent<IWidgetApiRequest>) => {
// Acknowledge first // Acknowledge first
@ -394,23 +354,6 @@ export class StopGapWidget extends EventEmitter {
} }
}, },
); );
// TODO: Replace this event listener with appropriate driver functionality once the API
// establishes a sane way to send events back and forth.
this.messaging.on(`action:${WidgetApiFromWidgetAction.SendSticker}`,
(ev: CustomEvent<IStickerActionRequest>) => {
// Acknowledge first
ev.preventDefault();
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{});
// Send the sticker
defaultDispatcher.dispatch({
action: 'm.sticker',
data: ev.detail.data,
widgetId: this.mockWidget.id,
});
},
);
} }
} }

View file

@ -16,19 +16,30 @@
import { import {
Capability, Capability,
EventDirection,
IOpenIDCredentials,
IOpenIDUpdate,
ISendEventDetails, ISendEventDetails,
MatrixCapabilities, MatrixCapabilities,
OpenIDRequestState,
SimpleObservable,
Widget, Widget,
WidgetDriver, WidgetDriver,
WidgetEventCapability,
WidgetKind, WidgetKind,
} from "matrix-widget-api"; } from "matrix-widget-api";
import { iterableDiff, iterableUnion } from "../../utils/iterables"; import { iterableDiff, iterableUnion } from "../../utils/iterables";
import { MatrixClientPeg } from "../../MatrixClientPeg"; import { MatrixClientPeg } from "../../MatrixClientPeg";
import ActiveRoomObserver from "../../ActiveRoomObserver"; import ActiveRoomObserver from "../../ActiveRoomObserver";
import Modal from "../../Modal"; import Modal from "../../Modal";
import WidgetOpenIDPermissionsDialog from "../../components/views/dialogs/WidgetOpenIDPermissionsDialog";
import WidgetCapabilitiesPromptDialog, { import WidgetCapabilitiesPromptDialog, {
getRememberedCapabilitiesForWidget, getRememberedCapabilitiesForWidget,
} from "../../components/views/dialogs/WidgetCapabilitiesPromptDialog"; } from "../../components/views/dialogs/WidgetCapabilitiesPromptDialog";
import { WidgetPermissionCustomisations } from "../../customisations/WidgetPermissions";
import { OIDCState, WidgetPermissionStore } from "./WidgetPermissionStore";
import { WidgetType } from "../../widgets/WidgetType";
import { EventType } from "matrix-js-sdk/src/@types/event";
// TODO: Purge this from the universe // TODO: Purge this from the universe
@ -36,13 +47,27 @@ export class StopGapWidgetDriver extends WidgetDriver {
private allowedCapabilities: Set<Capability>; private allowedCapabilities: Set<Capability>;
// TODO: Refactor widgetKind into the Widget class // TODO: Refactor widgetKind into the Widget class
constructor(allowedCapabilities: Capability[], private forWidget: Widget, private forWidgetKind: WidgetKind) { constructor(
allowedCapabilities: Capability[],
private forWidget: Widget,
private forWidgetKind: WidgetKind,
private inRoomId?: string,
) {
super(); super();
// Always allow screenshots to be taken because it's a client-induced flow. The widget can't // Always allow screenshots to be taken because it's a client-induced flow. The widget can't
// spew screenshots at us and can't request screenshots of us, so it's up to us to provide the // spew screenshots at us and can't request screenshots of us, so it's up to us to provide the
// button if the widget says it supports screenshots. // button if the widget says it supports screenshots.
this.allowedCapabilities = new Set([...allowedCapabilities, MatrixCapabilities.Screenshots]); this.allowedCapabilities = new Set([...allowedCapabilities, MatrixCapabilities.Screenshots]);
// Grant the permissions that are specific to given widget types
if (WidgetType.JITSI.matches(this.forWidget.type) && forWidgetKind === WidgetKind.Room) {
this.allowedCapabilities.add(MatrixCapabilities.AlwaysOnScreen);
} else if (WidgetType.STICKERPICKER.matches(this.forWidget.type) && forWidgetKind === WidgetKind.Account) {
const stickerSendingCap = WidgetEventCapability.forRoomEvent(EventDirection.Send, EventType.Sticker).raw;
this.allowedCapabilities.add(MatrixCapabilities.StickerSending); // legacy as far as MSC2762 is concerned
this.allowedCapabilities.add(stickerSendingCap);
}
} }
public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> { public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> {
@ -52,7 +77,19 @@ export class StopGapWidgetDriver extends WidgetDriver {
const diff = iterableDiff(requested, this.allowedCapabilities); const diff = iterableDiff(requested, this.allowedCapabilities);
const missing = new Set(diff.removed); // "removed" is "in A (requested) but not in B (allowed)" const missing = new Set(diff.removed); // "removed" is "in A (requested) but not in B (allowed)"
const allowedSoFar = new Set(this.allowedCapabilities); const allowedSoFar = new Set(this.allowedCapabilities);
getRememberedCapabilitiesForWidget(this.forWidget).forEach(cap => allowedSoFar.add(cap)); getRememberedCapabilitiesForWidget(this.forWidget).forEach(cap => {
allowedSoFar.add(cap);
missing.delete(cap);
});
if (WidgetPermissionCustomisations.preapproveCapabilities) {
const approved = await WidgetPermissionCustomisations.preapproveCapabilities(this.forWidget, requested);
if (approved) {
approved.forEach(cap => {
allowedSoFar.add(cap);
missing.delete(cap);
});
}
}
// TODO: Do something when the widget requests new capabilities not yet asked for // TODO: Do something when the widget requests new capabilities not yet asked for
if (missing.size > 0) { if (missing.size > 0) {
try { try {
@ -90,4 +127,37 @@ export class StopGapWidgetDriver extends WidgetDriver {
return {roomId, eventId: r.event_id}; return {roomId, eventId: r.event_id};
} }
public async askOpenID(observer: SimpleObservable<IOpenIDUpdate>) {
const oidcState = WidgetPermissionStore.instance.getOIDCState(
this.forWidget, this.forWidgetKind, this.inRoomId,
);
const getToken = (): Promise<IOpenIDCredentials> => {
return MatrixClientPeg.get().getOpenIdToken();
};
if (oidcState === OIDCState.Denied) {
return observer.update({state: OpenIDRequestState.Blocked});
}
if (oidcState === OIDCState.Allowed) {
return observer.update({state: OpenIDRequestState.Allowed, token: await getToken()});
}
observer.update({state: OpenIDRequestState.PendingUserConfirmation});
Modal.createTrackedDialog("OpenID widget permissions", '', WidgetOpenIDPermissionsDialog, {
widget: this.forWidget,
widgetKind: this.forWidgetKind,
inRoomId: this.inRoomId,
onFinished: async (confirm) => {
if (!confirm) {
return observer.update({state: OpenIDRequestState.Blocked});
}
return observer.update({state: OpenIDRequestState.Allowed, token: await getToken()});
},
});
}
} }

View file

@ -0,0 +1,88 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import SettingsStore from "../../settings/SettingsStore";
import { Widget, WidgetKind } from "matrix-widget-api";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import { SettingLevel } from "../../settings/SettingLevel";
export enum OIDCState {
Allowed, // user has set the remembered value as allowed
Denied, // user has set the remembered value as disallowed
Unknown, // user has not set a remembered value
}
export class WidgetPermissionStore {
private static internalInstance: WidgetPermissionStore;
private constructor() {
}
public static get instance(): WidgetPermissionStore {
if (!WidgetPermissionStore.internalInstance) {
WidgetPermissionStore.internalInstance = new WidgetPermissionStore();
}
return WidgetPermissionStore.internalInstance;
}
// TODO (all functions here): Merge widgetKind with the widget definition
private packSettingKey(widget: Widget, kind: WidgetKind, roomId?: string): string {
let location = roomId;
if (kind !== WidgetKind.Room) {
location = MatrixClientPeg.get().getUserId();
}
if (kind === WidgetKind.Modal) {
location = '*MODAL*-' + location; // to guarantee differentiation from whatever spawned it
}
if (!location) {
throw new Error("Failed to determine a location to check the widget's OIDC state with");
}
return encodeURIComponent(`${location}::${widget.templateUrl}`);
}
public getOIDCState(widget: Widget, kind: WidgetKind, roomId?: string): OIDCState {
const settingsKey = this.packSettingKey(widget, kind, roomId);
const settings = SettingsStore.getValue("widgetOpenIDPermissions");
if (settings?.deny?.includes(settingsKey)) {
return OIDCState.Denied;
}
if (settings?.allow?.includes(settingsKey)) {
return OIDCState.Allowed;
}
return OIDCState.Unknown;
}
public setOIDCState(widget: Widget, kind: WidgetKind, roomId: string, newState: OIDCState) {
const settingsKey = this.packSettingKey(widget, kind, roomId);
const currentValues = SettingsStore.getValue("widgetOpenIDPermissions");
if (!currentValues.allow) currentValues.allow = [];
if (!currentValues.deny) currentValues.deny = [];
if (newState === OIDCState.Allowed) {
currentValues.allow.push(settingsKey);
} else if (newState === OIDCState.Denied) {
currentValues.deny.push(settingsKey);
} else {
currentValues.allow = currentValues.allow.filter(c => c !== settingsKey);
currentValues.deny = currentValues.deny.filter(c => c !== settingsKey);
}
SettingsStore.setValue("widgetOpenIDPermissions", null, SettingLevel.DEVICE, currentValues);
}
}

View file

@ -22,7 +22,6 @@ import SdkConfig from "../SdkConfig";
import dis from '../dispatcher/dispatcher'; import dis from '../dispatcher/dispatcher';
import WidgetEchoStore from '../stores/WidgetEchoStore'; import WidgetEchoStore from '../stores/WidgetEchoStore';
import SettingsStore from "../settings/SettingsStore"; import SettingsStore from "../settings/SettingsStore";
import ActiveWidgetStore from "../stores/ActiveWidgetStore";
import {IntegrationManagers} from "../integrations/IntegrationManagers"; import {IntegrationManagers} from "../integrations/IntegrationManagers";
import {Room} from "matrix-js-sdk/src/models/room"; import {Room} from "matrix-js-sdk/src/models/room";
import {WidgetType} from "../widgets/WidgetType"; import {WidgetType} from "../widgets/WidgetType";
@ -457,27 +456,6 @@ export default class WidgetUtils {
return capWhitelist; return capWhitelist;
} }
static getWidgetSecurityKey(widgetId: string, widgetUrl: string, isUserWidget: boolean): string {
let widgetLocation = ActiveWidgetStore.getRoomId(widgetId);
if (isUserWidget) {
const userWidget = WidgetUtils.getUserWidgetsArray()
.find((w) => w.id === widgetId && w.content && w.content.url === widgetUrl);
if (!userWidget) {
throw new Error("No matching user widget to form security key");
}
widgetLocation = userWidget.sender;
}
if (!widgetLocation) {
throw new Error("Failed to locate where the widget resides");
}
return encodeURIComponent(`${widgetLocation}::${widgetUrl}`);
}
static getLocalJitsiWrapperUrl(opts: {forLocalRender?: boolean, auth?: string} = {}) { static getLocalJitsiWrapperUrl(opts: {forLocalRender?: boolean, auth?: string} = {}) {
// NB. we can't just encodeURIComponent all of these because the $ signs need to be there // NB. we can't just encodeURIComponent all of these because the $ signs need to be there
const queryStringParts = [ const queryStringParts = [

View file

@ -36,6 +36,7 @@ describe("<TextualBody />", () => {
MatrixClientPeg.matrixClient = { MatrixClientPeg.matrixClient = {
getRoom: () => mkStubRoom("room_id"), getRoom: () => mkStubRoom("room_id"),
getAccountData: () => undefined, getAccountData: () => undefined,
isGuest: () => false,
}; };
const ev = mkEvent({ const ev = mkEvent({
@ -59,6 +60,7 @@ describe("<TextualBody />", () => {
MatrixClientPeg.matrixClient = { MatrixClientPeg.matrixClient = {
getRoom: () => mkStubRoom("room_id"), getRoom: () => mkStubRoom("room_id"),
getAccountData: () => undefined, getAccountData: () => undefined,
isGuest: () => false,
}; };
const ev = mkEvent({ const ev = mkEvent({
@ -83,6 +85,7 @@ describe("<TextualBody />", () => {
MatrixClientPeg.matrixClient = { MatrixClientPeg.matrixClient = {
getRoom: () => mkStubRoom("room_id"), getRoom: () => mkStubRoom("room_id"),
getAccountData: () => undefined, getAccountData: () => undefined,
isGuest: () => false,
}; };
}); });
@ -135,6 +138,7 @@ describe("<TextualBody />", () => {
getHomeserverUrl: () => "https://my_server/", getHomeserverUrl: () => "https://my_server/",
on: () => undefined, on: () => undefined,
removeListener: () => undefined, removeListener: () => undefined,
isGuest: () => false,
}; };
}); });

View file

@ -6206,6 +6206,13 @@ jsx-ast-utils@^2.4.1:
array-includes "^3.1.1" array-includes "^3.1.1"
object.assign "^4.1.0" object.assign "^4.1.0"
katex@^0.12.0:
version "0.12.0"
resolved "https://registry.yarnpkg.com/katex/-/katex-0.12.0.tgz#2fb1c665dbd2b043edcf8a1f5c555f46beaa0cb9"
integrity sha512-y+8btoc/CK70XqcHqjxiGWBOeIL8upbS0peTPXTvgrh21n1RiWWcIpSWM+4uXq+IAgNh9YYQWdc7LVDPDAEEAg==
dependencies:
commander "^2.19.0"
kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
version "3.2.2" version "3.2.2"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
@ -6506,8 +6513,8 @@ mathml-tag-names@^2.0.1:
integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": "matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop":
version "9.1.0" version "9.2.0"
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/5ac00e346593f29f324b3af8e322928a6e1c427a" resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/6661bde6088e6e43f31198e8532432e162aef33c"
dependencies: dependencies:
"@babel/runtime" "^7.11.2" "@babel/runtime" "^7.11.2"
another-json "^0.2.0" another-json "^0.2.0"
@ -6532,10 +6539,10 @@ matrix-react-test-utils@^0.2.2:
resolved "https://registry.yarnpkg.com/matrix-react-test-utils/-/matrix-react-test-utils-0.2.2.tgz#c87144d3b910c7edc544a6699d13c7c2bf02f853" resolved "https://registry.yarnpkg.com/matrix-react-test-utils/-/matrix-react-test-utils-0.2.2.tgz#c87144d3b910c7edc544a6699d13c7c2bf02f853"
integrity sha512-49+7gfV6smvBIVbeloql+37IeWMTD+fiywalwCqk8Dnz53zAFjKSltB3rmWHso1uecLtQEcPtCijfhzcLXAxTQ== integrity sha512-49+7gfV6smvBIVbeloql+37IeWMTD+fiywalwCqk8Dnz53zAFjKSltB3rmWHso1uecLtQEcPtCijfhzcLXAxTQ==
matrix-widget-api@^0.1.0-beta.9: matrix-widget-api@^0.1.0-beta.10:
version "0.1.0-beta.9" version "0.1.0-beta.10"
resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.9.tgz#83952132c1610e013acb3e695f923f971ddd5637" resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.10.tgz#2e4d658d90ff3152c5567089b4ddd21fb44ec1dd"
integrity sha512-nXo4iaquSya6hYLXccX8o1K960ckSQ0YXIubRDha+YmB+L09F5a7bUPS5JN2tYANOMzyfFAzWVuFwjHv4+K+rg== integrity sha512-yX2UURjM1zVp7snPiOFcH9+FDBdHfAdt5HEAyDUHGJ7w/F2zOtcK/y0dMlZ1+XhxY7Wv0IBZH0US8X/ioJRX1A==
dependencies: dependencies:
events "^3.2.0" events "^3.2.0"