diff --git a/CHANGELOG.md b/CHANGELOG.md index 13c5ba81ed..5aac4e2974 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.8.0-rc.1...v3.8.0) diff --git a/package.json b/package.json index da0d33a59e..f3b8104663 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.8.0", + "version": "3.9.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -76,10 +76,12 @@ "highlight.js": "^10.1.2", "html-entities": "^1.3.1", "is-ip": "^2.0.0", + "katex": "^0.12.0", + "cheerio": "^1.0.0-rc.3", "linkifyjs": "^2.1.9", "lodash": "^4.17.19", "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", "pako": "^1.0.11", "parse5": "^5.1.1", diff --git a/res/css/structures/_RoomStatusBar.scss b/res/css/structures/_RoomStatusBar.scss index 2d5359c0eb..5bf2aee3ae 100644 --- a/res/css/structures/_RoomStatusBar.scss +++ b/res/css/structures/_RoomStatusBar.scss @@ -19,57 +19,6 @@ limitations under the License. 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 { width: 52px; margin-top: -1px; @@ -162,11 +111,6 @@ limitations under the License. margin-top: 10px; } - .mx_RoomStatusBar_callBar { - height: 40px; - line-height: $font-40px; - } - .mx_RoomStatusBar_typingBar { height: 40px; line-height: $font-40px; diff --git a/res/css/structures/_UserMenu.scss b/res/css/structures/_UserMenu.scss index 6a352d46a3..84c21364ce 100644 --- a/res/css/structures/_UserMenu.scss +++ b/res/css/structures/_UserMenu.scss @@ -231,9 +231,29 @@ limitations under the License. justify-content: center; } + &.mx_UserMenu_contextMenu_guestPrompts, &.mx_UserMenu_contextMenu_hostingLink { 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 { diff --git a/res/css/views/avatars/_BaseAvatar.scss b/res/css/views/avatars/_BaseAvatar.scss index 1a1e14e7ac..cbddd97e18 100644 --- a/res/css/views/avatars/_BaseAvatar.scss +++ b/res/css/views/avatars/_BaseAvatar.scss @@ -41,7 +41,7 @@ limitations under the License. .mx_BaseAvatar_image { object-fit: cover; - border-radius: 40px; + border-radius: 125px; vertical-align: top; background-color: $avatar-bg-color; } diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index 2aeaaa87dc..2b87181b1e 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -15,87 +15,196 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_CallView_voice { - background-color: $accent-color; - color: $accent-fg-color; - cursor: pointer; - padding: 6px; - font-weight: bold; +.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; +} - border-radius: 8px; - min-width: 200px; +.mx_CallView_large { + padding-bottom: 10px; - display: flex; - align-items: center; - - img { - 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_voice { + height: 360px; } } -.mx_CallView_hangup { - position: absolute; +.mx_CallView_pip { + width: 320px; - 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_voice { + height: 180px; } } +.mx_CallView_voice { + position: relative; + display: flex; + align-items: center; + justify-content: center; + background-color: $inverted-bg-color; +} + .mx_CallView_video { width: 100%; position: relative; 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; +} diff --git a/res/css/views/voip/_VideoFeed.scss b/res/css/views/voip/_VideoFeed.scss index e5e3587dac..931410dba3 100644 --- a/res/css/views/voip/_VideoFeed.scss +++ b/res/css/views/voip/_VideoFeed.scss @@ -14,10 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_VideoFeed video { - width: 100%; -} - .mx_VideoFeed_remote { width: 100%; background-color: #000; @@ -28,16 +24,12 @@ limitations under the License. width: 25%; height: 25%; position: absolute; - left: 10px; - bottom: 10px; + right: 10px; + top: 10px; z-index: 100; + border-radius: 4px; } -.mx_VideoFeed_local video { - width: auto; - height: 100%; -} - -.mx_VideoFeed_mirror video { +.mx_VideoFeed_mirror { transform: scale(-1, 1); } diff --git a/res/img/element-icons/call/expand.svg b/res/img/element-icons/call/expand.svg new file mode 100644 index 0000000000..91ef4d8a76 --- /dev/null +++ b/res/img/element-icons/call/expand.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/call/video-muted.svg b/res/img/element-icons/call/video-muted.svg deleted file mode 100644 index d2aea71d11..0000000000 --- a/res/img/element-icons/call/video-muted.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/res/img/element-icons/call/voice-muted.svg b/res/img/element-icons/call/voice-muted.svg deleted file mode 100644 index 32abafb04a..0000000000 --- a/res/img/element-icons/call/voice-muted.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/res/img/element-icons/call/voice-unmuted.svg b/res/img/element-icons/call/voice-unmuted.svg deleted file mode 100644 index e664080217..0000000000 --- a/res/img/element-icons/call/voice-unmuted.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/res/img/element-icons/room/in-call.svg b/res/img/element-icons/room/in-call.svg deleted file mode 100644 index 0e574faa84..0000000000 --- a/res/img/element-icons/room/in-call.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/res/img/hangup.svg b/res/img/hangup.svg deleted file mode 100644 index be038d2b30..0000000000 --- a/res/img/hangup.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - Fill 72 + Path 98 - Created with Sketch. - - - - - - - - - - \ No newline at end of file diff --git a/res/img/voip/hangup.svg b/res/img/voip/hangup.svg new file mode 100644 index 0000000000..dfb20bd519 --- /dev/null +++ b/res/img/voip/hangup.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/res/img/voip/mic-off.svg b/res/img/voip/mic-off.svg new file mode 100644 index 0000000000..6409f1fd07 --- /dev/null +++ b/res/img/voip/mic-off.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/res/img/voip/mic-on.svg b/res/img/voip/mic-on.svg new file mode 100644 index 0000000000..3493b3c581 --- /dev/null +++ b/res/img/voip/mic-on.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/res/img/voip/vid-off.svg b/res/img/voip/vid-off.svg new file mode 100644 index 0000000000..199d97ab97 --- /dev/null +++ b/res/img/voip/vid-off.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/res/img/voip/vid-on.svg b/res/img/voip/vid-on.svg new file mode 100644 index 0000000000..d8146d01d3 --- /dev/null +++ b/res/img/voip/vid-on.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 710eded2cd..3be203ab98 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -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 Analytics from './Analytics'; import CountlyAnalytics from "./CountlyAnalytics"; +import {UIFeature} from "./settings/UIFeature"; enum AudioID { Ring = 'ringAudio', @@ -124,7 +125,7 @@ export default class CallHandler { return window.mxCallHandler; } - constructor() { + start() { dis.register(this.onAction); // add empty handlers for media actions, otherwise the media keys // 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('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 { diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 07bfd4858a..2301ad250b 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -27,9 +27,12 @@ import _linkifyString from 'linkifyjs/string'; import classNames from 'classnames'; import EMOJIBASE_REGEX from 'emojibase-regex'; 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 SettingsStore from './settings/SettingsStore'; import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks"; import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji"; import ReplyThread from "./components/views/elements/ReplyThread"; @@ -240,7 +243,8 @@ const sanitizeHtmlParams: IExtendedSanitizeOptions = { allowedAttributes: { // custom ones first: 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 img: ['src', 'width', 'height', 'alt', 'title'], ol: ['start'], @@ -414,6 +418,21 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts if (isHtmlMessage) { isDisplayedWithHtml = true; 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 { delete sanitizeParams.textFilter; @@ -515,7 +534,6 @@ export function checkBlockNode(node: Node) { case "H6": case "PRE": case "BLOCKQUOTE": - case "DIV": case "P": case "UL": case "OL": @@ -528,6 +546,9 @@ export function checkBlockNode(node: Node) { case "TH": case "TD": return true; + case "DIV": + // don't treat math nodes as block nodes for deserializing + return !(node as HTMLElement).hasAttribute("data-mx-maths"); default: return false; } diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 7469624f5c..ac96d59b09 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -48,6 +48,8 @@ import {Jitsi} from "./widgets/Jitsi"; import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "./BasePlatform"; import ThreepidInviteStore from "./stores/ThreepidInviteStore"; import CountlyAnalytics from "./CountlyAnalytics"; +import CallHandler from './CallHandler'; +import LifecycleCustomisations from "./customisations/Lifecycle"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -588,9 +590,9 @@ export function logout(): void { if (MatrixClientPeg.get().isGuest()) { // logout doesn't work for guest sessions - // Also we sometimes want to re-log in a guest session - // if we abort the login - onLoggedOut(); + // Also we sometimes want to re-log in a guest session if we abort the login. + // defer until next tick because it calls a synchronous dispatch and we are likely here from a dispatch. + setImmediate(() => onLoggedOut()); return; } @@ -665,6 +667,7 @@ async function startMatrixClient(startSyncing = true): Promise { DMRoomMap.makeShared().start(); IntegrationManagers.sharedInstance().startWatching(); ActiveWidgetStore.start(); + CallHandler.sharedInstance().start(); // 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 @@ -714,6 +717,7 @@ export async function onLoggedOut(): Promise { dis.dispatch({action: 'on_logged_out'}, true); stopMatrixClient(); await clearStorage({deleteEverything: true}); + LifecycleCustomisations.onLoggedOutAndStorageCleared?.(); } /** @@ -760,6 +764,7 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise$') != null) { + return true; + } + // Regex won't work for tags with attrs, but we only // allow anyway. const matches = /^<\/?(.*)>$/.exec(node.literal); @@ -30,6 +35,7 @@ function is_allowed_html_tag(node) { const tag = matches[1]; return ALLOWED_HTML_TAGS.indexOf(tag) > -1; } + return false; } diff --git a/src/TextForEvent.js b/src/TextForEvent.js index d86d88a697..56e9abc0f2 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -455,7 +455,7 @@ function textForWidgetEvent(event) { let widgetName = name || prevName || type || prevType || ''; // Apply sentence case to widget name 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 diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 114347196a..9fede15aa6 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -1353,18 +1353,6 @@ export default class MatrixChat extends React.PureComponent { }); }); - 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) { if (Lifecycle.isLoggingOut()) return; diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index e6d2985073..c1c4ad6292 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -18,13 +18,11 @@ import React from 'react'; import PropTypes from 'prop-types'; import Matrix from 'matrix-js-sdk'; import { _t, _td } from '../../languageHandler'; -import * as sdk from '../../index'; import {MatrixClientPeg} from '../../MatrixClientPeg'; import Resend from '../../Resend'; import dis from '../../dispatcher/dispatcher'; import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils'; import {Action} from "../../dispatcher/actions"; -import { CallState, CallType } from 'matrix-js-sdk/lib/webrtc/call'; const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_EXPANDED = 1; @@ -42,13 +40,6 @@ export default class RoomStatusBar extends React.Component { // the room this statusbar is representing. 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 // logically be shown when peeking, such as a prompt to invite people to a room. 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 = () => { Resend.resendUnsentEvents(this.props.room); 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 // indicate other sizes. _getSize() { - if (this._shouldShowConnectionError() || this._showCallBar()) { + if (this._shouldShowConnectionError()) { return STATUS_BAR_EXPANDED; } else if (this.state.unsentMessages.length > 0) { return STATUS_BAR_EXPANDED_LARGE; @@ -160,22 +145,6 @@ export default class RoomStatusBar extends React.Component { 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 ( - - ); - } - - if (this._shouldShowConnectionError()) { - return null; - } - - return null; - } - _shouldShowConnectionError() { // no conn bar trumps the "some not sent" msg since you can't resend without // a connection! @@ -266,25 +235,6 @@ export default class RoomStatusBar extends React.Component { ; } - _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. _getContent() { if (this._shouldShowConnectionError()) { @@ -307,26 +257,14 @@ export default class RoomStatusBar extends React.Component { return this._getUnsentMessageContent(); } - if (this._showCallBar()) { - return ( -
- { this._getCallStatusText() } -
- ); - } - return null; } render() { const content = this._getContent(); - const indicator = this._getIndicator(); return (
-
- { indicator } -
{ content }
diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index a9647e6ef9..f4f7c6ceec 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -41,7 +41,7 @@ import rateLimitedFunc from '../../ratelimitedfunc'; import * as ObjectUtils from '../../ObjectUtils'; import * as Rooms from '../../Rooms'; import eventSearch, {searchPagination} from '../../Searching'; -import {isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isOnlyCtrlOrCmdKeyEvent, Key} from '../../Keyboard'; +import {isOnlyCtrlOrCmdIgnoreShiftKeyEvent, Key} from '../../Keyboard'; import MainSplit from './MainSplit'; import RightPanel from './RightPanel'; import RoomViewStore from '../../stores/RoomViewStore'; @@ -67,10 +67,9 @@ import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar"; import PinnedEventsPanel from "../views/rooms/PinnedEventsPanel"; import AuxPanel from "../views/rooms/AuxPanel"; import RoomHeader from "../views/rooms/RoomHeader"; -import TintableSvg from "../views/elements/TintableSvg"; import {XOR} from "../../@types/common"; 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 {UPDATE_EVENT} from "../../stores/AsyncStore"; import Notifier from "../../Notifier"; @@ -507,8 +506,6 @@ export default class RoomView extends React.Component { this.props.resizeNotifier.on("middlePanelResized", this.onResize); } this.onResize(); - - document.addEventListener("keydown", this.onNativeKeyDown); } shouldComponentUpdate(nextProps, nextState) { @@ -591,8 +588,6 @@ export default class RoomView extends React.Component { this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize); } - document.removeEventListener("keydown", this.onNativeKeyDown); - // Remove RoomStore listener if (this.roomStoreToken) { this.roomStoreToken.remove(); @@ -641,33 +636,6 @@ export default class RoomView extends React.Component { } }; - // 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 => { let handled = false; @@ -1754,8 +1722,6 @@ export default class RoomView extends React.Component { isStatusAreaExpanded = this.state.statusBarVisible; statusBar = { }; } - if (activeCall) { - let zoomButton; let videoMuteButton; - - if (activeCall.type === CallType.Video) { - zoomButton = ( -
- -
- ); - - videoMuteButton = -
- -
; - } - const voiceMuteButton = -
- -
; - - // wrap the existing status bar into a 'callStatusBar' which adds more knobs. - statusBar = -
- { voiceMuteButton } - { videoMuteButton } - { zoomButton } - { statusBar } -
; - } - // if we have search results, we keep the messagepanel (so that it preserves its // scroll state), but hide it. let searchResultsPanel; diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 75208b8cfe..08bd472225 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -29,7 +29,7 @@ import LogoutDialog from "../views/dialogs/LogoutDialog"; import SettingsStore from "../../settings/SettingsStore"; import {getCustomTheme} from "../../theme"; import {getHostingLink} from "../../utils/HostingLink"; -import {ButtonEvent} from "../views/elements/AccessibleButton"; +import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton"; import SdkConfig from "../../SdkConfig"; import {getHomePageUrl} from "../../utils/pages"; import { OwnProfileStore } from "../../stores/OwnProfileStore"; @@ -205,6 +205,16 @@ export default class UserMenu extends React.Component { 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) => { ev.preventDefault(); ev.stopPropagation(); @@ -261,10 +271,29 @@ export default class UserMenu extends React.Component { const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName(); - let hostingLink; + let topSection; const signupLink = getHostingLink("user-context-menu"); - if (signupLink) { - hostingLink = ( + if (MatrixClientPeg.get().isGuest()) { + topSection = ( +
+ {_t("Got an account? Sign in", {}, { + a: sub => ( + + {sub} + + ), + })} + {_t("New here? Create an account", {}, { + a: sub => ( + + {sub} + + ), + })} +
+ ) + } else if (signupLink) { + topSection = (
{_t( "Upgrade to your own domain", {}, @@ -422,6 +451,20 @@ export default class UserMenu extends React.Component { ) + } else if (MatrixClientPeg.get().isGuest()) { + primaryOptionList = ( + + + { homeButton } + this.onSettingsOpen(e, null)} + /> + { feedbackButton } + + + ); } const classes = classNames({ @@ -451,7 +494,7 @@ export default class UserMenu extends React.Component { />
- {hostingLink} + {topSection} {primaryOptionList} {secondarySection} ; diff --git a/src/components/structures/auth/Login.js b/src/components/structures/auth/Login.tsx similarity index 71% rename from src/components/structures/auth/Login.js rename to src/components/structures/auth/Login.tsx index c3cbac0442..4cd8981a65 100644 --- a/src/components/structures/auth/Login.js +++ b/src/components/structures/auth/Login.tsx @@ -1,7 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018, 2019 New Vector Ltd +Copyright 2015, 2016, 2017, 2018, 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. @@ -16,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, {ComponentProps, ReactNode} from 'react'; + import {_t, _td} from '../../../languageHandler'; import * as sdk from '../../../index'; import Login from '../../../Login'; @@ -31,15 +29,12 @@ import PlatformPeg from '../../../PlatformPeg'; import SettingsStore from "../../../settings/SettingsStore"; import {UIFeature} from "../../../settings/UIFeature"; import CountlyAnalytics from "../../../CountlyAnalytics"; - -// For validating phone numbers without country codes -const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; - -// Phases -// Show controls to configure server details -const PHASE_SERVER_DETAILS = 0; -// Show the appropriate login flow(s) for the server -const PHASE_LOGIN = 1; +import {IMatrixClientCreds} from "../../../MatrixClientPeg"; +import ServerConfig from "../../views/auth/ServerConfig"; +import PasswordLogin from "../../views/auth/PasswordLogin"; +import SignInToText from "../../views/auth/SignInToText"; +import InlineSpinner from "../../views/elements/InlineSpinner"; +import Spinner from "../../views/elements/Spinner"; // Enable phases for login 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("General failure"); +interface IProps { + serverConfig: ValidatedServerConfig; + // If true, the component will consider itself busy. + busy?: boolean; + 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: + // - The object returned by the login API + // - 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 + // for operations like uploading cross-signing keys). + onLoggedIn(data: IMatrixClientCreds, password: string): void; + + // login shouldn't know or care how registration, password recovery, etc is done. + onRegisterClick(): void; + onForgotPasswordClick?(): void; + onServerConfigChange(config: ValidatedServerConfig): void; +} + +enum Phase { + // Show controls to configure server details + ServerDetails, + // Show the appropriate login flow(s) for the server + Login, +} + +interface IState { + busy: boolean; + busyLoggingIn?: boolean; + errorText?: ReactNode; + loginIncorrect: boolean; + // can we attempt to log in or are there validation errors? + canTryLogin: boolean; + + // used for preserving form values when changing homeserver + username: string; + phoneCountry?: string; + phoneNumber: string; + + // Phase of the overall login dialog. + 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 { - static propTypes = { - // Called when the user has logged in. Params: - // - The object returned by the login API - // - 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 - // for operations like uploading cross-signing keys). - onLoggedIn: PropTypes.func.isRequired, - - // If true, the component will consider itself busy. - busy: PropTypes.bool, - - // 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: PropTypes.string, - - defaultDeviceDisplayName: PropTypes.string, - - // login shouldn't know or care how registration, password recovery, - // etc is done. - onRegisterClick: PropTypes.func.isRequired, - onForgotPasswordClick: PropTypes.func, - onServerConfigChange: PropTypes.func.isRequired, - - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - isSyncing: PropTypes.bool, - }; +export default class LoginComponent extends React.Component { + private unmounted = false; + private loginLogic: Login; + private readonly stepRendererMap: Record ReactNode>; constructor(props) { super(props); - this._unmounted = false; - this.state = { busy: false, busyLoggingIn: null, errorText: null, loginIncorrect: false, - canTryLogin: true, // can we attempt to log in or are there validation errors? - - // used for preserving form values when changing homeserver + canTryLogin: true, username: "", phoneCountry: null, phoneNumber: "", - - // Phase of the overall login dialog. - 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. + phase: Phase.Login, + currentFlow: null, serverIsAlive: true, serverErrorIsFatal: false, 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 // letting you do that login type - this._stepRendererMap = { - 'm.login.password': this._renderPasswordStep, + this.stepRendererMap = { + 'm.login.password': this.renderPasswordStep, // CAS and SSO are the same thing, modulo the url we link to - 'm.login.cas': () => this._renderSsoStep("cas"), - 'm.login.sso': () => this._renderSsoStep("sso"), + 'm.login.cas': () => this.renderSsoStep("cas"), + 'm.login.sso': () => this.renderSsoStep("sso"), }; 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 // eslint-disable-next-line camelcase UNSAFE_componentWillMount() { - this._initLoginLogic(); + this.initLoginLogic(this.props.serverConfig); } componentWillUnmount() { - this._unmounted = true; + this.unmounted = true; } // 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; // 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; onPasswordLogin = async (username, phoneCountry, phoneNumber, password) => { @@ -194,13 +206,13 @@ export default class LoginComponent extends React.Component { loginIncorrect: false, }); - this._loginLogic.loginViaPassword( + this.loginLogic.loginViaPassword( username, phoneCountry, phoneNumber, password, ).then((data) => { this.setState({serverIsAlive: true}); // it must be, we logged in. this.props.onLoggedIn(data, password); }, (error) => { - if (this._unmounted) { + if (this.unmounted) { return; } let errorText; @@ -212,21 +224,23 @@ export default class LoginComponent extends React.Component { } else if (error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { const errorTop = messageForResourceLimitError( error.data.limit_type, - error.data.admin_contact, { - 'monthly_active_user': _td( - "This homeserver has hit its Monthly Active User limit.", - ), - '': _td( - "This homeserver has exceeded one of its resource limits.", - ), - }); + error.data.admin_contact, + { + 'monthly_active_user': _td( + "This homeserver has hit its Monthly Active User limit.", + ), + '': _td( + "This homeserver has exceeded one of its resource limits.", + ), + }, + ); const errorDetail = messageForResourceLimitError( error.data.limit_type, - error.data.admin_contact, { - '': _td( - "Please contact your service administrator to continue using this service.", - ), - }); + error.data.admin_contact, + { + '': _td("Please contact your service administrator to continue using this service."), + }, + ); errorText = (
{errorTop}
@@ -253,7 +267,7 @@ export default class LoginComponent extends React.Component { } } else { // other errors, not specific to doing a password login - errorText = this._errorTextFromError(error); + errorText = this.errorTextFromError(error); } 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 // 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 - // 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. this.setState({ busy: false, @@ -304,7 +318,7 @@ export default class LoginComponent extends React.Component { message = e.translatedMessage; } - let errorText = message; + let errorText: ReactNode = message; let discoveryState = {}; if (AutoDiscoveryUtils.isLivelinessError(e)) { 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 => { ev.preventDefault(); ev.stopPropagation(); @@ -352,14 +351,14 @@ export default class LoginComponent extends React.Component { }; onTryRegisterClick = ev => { - const step = this._getCurrentFlowStep(); + const step = this.getCurrentFlowStep(); if (step === 'm.login.sso' || step === 'm.login.cas') { // 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'. ev.preventDefault(); ev.stopPropagation(); 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); } else { // 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({ - phase: PHASE_LOGIN, + phase: Phase.Login, }); }; - onEditServerDetailsClick = ev => { + private onEditServerDetailsClick = ev => { ev.preventDefault(); ev.stopPropagation(); this.setState({ - phase: PHASE_SERVER_DETAILS, + phase: Phase.ServerDetails, }); }; - async _initLoginLogic(hsUrl, isUrl) { - hsUrl = hsUrl || this.props.serverConfig.hsUrl; - isUrl = isUrl || this.props.serverConfig.isUrl; - + private async initLoginLogic({hsUrl, isUrl}: ValidatedServerConfig) { let isDefaultServer = false; if (this.props.serverConfig.isDefault && hsUrl === this.props.serverConfig.hsUrl @@ -397,7 +393,7 @@ export default class LoginComponent extends React.Component { const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, { defaultDeviceDisplayName: this.props.defaultDeviceDisplayName, }); - this._loginLogic = loginLogic; + this.loginLogic = loginLogic; this.setState({ busy: true, @@ -428,7 +424,7 @@ export default class LoginComponent extends React.Component { if (this.state.serverErrorIsFatal) { // Server is dead: show server details prompt instead this.setState({ - phase: PHASE_SERVER_DETAILS, + phase: Phase.ServerDetails, }); return; } @@ -437,7 +433,7 @@ export default class LoginComponent extends React.Component { loginLogic.getFlows().then((flows) => { // look for a flow where we understand all of the steps. for (let i = 0; i < flows.length; i++ ) { - if (!this._isSupportedFlow(flows[i])) { + if (!this.isSupportedFlow(flows[i])) { continue; } @@ -446,7 +442,7 @@ export default class LoginComponent extends React.Component { // that for now). loginLogic.chooseFlow(i); this.setState({ - currentFlow: this._getCurrentFlowStep(), + currentFlow: this.getCurrentFlowStep(), }); return; } @@ -460,7 +456,7 @@ export default class LoginComponent extends React.Component { }); }, (err) => { this.setState({ - errorText: this._errorTextFromError(err), + errorText: this.errorTextFromError(err), loginIncorrect: false, canTryLogin: false, }); @@ -471,28 +467,28 @@ export default class LoginComponent extends React.Component { }); } - _isSupportedFlow(flow) { + private isSupportedFlow(flow) { // 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. - if (!this._stepRendererMap[flow.type]) { + if (!this.stepRendererMap[flow.type]) { console.log("Skipping flow", flow, "due to unsupported login type", flow.type); return false; } return true; } - _getCurrentFlowStep() { - return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null; + private getCurrentFlowStep() { + return this.loginLogic ? this.loginLogic.getCurrentFlowStep() : null; } - _errorTextFromError(err) { + private errorTextFromError(err) { let errCode = err.errcode; if (!errCode && err.httpStatus) { errCode = "HTTP " + err.httpStatus; } - let errorText = _t("Error: Problem communicating with the given homeserver.") + - (errCode ? " (" + errCode + ")" : ""); + let errorText: ReactNode = _t("Error: Problem communicating with the given homeserver.") + + (errCode ? " (" + errCode + ")" : ""); if (err.cors === 'rejected') { if (window.location.protocol === 'https:' && @@ -502,29 +498,27 @@ export default class LoginComponent extends React.Component { errorText = { _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " + "Either use HTTPS or enable unsafe scripts.", {}, - { - 'a': (sub) => { - return - { sub } - ; - }, + { + 'a': (sub) => { + return + { sub } + ; }, - ) } + }) } ; } else { errorText = { _t("Can't connect to homeserver - please check your connectivity, ensure your " + "homeserver's SSL certificate is trusted, and that a browser extension " + "is not blocking requests.", {}, - { - 'a': (sub) => - - { sub } - , - }, - ) } + { + 'a': (sub) => + + { sub } + , + }) } ; } } @@ -532,18 +526,16 @@ export default class LoginComponent extends React.Component { return errorText; } - renderServerComponent() { - const ServerConfig = sdk.getComponent("auth.ServerConfig"); - + private renderServerComponent() { if (SdkConfig.get()['disable_custom_urls']) { return null; } - if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) { + if (PHASES_ENABLED && this.state.phase !== Phase.ServerDetails) { return null; } - const serverDetailsProps = {}; + const serverDetailsProps: ComponentProps = {}; if (PHASES_ENABLED) { serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick; serverDetailsProps.submitText = _t("Next"); @@ -558,8 +550,8 @@ export default class LoginComponent extends React.Component { />; } - renderLoginComponentForStep() { - if (PHASES_ENABLED && this.state.phase !== PHASE_LOGIN) { + private renderLoginComponentForStep() { + if (PHASES_ENABLED && this.state.phase !== Phase.Login) { return null; } @@ -569,7 +561,7 @@ export default class LoginComponent extends React.Component { return null; } - const stepRenderer = this._stepRendererMap[step]; + const stepRenderer = this.stepRendererMap[step]; if (stepRenderer) { return stepRenderer(); @@ -578,9 +570,7 @@ export default class LoginComponent extends React.Component { return null; } - _renderPasswordStep = () => { - const PasswordLogin = sdk.getComponent('auth.PasswordLogin'); - + private renderPasswordStep = () => { let onEditServerDetailsClick = null; // If custom URLs are allowed, wire up the server details edit link. if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) { @@ -589,29 +579,25 @@ export default class LoginComponent extends React.Component { return ( ); }; - _renderSsoStep = loginType => { - const SignInToText = sdk.getComponent('views.auth.SignInToText'); - + private renderSsoStep = loginType => { let onEditServerDetailsClick = null; // If custom URLs are allowed, wire up the server details edit link. if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) { @@ -632,7 +618,7 @@ export default class LoginComponent extends React.Component { @@ -641,12 +627,10 @@ export default class LoginComponent extends React.Component { }; render() { - const Loader = sdk.getComponent("elements.Spinner"); - const InlineSpinner = sdk.getComponent("elements.InlineSpinner"); const AuthHeader = sdk.getComponent("auth.AuthHeader"); const AuthBody = sdk.getComponent("auth.AuthBody"); const loader = this.isBusy() && !this.state.busyLoggingIn ? -
: null; +
: null; const errorText = this.state.errorText; diff --git a/src/components/structures/auth/Registration.js b/src/components/structures/auth/Registration.tsx similarity index 78% rename from src/components/structures/auth/Registration.js rename to src/components/structures/auth/Registration.tsx index 80bf3b72cd..f97f20cf59 100644 --- a/src/components/structures/auth/Registration.js +++ b/src/components/structures/auth/Registration.tsx @@ -1,8 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018, 2019 New Vector Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2015, 2016, 2017, 2018, 2019, 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. @@ -18,8 +15,9 @@ limitations under the License. */ import Matrix from 'matrix-js-sdk'; -import React from 'react'; -import PropTypes from 'prop-types'; +import React, {ComponentProps, ReactNode} from 'react'; +import {MatrixClient} from "matrix-js-sdk/src/client"; + import * as sdk from '../../../index'; import { _t, _td } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; @@ -34,36 +32,96 @@ import Login from "../../../Login"; import dis from "../../../dispatcher/dispatcher"; // Phases -// Show controls to configure server details -const PHASE_SERVER_DETAILS = 0; -// Show the appropriate registration flow(s) for the server -const PHASE_REGISTRATION = 1; +enum Phase { + // Show controls to configure server details + ServerDetails = 0, + // Show the appropriate registration flow(s) for the server + Registration = 1, +} + +interface IProps { + serverConfig: ValidatedServerConfig; + defaultDeviceDisplayName: string; + email?: string; + brand?: string; + clientSecret?: string; + sessionId?: string; + idSid?: string; + + // Called when the user has logged in. Params: + // - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken + // - 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 operations like uploading cross-signing keys). + onLoggedIn(params: { + userId: string; + deviceId: string + homeserverUrl: string; + identityServerUrl?: string; + accessToken: string; + }, password: string): void; + makeRegistrationUrl(params: { + /* 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. + onLoginClick(): void; + onServerConfigChange(config: ValidatedServerConfig): void; +} + +interface IState { + busy: boolean; + errorText?: ReactNode; + // true if we're waiting for the user to complete + // We remember the values entered by the user because + // the registration form will be unmounted during the + // course of registration, but if there's an error we + // want to bring back the registration form with the + // values the user entered still in it. We can keep + // them in this component's state since this component + // persist for the duration of the registration process. + formVals: Record; + // user-interactive auth + // If we've been given a session ID, we're resuming + // straight back into UI auth + doingUIAuth: boolean; + // If set, we've registered but are not going to log + // the user in to their new account automatically. + 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 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; + + // Our matrix client - part of state because we can't render the UI auth + // component without it. + matrixClient?: MatrixClient; + // whether the HS requires an ID server to register with a threepid + serverRequiresIdServer?: boolean; + // The user ID we've just registered + registeredUsername?: string; + // if a different user ID to the one we just registered is logged in, + // this is the user ID that's logged in. + differentLoggedInUserId?: string; +} // Enable phases for registration const PHASES_ENABLED = true; -export default class Registration extends React.Component { - static propTypes = { - // Called when the user has logged in. Params: - // - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken - // - 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 operations like uploading cross-signing keys). - onLoggedIn: PropTypes.func.isRequired, - - clientSecret: PropTypes.string, - sessionId: PropTypes.string, - makeRegistrationUrl: PropTypes.func.isRequired, - idSid: PropTypes.string, - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - brand: PropTypes.string, - email: PropTypes.string, - // registration shouldn't know or care how login is done. - onLoginClick: PropTypes.func.isRequired, - onServerConfigChange: PropTypes.func.isRequired, - defaultDeviceDisplayName: PropTypes.string, - }; - +export default class Registration extends React.Component { constructor(props) { super(props); @@ -71,56 +129,22 @@ export default class Registration extends React.Component { this.state = { busy: false, errorText: null, - // We remember the values entered by the user because - // the registration form will be unmounted during the - // course of registration, but if there's an error we - // want to bring back the registration form with the - // values the user entered still in it. We can keep - // them in this component's state since this component - // persist for the duration of the registration process. formVals: { email: this.props.email, }, - // true if we're waiting for the user to complete - // user-interactive auth - // If we've been given a session ID, we're resuming - // straight back into UI auth doingUIAuth: Boolean(this.props.sessionId), serverType, - // Phase of the overall registration dialog. - phase: PHASE_REGISTRATION, + phase: Phase.Registration, flows: null, - // If set, we've registered but are not going to log - // the user in to their new account automatically. completedNoSignin: false, - - // 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, serverErrorIsFatal: false, serverDeadError: "", - - // Our matrix client - part of state because we can't render the UI auth - // component without it. - matrixClient: null, - - // whether the HS requires an ID server to register with a threepid - serverRequiresIdServer: null, - - // The user ID we've just registered - registeredUsername: null, - - // if a different user ID to the one we just registered is logged in, - // this is the user ID that's logged in. - differentLoggedInUserId: null, }; } componentDidMount() { - this._unmounted = false; - this._replaceClient(); + this.replaceClient(this.props.serverConfig); } // 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 && 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 // 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. this.setState({ serverType, - phase: this.getDefaultPhaseForServerType(serverType), + phase: Registration.getDefaultPhaseForServerType(serverType), }); } } - getDefaultPhaseForServerType(type) { + private static getDefaultPhaseForServerType(type: IState["serverType"]) { switch (type) { case ServerType.FREE: { // Move directly to the registration phase since the server // details are fixed. - return PHASE_REGISTRATION; + return Phase.Registration; } case ServerType.PREMIUM: case ServerType.ADVANCED: - return PHASE_SERVER_DETAILS; + return Phase.ServerDetails; } } - onServerTypeChange = type => { + private onServerTypeChange = (type: IState["serverType"]) => { this.setState({ serverType: type, }); @@ -181,11 +205,11 @@ export default class Registration extends React.Component { // Reset the phase to default phase for the server type. this.setState({ - phase: this.getDefaultPhaseForServerType(type), + phase: Registration.getDefaultPhaseForServerType(type), }); }; - async _replaceClient(serverConfig) { + private async replaceClient(serverConfig: ValidatedServerConfig) { this.setState({ errorText: 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) busy: true, }); - if (!serverConfig) serverConfig = this.props.serverConfig; // Do a liveliness check on the URLs 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 // need to. if (!this.state.doingUIAuth) { - await this._makeRegisterRequest(null); + await this.makeRegisterRequest(null); // This should never succeed since we specified no auth object. 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({ errorText: "", 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( emailAddress, 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) { let msg = response.message || response.toString(); // can we give a better error message? if (response.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { const errorTop = messageForResourceLimitError( response.data.limit_type, - response.data.admin_contact, { - 'monthly_active_user': _td( - "This homeserver has hit its Monthly Active User limit.", - ), - '': _td( - "This homeserver has exceeded one of its resource limits.", - ), - }); + response.data.admin_contact, + { + 'monthly_active_user': _td("This homeserver has hit its Monthly Active User limit."), + '': _td("This homeserver has exceeded one of its resource limits."), + }, + ); const errorDetail = messageForResourceLimitError( response.data.limit_type, - response.data.admin_contact, { - '': _td( - "Please contact your service administrator to continue using this service.", - ), - }); + response.data.admin_contact, + { + '': _td("Please contact your service administrator to continue using this service."), + }, + ); msg =

{errorTop}

{errorDetail}

@@ -339,7 +360,7 @@ export default class Registration extends React.Component { } else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) { let msisdnAvailable = false; 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) { 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 = { doingUIAuth: false, 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 @@ -372,8 +397,6 @@ export default class Registration extends React.Component { `Found a session for ${sessionOwner} but ${response.userId} has just registered.`, ); newState.differentLoggedInUserId = sessionOwner; - } else { - newState.differentLoggedInUserId = null; } if (response.access_token) { @@ -385,9 +408,7 @@ export default class Registration extends React.Component { accessToken: response.access_token, }, this.state.formVals.password); - this._setupPushers(); - // we're still busy until we get unmounted: don't show the registration form again - newState.busy = true; + this.setupPushers(); } else { newState.busy = false; newState.completedNoSignin = true; @@ -396,7 +417,7 @@ export default class Registration extends React.Component { this.setState(newState); }; - _setupPushers() { + private setupPushers() { if (!this.props.brand) { return Promise.resolve(); } @@ -419,38 +440,38 @@ export default class Registration extends React.Component { }); } - onLoginClick = ev => { + private onLoginClick = ev => { ev.preventDefault(); ev.stopPropagation(); this.props.onLoginClick(); }; - onGoToFormClicked = ev => { + private onGoToFormClicked = ev => { ev.preventDefault(); ev.stopPropagation(); - this._replaceClient(); + this.replaceClient(this.props.serverConfig); this.setState({ busy: false, doingUIAuth: false, - phase: PHASE_REGISTRATION, + phase: Phase.Registration, }); }; - onServerDetailsNextPhaseClick = async () => { + private onServerDetailsNextPhaseClick = async () => { this.setState({ - phase: PHASE_REGISTRATION, + phase: Phase.Registration, }); }; - onEditServerDetailsClick = ev => { + private onEditServerDetailsClick = ev => { ev.preventDefault(); ev.stopPropagation(); 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 // 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 @@ -466,13 +487,15 @@ export default class Registration extends React.Component { username: this.state.formVals.username, password: this.state.formVals.password, initial_device_display_name: this.props.defaultDeviceDisplayName, + auth: undefined, + inhibit_login: undefined, }; if (auth) registerParams.auth = auth; if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin; return this.state.matrixClient.registerRequest(registerParams); }; - _getUIAuthInputs() { + private getUIAuthInputs() { return { emailAddress: this.state.formVals.email, 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 // which checks the user hasn't already logged in somewhere else (perhaps we should do // this more generally?) - _onLoginClickWithCheck = async ev => { + private onLoginClickWithCheck = async ev => { ev.preventDefault(); 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 ServerConfig = sdk.getComponent("auth.ServerConfig"); 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 - 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; } @@ -511,7 +534,7 @@ export default class Registration extends React.Component { // 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 // 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
; } - const serverDetailsProps = {}; + const serverDetailsProps: ComponentProps = {}; if (PHASES_ENABLED) { serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick; serverDetailsProps.submitText = _t("Next"); @@ -559,8 +582,8 @@ export default class Registration extends React.Component {
; } - renderRegisterComponent() { - if (PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) { + private renderRegisterComponent() { + if (PHASES_ENABLED && this.state.phase !== Phase.Registration) { return null; } @@ -571,10 +594,10 @@ export default class Registration extends React.Component { if (this.state.matrixClient && this.state.doingUIAuth) { return { _t('Go back') } ; @@ -651,7 +674,7 @@ export default class Registration extends React.Component { loggedInUserId: this.state.differentLoggedInUserId, }, )}

-

+

{_t("Continue with previous account")}

; @@ -660,7 +683,7 @@ export default class Registration extends React.Component { regDoneText =

{_t( "Log in to your new account.", {}, { - a: (sub) => {sub}, + a: (sub) => {sub}, }, )}

; } else { @@ -670,7 +693,7 @@ export default class Registration extends React.Component { regDoneText =

{_t( "You can now close this window or log in to your new account.", {}, { - a: (sub) => {sub}, + a: (sub) => {sub}, }, )}

; } @@ -679,7 +702,7 @@ export default class Registration extends React.Component { { regDoneText }
; } 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, }); if (this.props.serverConfig.hsNameIsDifferent) { @@ -717,7 +740,7 @@ export default class Registration extends React.Component { { errorText } { serverDeadSection } { this.renderServerComponent() } - { this.state.phase !== PHASE_SERVER_DETAILS &&

+ { this.state.phase !== Phase.ServerDetails &&

{yourMatrixAccountText} {editLink}

} diff --git a/src/components/views/auth/PassphraseField.tsx b/src/components/views/auth/PassphraseField.tsx index b420ed0872..e240ad61ca 100644 --- a/src/components/views/auth/PassphraseField.tsx +++ b/src/components/views/auth/PassphraseField.tsx @@ -21,9 +21,9 @@ import zxcvbn from "zxcvbn"; import SdkConfig from "../../../SdkConfig"; import withValidation, {IFieldState, IValidationResult} from "../elements/Validation"; import {_t, _td} from "../../../languageHandler"; -import Field from "../elements/Field"; +import Field, {IInputProps} from "../elements/Field"; -interface IProps { +interface IProps extends Omit { autoFocus?: boolean; id?: string; className?: string; diff --git a/src/components/views/auth/PasswordLogin.js b/src/components/views/auth/PasswordLogin.js deleted file mode 100644 index 405f9051b9..0000000000 --- a/src/components/views/auth/PasswordLogin.js +++ /dev/null @@ -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 ; - case PasswordLogin.LOGIN_FIELD_MXID: - classes.error = this.props.loginIncorrect && !this.state.username; - return ; - case PasswordLogin.LOGIN_FIELD_PHONE: { - const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown'); - classes.error = this.props.loginIncorrect && !this.state.phoneNumber; - - const phoneCountry = ; - - return ; - } - } - } - - 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 = - {_t('Not sure of your password? Set a new one', {}, { - a: sub => ( - - {sub} - - ), - })} - ; - } - - 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 = ( -
- - - - - - -
- ); - } - - return ( -
- -
- {loginType} - {loginField} - - {forgotPasswordJsx} - { !this.props.busy && } - -
- ); - } -} diff --git a/src/components/views/auth/PasswordLogin.tsx b/src/components/views/auth/PasswordLogin.tsx new file mode 100644 index 0000000000..fced2e08d0 --- /dev/null +++ b/src/components/views/auth/PasswordLogin.tsx @@ -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>; + 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 { + 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 this[LoginField.Email] = field} + />; + case LoginField.MatrixId: + classes.error = this.props.loginIncorrect && !this.props.username; + return this[LoginField.MatrixId] = field} + />; + case LoginField.Phone: { + classes.error = this.props.loginIncorrect && !this.props.phoneNumber; + + const phoneCountry = ; + + return 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 = + {_t('Not sure of your password? Set a new one', {}, { + a: sub => ( + + {sub} + + ), + })} + ; + } + + 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 = ( +
+ + + + + + +
+ ); + } + + return ( +
+ +
+ {loginType} + {loginField} + this[LoginField.Password] = field} + /> + {forgotPasswordJsx} + { !this.props.busy && } + +
+ ); + } +} diff --git a/src/components/views/auth/RegistrationForm.js b/src/components/views/auth/RegistrationForm.tsx similarity index 77% rename from src/components/views/auth/RegistrationForm.js rename to src/components/views/auth/RegistrationForm.tsx index 70c1017427..f6fb3bb3ea 100644 --- a/src/components/views/auth/RegistrationForm.js +++ b/src/components/views/auth/RegistrationForm.tsx @@ -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 2015, 2016, 2017, 2018, 2019, 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. @@ -18,7 +16,7 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; + import * as sdk from '../../../index'; import * as Email from '../../../email'; import { looksValid as phoneNumberLooksValid } from '../../../phonenumber'; @@ -31,32 +29,57 @@ import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; import PassphraseField from "./PassphraseField"; import CountlyAnalytics from "../../../CountlyAnalytics"; -const FIELD_EMAIL = 'field_email'; -const FIELD_PHONE_NUMBER = 'field_phone_number'; -const FIELD_USERNAME = 'field_username'; -const FIELD_PASSWORD = 'field_password'; -const FIELD_PASSWORD_CONFIRM = 'field_password_confirm'; +enum RegistrationField { + Email = "field_email", + PhoneNumber = "field_phone_number", + Username = "field_username", + Password = "field_password", + PasswordConfirm = "field_password_confirm", +} 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; + onEditServerDetailsClick?(): void; +} + +interface IState { + // Field error codes by field ID + fieldValid: Partial>; + // 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. */ -export default class RegistrationForm extends React.Component { - 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, - }; - +export default class RegistrationForm extends React.PureComponent { static defaultProps = { onValidationChange: console.error, canSubmit: true, @@ -66,9 +89,7 @@ export default class RegistrationForm extends React.Component { super(props); this.state = { - // Field error codes by field ID fieldValid: {}, - // The ISO2 country code selected in the phone number entry phoneCountry: this.props.defaultPhoneCountry, username: this.props.defaultUsername || "", email: this.props.defaultEmail || "", @@ -81,7 +102,7 @@ export default class RegistrationForm extends React.Component { CountlyAnalytics.instance.track("onboarding_registration_begin"); } - onSubmit = async ev => { + private onSubmit = async ev => { ev.preventDefault(); if (!this.props.canSubmit) return; @@ -92,7 +113,6 @@ export default class RegistrationForm extends React.Component { return; } - const self = this; if (this.state.email === '') { 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 " + "reset your password in the future.", ); - } else if (this._showEmail()) { + } else if (this.showEmail()) { desc = _t( "If you don't specify an email address, you won't be able to reset your password. " + "Are you sure?", ); } else { // user can't set an e-mail so don't prompt them to - self._doSubmit(ev); + this.doSubmit(ev); return; } @@ -120,18 +140,18 @@ export default class RegistrationForm extends React.Component { title: _t("Warning!"), description: desc, button: _t("Continue"), - onFinished(confirmed) { + onFinished: (confirmed) => { if (confirmed) { - self._doSubmit(ev); + this.doSubmit(ev); } }, }); } else { - self._doSubmit(ev); + this.doSubmit(ev); } }; - _doSubmit(ev) { + private doSubmit(ev) { const email = this.state.email.trim(); 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, // 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) { activeElement.blur(); } const fieldIDsInDisplayOrder = [ - FIELD_USERNAME, - FIELD_PASSWORD, - FIELD_PASSWORD_CONFIRM, - FIELD_EMAIL, - FIELD_PHONE_NUMBER, + RegistrationField.Username, + RegistrationField.Password, + RegistrationField.PasswordConfirm, + RegistrationField.Email, + RegistrationField.PhoneNumber, ]; // 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. */ - allFieldsValid() { + private allFieldsValid() { const keys = Object.keys(this.state.fieldValid); for (let i = 0; i < keys.length; ++i) { if (!this.state.fieldValid[keys[i]]) { @@ -218,7 +238,7 @@ export default class RegistrationForm extends React.Component { return true; } - findFirstInvalidField(fieldIDs) { + private findFirstInvalidField(fieldIDs: RegistrationField[]) { for (const fieldID of fieldIDs) { if (!this.state.fieldValid[fieldID] && this[fieldID]) { return this[fieldID]; @@ -227,7 +247,7 @@ export default class RegistrationForm extends React.Component { return null; } - markFieldValid(fieldID, valid) { + private markFieldValid(fieldID: RegistrationField, valid: boolean) { const { fieldValid } = this.state; fieldValid[fieldID] = valid; this.setState({ @@ -235,26 +255,26 @@ export default class RegistrationForm extends React.Component { }); } - onEmailChange = ev => { + private onEmailChange = ev => { this.setState({ email: ev.target.value, }); }; - onEmailValidate = async fieldState => { + private onEmailValidate = async fieldState => { const result = await this.validateEmailRules(fieldState); - this.markFieldValid(FIELD_EMAIL, result.valid); + this.markFieldValid(RegistrationField.Email, result.valid); return result; }; - validateEmailRules = withValidation({ + private validateEmailRules = withValidation({ description: () => _t("Use an email address to recover your account"), hideDescriptionIfValid: true, rules: [ { key: "required", - test({ value, allowEmpty }) { - return allowEmpty || !this._authStepIsRequired('m.login.email.identity') || !!value; + test(this: RegistrationForm, { value, allowEmpty }) { + return allowEmpty || !this.authStepIsRequired('m.login.email.identity') || !!value; }, 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({ password: ev.target.value, }); }; - onPasswordValidate = result => { - this.markFieldValid(FIELD_PASSWORD, result.valid); + private onPasswordValidate = result => { + this.markFieldValid(RegistrationField.Password, result.valid); }; - onPasswordConfirmChange = ev => { + private onPasswordConfirmChange = ev => { this.setState({ passwordConfirm: ev.target.value, }); }; - onPasswordConfirmValidate = async fieldState => { + private onPasswordConfirmValidate = async fieldState => { const result = await this.validatePasswordConfirmRules(fieldState); - this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid); + this.markFieldValid(RegistrationField.PasswordConfirm, result.valid); return result; }; - validatePasswordConfirmRules = withValidation({ + private validatePasswordConfirmRules = withValidation({ rules: [ { key: "required", @@ -297,41 +317,40 @@ export default class RegistrationForm extends React.Component { }, { key: "match", - test({ value }) { + test(this: RegistrationForm, { value }) { return !value || value === this.state.password; }, invalid: () => _t("Passwords don't match"), }, - ], + ], }); - onPhoneCountryChange = newVal => { + private onPhoneCountryChange = newVal => { this.setState({ phoneCountry: newVal.iso2, - phonePrefix: newVal.prefix, }); }; - onPhoneNumberChange = ev => { + private onPhoneNumberChange = ev => { this.setState({ phoneNumber: ev.target.value, }); }; - onPhoneNumberValidate = async fieldState => { + private onPhoneNumberValidate = async fieldState => { const result = await this.validatePhoneNumberRules(fieldState); - this.markFieldValid(FIELD_PHONE_NUMBER, result.valid); + this.markFieldValid(RegistrationField.PhoneNumber, result.valid); return result; }; - validatePhoneNumberRules = withValidation({ + private validatePhoneNumberRules = withValidation({ description: () => _t("Other users can invite you to rooms using your contact details"), hideDescriptionIfValid: true, rules: [ { key: "required", - test({ value, allowEmpty }) { - return allowEmpty || !this._authStepIsRequired('m.login.msisdn') || !!value; + test(this: RegistrationForm, { value, allowEmpty }) { + return allowEmpty || !this.authStepIsRequired('m.login.msisdn') || !!value; }, 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({ username: ev.target.value, }); }; - onUsernameValidate = async fieldState => { + private onUsernameValidate = async fieldState => { const result = await this.validateUsernameRules(fieldState); - this.markFieldValid(FIELD_USERNAME, result.valid); + this.markFieldValid(RegistrationField.Username, result.valid); return result; }; - validateUsernameRules = withValidation({ + private validateUsernameRules = withValidation({ description: () => _t("Use lowercase letters, numbers, dashes and underscores only"), hideDescriptionIfValid: true, rules: [ @@ -378,7 +397,7 @@ export default class RegistrationForm extends React.Component { * @param {string} step A stage name to check * @returns {boolean} Whether it is required */ - _authStepIsRequired(step) { + private authStepIsRequired(step: string) { return this.props.flows.every((flow) => { return flow.stages.includes(step); }); @@ -390,46 +409,46 @@ export default class RegistrationForm extends React.Component { * @param {string} step A stage name to check * @returns {boolean} Whether it is used */ - _authStepIsUsed(step) { + private authStepIsUsed(step: string) { return this.props.flows.some((flow) => { return flow.stages.includes(step); }); } - _showEmail() { + private showEmail() { const haveIs = Boolean(this.props.serverConfig.isUrl); if ( (this.props.serverRequiresIdServer && !haveIs) || - !this._authStepIsUsed('m.login.email.identity') + !this.authStepIsUsed('m.login.email.identity') ) { return false; } return true; } - _showPhoneNumber() { + private showPhoneNumber() { const threePidLogin = !SdkConfig.get().disable_3pid_login; const haveIs = Boolean(this.props.serverConfig.isUrl); if ( !threePidLogin || (this.props.serverRequiresIdServer && !haveIs) || - !this._authStepIsUsed('m.login.msisdn') + !this.authStepIsUsed('m.login.msisdn') ) { return false; } return true; } - renderEmail() { - if (!this._showEmail()) { + private renderEmail() { + if (!this.showEmail()) { return null; } 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 (optional)"); return this[FIELD_EMAIL] = field} + ref={field => this[RegistrationField.Email] = field} type="text" label={emailPlaceholder} value={this.state.email} @@ -440,10 +459,10 @@ export default class RegistrationForm extends React.Component { />; } - renderPassword() { + private renderPassword() { return this[FIELD_PASSWORD] = field} + fieldRef={field => this[RegistrationField.Password] = field} minScore={PASSWORD_MIN_SCORE} value={this.state.password} onChange={this.onPasswordChange} @@ -457,7 +476,7 @@ export default class RegistrationForm extends React.Component { const Field = sdk.getComponent('elements.Field'); return this[FIELD_PASSWORD_CONFIRM] = field} + ref={field => this[RegistrationField.PasswordConfirm] = field} type="password" autoComplete="new-password" label={_t("Confirm password")} @@ -470,12 +489,12 @@ export default class RegistrationForm extends React.Component { } renderPhoneNumber() { - if (!this._showPhoneNumber()) { + if (!this.showPhoneNumber()) { return null; } const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown'); const Field = sdk.getComponent('elements.Field'); - const phoneLabel = this._authStepIsRequired('m.login.msisdn') ? + const phoneLabel = this.authStepIsRequired('m.login.msisdn') ? _t("Phone") : _t("Phone (optional)"); const phoneCountry = ; return this[FIELD_PHONE_NUMBER] = field} + ref={field => this[RegistrationField.PhoneNumber] = field} type="text" label={phoneLabel} value={this.state.phoneNumber} @@ -499,7 +518,7 @@ export default class RegistrationForm extends React.Component { const Field = sdk.getComponent('elements.Field'); return this[FIELD_USERNAME] = field} + ref={field => this[RegistrationField.Username] = field} type="text" autoFocus={true} label={_t("Username")} @@ -517,8 +536,8 @@ export default class RegistrationForm extends React.Component { ); let emailHelperText = null; - if (this._showEmail()) { - if (this._showPhoneNumber()) { + if (this.showEmail()) { + if (this.showPhoneNumber()) { emailHelperText =
{_t( "Set an email for account recovery. " + diff --git a/src/components/views/avatars/PulsedAvatar.tsx b/src/components/views/avatars/PulsedAvatar.tsx deleted file mode 100644 index b4e876b9f6..0000000000 --- a/src/components/views/avatars/PulsedAvatar.tsx +++ /dev/null @@ -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 = (props) => { - return
- {props.children} -
; -}; - -export default PulsedAvatar; diff --git a/src/components/views/dialogs/ModalWidgetDialog.tsx b/src/components/views/dialogs/ModalWidgetDialog.tsx index e722374555..484e8f0dcf 100644 --- a/src/components/views/dialogs/ModalWidgetDialog.tsx +++ b/src/components/views/dialogs/ModalWidgetDialog.tsx @@ -38,6 +38,7 @@ import {MatrixClientPeg} from "../../../MatrixClientPeg"; import RoomViewStore from "../../../stores/RoomViewStore"; import {OwnProfileStore} from "../../../stores/OwnProfileStore"; import { arrayFastClone } from "../../../utils/arrays"; +import { ElementWidget } from "../../../stores/widgets/StopGapWidget"; interface IProps { widgetDefinition: IModalWidgetOpenRequestData; @@ -64,7 +65,7 @@ export default class ModalWidgetDialog extends React.PureComponent + const isDisabled = this.state.disabledButtonIds.includes(def.id); + + return { def.label } ; }); diff --git a/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js b/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js index e793b85079..7ed3d04318 100644 --- a/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js +++ b/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js @@ -17,18 +17,17 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import {_t} from "../../../languageHandler"; -import SettingsStore from "../../../settings/SettingsStore"; import * as sdk from "../../../index"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; -import WidgetUtils from "../../../utils/WidgetUtils"; -import {SettingLevel} from "../../../settings/SettingLevel"; +import {Widget} from "matrix-widget-api"; +import {OIDCState, WidgetPermissionStore} from "../../../stores/widgets/WidgetPermissionStore"; export default class WidgetOpenIDPermissionsDialog extends React.Component { static propTypes = { onFinished: PropTypes.func.isRequired, - widgetUrl: PropTypes.string.isRequired, - widgetId: PropTypes.string.isRequired, - isUserWidget: PropTypes.bool.isRequired, + widget: PropTypes.objectOf(Widget).isRequired, + widgetKind: PropTypes.string.isRequired, // WidgetKind from widget-api + inRoomId: PropTypes.string, }; constructor() { @@ -51,16 +50,10 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component { if (this.state.rememberSelection) { console.log(`Remembering ${this.props.widgetId} as allowed=${allowed} for OpenID`); - const currentValues = SettingsStore.getValue("widgetOpenIDPermissions"); - if (!currentValues.allow) currentValues.allow = []; - if (!currentValues.deny) currentValues.deny = []; - - 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); + WidgetPermissionStore.instance.setOIDCState( + this.props.widget, this.props.widgetKind, this.props.inRoomId, + allowed ? OIDCState.Allowed : OIDCState.Denied, + ); } 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. " + "By allowing this, the widget will be able to verify your user ID, but not " + "perform actions as you.", { - widgetUrl: this.props.widgetUrl.split("?")[0], + widgetUrl: this.props.widget.templateUrl.split("?")[0], }, )}

diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index f3ebe24c15..b862a1e912 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -23,7 +23,6 @@ import PropTypes from 'prop-types'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import AccessibleButton from './AccessibleButton'; import { _t } from '../../../languageHandler'; -import * as sdk from '../../../index'; import AppPermission from './AppPermission'; import AppWarning from './AppWarning'; import Spinner from './Spinner'; @@ -375,19 +374,18 @@ export default class AppTile extends React.Component { />
); - // 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 - // re-mounted later when we do. - if (this.props.whitelistCapabilities.includes('m.always_on_screen')) { - const PersistedElement = sdk.getComponent("elements.PersistedElement"); - // Also wrap the PersistedElement in a div to fix the height, otherwise - // AppTile's border is in the wrong place - appTileBody =
- - {appTileBody} - -
; - } + + // all widgets can theoretically be allowed to remain on screen, so we wrap + // them all in a PersistedElement from the get-go. If we wait, the iframe will + // be re-mounted later, which means the widget has to start over, which is bad. + + // Also wrap the PersistedElement in a div to fix the height, otherwise + // AppTile's border is in the wrong place + appTileBody =
+ + {appTileBody} + +
; } } @@ -474,10 +472,6 @@ AppTile.propTypes = { handleMinimisePointerEvents: PropTypes.bool, // Optionally hide the popout widget icon 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 userWidget: PropTypes.bool, }; @@ -488,7 +482,6 @@ AppTile.defaultProps = { showTitle: true, showPopout: true, handleMinimisePointerEvents: false, - whitelistCapabilities: [], userWidget: false, miniMode: false, }; diff --git a/src/components/views/elements/Field.tsx b/src/components/views/elements/Field.tsx index 7fd154047d..fb34f51b60 100644 --- a/src/components/views/elements/Field.tsx +++ b/src/components/views/elements/Field.tsx @@ -64,7 +64,7 @@ interface IProps { // All other props pass through to the . } -interface IInputProps extends IProps, InputHTMLAttributes { +export interface IInputProps extends IProps, InputHTMLAttributes { // The element to create. Defaults to "input". element?: "input"; // The input's value. This is a controlled component, so the value is required. diff --git a/src/components/views/elements/PersistentApp.js b/src/components/views/elements/PersistentApp.js index 64825dfc96..a1e805c085 100644 --- a/src/components/views/elements/PersistentApp.js +++ b/src/components/views/elements/PersistentApp.js @@ -71,7 +71,6 @@ export default class PersistentApp extends React.Component { appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(), persistentWidgetInRoomId, appEvent.getId(), ); - const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, persistentWidgetInRoomId); const AppTile = sdk.getComponent('elements.AppTile'); return ; diff --git a/src/components/views/elements/Validation.tsx b/src/components/views/elements/Validation.tsx index 31f7c866b1..1b0659ab9b 100644 --- a/src/components/views/elements/Validation.tsx +++ b/src/components/views/elements/Validation.tsx @@ -32,7 +32,7 @@ interface IRule { interface IArgs { rules: IRule[]; - description(this: T, derivedData: D): React.ReactChild; + description?(this: T, derivedData: D): React.ReactChild; hideDescriptionIfValid?: boolean; deriveData?(data: Data): Promise; } diff --git a/src/components/views/right_panel/WidgetCard.tsx b/src/components/views/right_panel/WidgetCard.tsx index 7dbb77df18..c1753e90e3 100644 --- a/src/components/views/right_panel/WidgetCard.tsx +++ b/src/components/views/right_panel/WidgetCard.tsx @@ -103,7 +103,6 @@ const WidgetCard: React.FC = ({ room, widgetId, onClose }) => { creatorUserId={app.creatorUserId} widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)} waitForIframeLoad={app.waitForIframeLoad} - whitelistCapabilities={WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, room.roomId)} /> ; }; diff --git a/src/components/views/rooms/AppsDrawer.js b/src/components/views/rooms/AppsDrawer.js index 9cf213b44e..3208844bc5 100644 --- a/src/components/views/rooms/AppsDrawer.js +++ b/src/components/views/rooms/AppsDrawer.js @@ -210,8 +210,6 @@ export default class AppsDrawer extends React.Component { if (!this.props.showApps) return
; const apps = this.state.apps.map((app, index, arr) => { - const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, this.props.room.roomId); - return (); }); diff --git a/src/components/views/rooms/Stickerpicker.js b/src/components/views/rooms/Stickerpicker.js index ae7ed48898..0b81f82721 100644 --- a/src/components/views/rooms/Stickerpicker.js +++ b/src/components/views/rooms/Stickerpicker.js @@ -280,7 +280,6 @@ export default class Stickerpicker extends React.Component { showPopout={false} onMinimiseClick={this._onHideStickersClick} handleMinimisePointerEvents={true} - whitelistCapabilities={['m.sticker', 'visibility']} userWidget={true} /> diff --git a/src/components/views/voip/CallPreview.tsx b/src/components/views/voip/CallPreview.tsx index 3d9235792b..8e1b0dd963 100644 --- a/src/components/views/voip/CallPreview.tsx +++ b/src/components/views/voip/CallPreview.tsx @@ -26,6 +26,15 @@ import PersistentApp from "../elements/PersistentApp"; import SettingsStore from "../../../settings/SettingsStore"; 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 { } @@ -94,14 +103,13 @@ export default class CallPreview extends React.Component { const callForRoom = CallHandler.sharedInstance().getCallForRoom(this.state.roomId); const showCall = ( this.state.activeCall && - this.state.activeCall.state === CallState.Connected && + SHOW_CALL_IN_STATES.includes(this.state.activeCall.state) && !callForRoom ); if (showCall) { return ( diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 653a72cca0..db6d2b7ae0 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -21,12 +21,13 @@ import dis from '../../../dispatcher/dispatcher'; import CallHandler from '../../../CallHandler'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; -import AccessibleButton from '../elements/AccessibleButton'; import VideoFeed, { VideoFeedType } from "./VideoFeed"; import RoomAvatar from "../avatars/RoomAvatar"; -import PulsedAvatar from '../avatars/PulsedAvatar'; import { CallState, CallType, MatrixCall } 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 { // 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. onResize?: any; - // classname applied to view, - className?: string; - // Whether to show the hang up icon:W showHangup?: boolean; } @@ -53,6 +51,10 @@ interface IProps { interface IState { call: MatrixCall; isLocalOnHold: boolean, + micMuted: boolean, + vidMuted: boolean, + callState: CallState, + controlsVisible: boolean, } function getFullScreenElement() { @@ -83,10 +85,15 @@ function exitFullscreen() { 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 { private dispatcherRef: string; - private container = createRef(); - + private contentRef = createRef(); + private controlsHideTimer: number = null; constructor(props: IProps) { super(props); @@ -94,6 +101,10 @@ export default class CallView extends React.Component { this.state = { call, 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); @@ -101,9 +112,11 @@ export default class CallView extends React.Component { public componentDidMount() { this.dispatcherRef = dis.register(this.onAction); + document.addEventListener('keydown', this.onNativeKeyDown); } public componentWillUnmount() { + document.removeEventListener("keydown", this.onNativeKeyDown); this.updateCallListeners(this.state.call, null); dis.unregister(this.dispatcherRef); } @@ -111,11 +124,11 @@ export default class CallView extends React.Component { private onAction = (payload) => { switch (payload.action) { case 'video_fullscreen': { - if (!this.container.current) { + if (!this.contentRef.current) { return; } if (payload.fullscreen) { - requestFullscreen(this.container.current); + requestFullscreen(this.contentRef.current); } else if (getFullScreenElement()) { exitFullscreen(); } @@ -125,9 +138,21 @@ export default class CallView extends React.Component { const newCall = this.getCall(); if (newCall !== this.state.call) { 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({ call: newCall, 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()) { @@ -144,11 +169,6 @@ export default class CallView extends React.Component { if (this.props.room) { const roomId = this.props.room.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 { call = CallHandler.sharedInstance().getAnyActiveCall(); // Ignore calls if we can't get the room associated with them. @@ -160,7 +180,7 @@ export default class CallView extends React.Component { } } - if (call && call.state == CallState.Ended) return null; + if (call && [CallState.Ended, CallState.Ringing].includes(call.state)) return null; return call; } @@ -177,67 +197,240 @@ export default class CallView extends React.Component { }); }; - public render() { - let view: React.ReactNode; + private onFullscreenClick = () => { + dis.dispatch({ + action: 'video_fullscreen', + fullscreen: true, + }); + }; - if (this.state.call) { - if (this.state.call.type === "voice") { - const client = MatrixClientPeg.get(); - const callRoom = client.getRoom(this.state.call.roomId); + private onExpandClick = () => { + dis.dispatch({ + action: 'view_room', + room_id: this.state.call.roomId, + }); + }; - let caption = _t("Active call"); - if (this.state.isLocalOnHold) { - // we currently have no UI for holding / unholding a call (apart from slash - // commands) so we don't disintguish between when we've put the call on hold - // (ie. we'd show an unhold button) and when the other side has put us on hold - // (where obviously we would not show such a button). - caption = _t("Call Paused"); + 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; - view = - - - -
-

{callRoom.name}

-

{ caption }

-
-
; - } 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 =
- - -
; - } + case Key.E: + if (ctrlCmdOnly) { + this.onVidMuteClick(); + // show the controls to give feedback + this.showControls(); + handled = true; + } + break; } - let hangup: React.ReactNode; - if (this.props.showHangup) { - hangup =
{ - dis.dispatch({ - action: 'hangup', - room_id: this.state.call.roomId, - }); - }} + 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; + + const client = MatrixClientPeg.get(); + const callRoom = client.getRoom(this.state.call.roomId); + + let callControls; + if (this.props.room) { + const micClasses = classNames({ + mx_CallView_callControls_button: true, + mx_CallView_callControls_button_micOn: !this.state.micMuted, + mx_CallView_callControls_button_micOff: this.state.micMuted, + }); + + const vidClasses = classNames({ + mx_CallView_callControls_button: true, + mx_CallView_callControls_button_vidOn: !this.state.vidMuted, + mx_CallView_callControls_button_vidOff: this.state.vidMuted, + }); + + // 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 ?
: null; + + callControls =
+
+
{ + dis.dispatch({ + action: 'hangup', + room_id: this.state.call.roomId, + }); + }} + /> + {vidMuteButton} +
+
+
; + } + + // 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 =
+ + + {callControls} +
; + } else { + const avatarSize = this.props.room ? 200 : 75; + contentView =
+ + {callControls} +
; + } + + 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 =
; } - return
- {view} - {hangup} + let expandButton; + if (!this.props.room) { + expandButton =
; + } + + const headerControls =
+ {fullScreenButton} + {expandButton} +
; + + let header: React.ReactNode; + if (this.props.room) { + header =
+
+ {callTypeText} + {headerControls} +
; + myClassName = 'mx_CallView_large'; + } else { + header =
+ + + +
+
{callRoom.name}
+
{callTypeText}
+
+ {headerControls} +
; + myClassName = 'mx_CallView_pip'; + } + + return
+ {header} + {contentView}
; } } diff --git a/src/components/views/voip/IncomingCallBox.tsx b/src/components/views/voip/IncomingCallBox.tsx index 355dff9ff6..0403a9eb75 100644 --- a/src/components/views/voip/IncomingCallBox.tsx +++ b/src/components/views/voip/IncomingCallBox.tsx @@ -22,7 +22,6 @@ import dis from '../../../dispatcher/dispatcher'; import { _t } from '../../../languageHandler'; import { ActionPayload } from '../../../dispatcher/payloads'; import CallHandler from '../../../CallHandler'; -import PulsedAvatar from '../avatars/PulsedAvatar'; import RoomAvatar from '../avatars/RoomAvatar'; import FormButton from '../elements/FormButton'; import { CallState } from 'matrix-js-sdk/lib/webrtc/call'; @@ -108,13 +107,11 @@ export default class IncomingCallBox extends React.Component { return
- - - +

{caller}

{incomingCallText}

diff --git a/src/components/views/voip/VideoFeed.tsx b/src/components/views/voip/VideoFeed.tsx index 9dba9fa9c8..5fb71a6d69 100644 --- a/src/components/views/voip/VideoFeed.tsx +++ b/src/components/views/voip/VideoFeed.tsx @@ -73,8 +73,6 @@ export default class VideoFeed extends React.Component { let videoStyle = {}; if (this.props.maxHeight) videoStyle = { maxHeight: this.props.maxHeight }; - return
- -
; + return