diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index aa2a6b7f0b..db90d26ba7 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -1,36 +1,15 @@ # autogenerated file: run scripts/generate-eslint-error-ignore-file to update. -src/ImageUtils.js src/Markdown.js -src/Rooms.js -src/Unread.js src/Velociraptor.js src/components/structures/RoomDirectory.js -src/components/structures/ScrollPanel.js -src/components/structures/UploadBar.js -src/components/views/elements/AddressSelector.js -src/components/views/elements/DirectorySearchBox.js -src/components/views/messages/MFileBody.js -src/components/views/messages/TextualBody.js -src/components/views/rooms/AuxPanel.js -src/components/views/rooms/LinkPreviewWidget.js src/components/views/rooms/MemberList.js -src/components/views/rooms/RoomPreviewBar.js -src/components/views/settings/ChangeAvatar.js -src/components/views/settings/DevicesPanel.js -src/components/views/settings/Notifications.js -src/rageshake/rageshake.js src/ratelimitedfunc.js src/utils/DMRoomMap.js -src/utils/DecryptFile.js -src/utils/DirectoryUtils.js src/utils/MultiInviter.js -src/utils/Receipt.js test/components/structures/MessagePanel-test.js test/components/views/dialogs/InteractiveAuthDialog-test.js test/mock-clock.js -test/notifications/ContentRules-test.js -test/notifications/PushRuleVectorState-test.js src/component-index.js test/end-to-end-tests/node_modules/ test/end-to-end-tests/riot/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a22954c3f..13c5ba81ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,198 @@ +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) + + * Upgrade JS SDK to 9.1.0 + +Changes in [3.8.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.8.0-rc.1) (2020-11-04) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.7.1...v3.8.0-rc.1) + + * Upgrade JS SDK to 9.1.0-rc.1 + * Log when saving profile + [\#5394](https://github.com/matrix-org/matrix-react-sdk/pull/5394) + * Translations update from Weblate + [\#5395](https://github.com/matrix-org/matrix-react-sdk/pull/5395) + * Hide prompt to add email for notifications if 3pid ui feature is off + [\#5392](https://github.com/matrix-org/matrix-react-sdk/pull/5392) + * Fix room list message preview copy for hangup events + [\#5388](https://github.com/matrix-org/matrix-react-sdk/pull/5388) + * Track UISIs as Countly Events + [\#5382](https://github.com/matrix-org/matrix-react-sdk/pull/5382) + * Don't let users accidentally redact ACL events + [\#5384](https://github.com/matrix-org/matrix-react-sdk/pull/5384) + * Two more easy files to remove from eslintignore + [\#5378](https://github.com/matrix-org/matrix-react-sdk/pull/5378) + * Fix Widget OpenID Permissions for realsies + [\#5381](https://github.com/matrix-org/matrix-react-sdk/pull/5381) + * Fix regression with OpenID permissions on widgets + [\#5380](https://github.com/matrix-org/matrix-react-sdk/pull/5380) + * Fix room directory events happening in the wrong order for Funnels + [\#5379](https://github.com/matrix-org/matrix-react-sdk/pull/5379) + * Remove a couple more files from eslintignore + [\#5377](https://github.com/matrix-org/matrix-react-sdk/pull/5377) + * Fix countly method bindings and errors + [\#5376](https://github.com/matrix-org/matrix-react-sdk/pull/5376) + * Fix a bunch of silly lint errors + [\#5375](https://github.com/matrix-org/matrix-react-sdk/pull/5375) + * Typescript: ImageUtils + [\#5374](https://github.com/matrix-org/matrix-react-sdk/pull/5374) + * Convert AuxPanel to TypeScript + [\#5373](https://github.com/matrix-org/matrix-react-sdk/pull/5373) + * Only pass metrics if they exist otherwise Countly will be unhappy! + [\#5372](https://github.com/matrix-org/matrix-react-sdk/pull/5372) + * Fix CountlyAnalytics NPE on MatrixClientPeg + [\#5370](https://github.com/matrix-org/matrix-react-sdk/pull/5370) + * fix CountlyAnalytics canEnable on wrong target + [\#5369](https://github.com/matrix-org/matrix-react-sdk/pull/5369) + * Initial Countly work + [\#5365](https://github.com/matrix-org/matrix-react-sdk/pull/5365) + * Fix videos not playing in non-encrypted rooms + [\#5368](https://github.com/matrix-org/matrix-react-sdk/pull/5368) + * Fix custom tag layout which regressed in #5309 + [\#5367](https://github.com/matrix-org/matrix-react-sdk/pull/5367) + * Watch replyToEvent at RoomView to prevent races + [\#5360](https://github.com/matrix-org/matrix-react-sdk/pull/5360) + * Add a UI Feature flag for room history settings + [\#5362](https://github.com/matrix-org/matrix-react-sdk/pull/5362) + * Hide inline images when preference disabled + [\#5361](https://github.com/matrix-org/matrix-react-sdk/pull/5361) + * Fix React warning by moving handler to each button + [\#5359](https://github.com/matrix-org/matrix-react-sdk/pull/5359) + * Do not preload encrypted videos|images unless autoplay or thumbnailing is on + [\#5352](https://github.com/matrix-org/matrix-react-sdk/pull/5352) + * Fix theme variable passed to Jitsi + [\#5357](https://github.com/matrix-org/matrix-react-sdk/pull/5357) + * docs: added comment explanation + [\#5349](https://github.com/matrix-org/matrix-react-sdk/pull/5349) + * Modal Widgets - MSC2790 + [\#5252](https://github.com/matrix-org/matrix-react-sdk/pull/5252) + * Widgets fixes + [\#5350](https://github.com/matrix-org/matrix-react-sdk/pull/5350) + * Fix User Menu avatar colouring being based on wrong string + [\#5348](https://github.com/matrix-org/matrix-react-sdk/pull/5348) + * Support 'answered elsewhere' + [\#5345](https://github.com/matrix-org/matrix-react-sdk/pull/5345) + +Changes in [3.7.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.7.1) (2020-10-28) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.7.0...v3.7.1) + + * Upgrade JS SDK to 9.0.1 + * [Release] Fix theme variable passed to Jitsi + [\#5358](https://github.com/matrix-org/matrix-react-sdk/pull/5358) + * [Release] Widget fixes + [\#5351](https://github.com/matrix-org/matrix-react-sdk/pull/5351) + +Changes in [3.7.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.7.0) (2020-10-26) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.7.0-rc.2...v3.7.0) + + * Upgrade JS SDK to 9.0.0 + +Changes in [3.7.0-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.7.0-rc.2) (2020-10-21) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.7.0-rc.1...v3.7.0-rc.2) + + * Fix JS SDK dependency to use 9.0.0-rc.1 as intended + +Changes in [3.7.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.7.0-rc.1) (2020-10-21) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.6.1...v3.7.0-rc.1) + + * Upgrade JS SDK to 9.0.0-rc.1 + * Update Weblate URL + [\#5346](https://github.com/matrix-org/matrix-react-sdk/pull/5346) + * Translations update from Weblate + [\#5347](https://github.com/matrix-org/matrix-react-sdk/pull/5347) + * Left Panel Widget support + [\#5247](https://github.com/matrix-org/matrix-react-sdk/pull/5247) + * Pinned widgets work + [\#5266](https://github.com/matrix-org/matrix-react-sdk/pull/5266) + * Convert resizer to Typescript + [\#5343](https://github.com/matrix-org/matrix-react-sdk/pull/5343) + * Hide filtering microcopy when left panel is minimized + [\#5338](https://github.com/matrix-org/matrix-react-sdk/pull/5338) + * Skip editor confirmation of upgrades + [\#5344](https://github.com/matrix-org/matrix-react-sdk/pull/5344) + * Spec compliance, /search doesn't have to return results + [\#5337](https://github.com/matrix-org/matrix-react-sdk/pull/5337) + * Fix excessive hosting link padding + [\#5336](https://github.com/matrix-org/matrix-react-sdk/pull/5336) + * Adjust for new widget messaging APIs + [\#5341](https://github.com/matrix-org/matrix-react-sdk/pull/5341) + * Fix case where sublist context menu missed an update + [\#5339](https://github.com/matrix-org/matrix-react-sdk/pull/5339) + * Add analytics to VoIP + [\#5340](https://github.com/matrix-org/matrix-react-sdk/pull/5340) + * Fix Jitsi OpenIDC auth + [\#5334](https://github.com/matrix-org/matrix-react-sdk/pull/5334) + * Support rejecting calls + [\#5324](https://github.com/matrix-org/matrix-react-sdk/pull/5324) + * Don't show admin tooling if we're not in the room + [\#5330](https://github.com/matrix-org/matrix-react-sdk/pull/5330) + * Show Integrations error if iframe failed to load too + [\#5328](https://github.com/matrix-org/matrix-react-sdk/pull/5328) + * Add security customisation points + [\#5327](https://github.com/matrix-org/matrix-react-sdk/pull/5327) + * Discard all mx_fadable legacy cruft which is totally useless + [\#5326](https://github.com/matrix-org/matrix-react-sdk/pull/5326) + * Fix background-image: url(null) for backdrop filter + [\#5319](https://github.com/matrix-org/matrix-react-sdk/pull/5319) + * Make the ACL update message less noisy + [\#5316](https://github.com/matrix-org/matrix-react-sdk/pull/5316) + * Fix aspect ratio of avatar before clicking Save + [\#5318](https://github.com/matrix-org/matrix-react-sdk/pull/5318) + * Don't supply popout widgets with widget parameters + [\#5323](https://github.com/matrix-org/matrix-react-sdk/pull/5323) + * Changed rainbow algorithm + [\#5301](https://github.com/matrix-org/matrix-react-sdk/pull/5301) + * Renamed TagPanel and TagOrderStore + [\#5309](https://github.com/matrix-org/matrix-react-sdk/pull/5309) + * Fix/clarify boolean logic for reaction previews + [\#5321](https://github.com/matrix-org/matrix-react-sdk/pull/5321) + * Support glare for VoIP calls + [\#5311](https://github.com/matrix-org/matrix-react-sdk/pull/5311) + * Round of Typescript conversions + [\#5314](https://github.com/matrix-org/matrix-react-sdk/pull/5314) + * Fix broken rendering of Room Create when showHiddenEvents enabled + [\#5317](https://github.com/matrix-org/matrix-react-sdk/pull/5317) + * Improve LHS resize performance and tidy stale props&classes + [\#5313](https://github.com/matrix-org/matrix-react-sdk/pull/5313) + * event-index: Pass the user/device id pair when initializing the event index. + [\#5312](https://github.com/matrix-org/matrix-react-sdk/pull/5312) + * Fix various aspects of (jitsi) widgets + [\#5315](https://github.com/matrix-org/matrix-react-sdk/pull/5315) + * Fix rogue (partial) call bar + [\#5310](https://github.com/matrix-org/matrix-react-sdk/pull/5310) + * Rewrite call state machine + [\#5308](https://github.com/matrix-org/matrix-react-sdk/pull/5308) + * Convert `src/SecurityManager.js` to TypeScript + [\#5307](https://github.com/matrix-org/matrix-react-sdk/pull/5307) + * Fix templating for v1 jitsi widgets + [\#5305](https://github.com/matrix-org/matrix-react-sdk/pull/5305) + * Use new preparing event for widget communications + [\#5303](https://github.com/matrix-org/matrix-react-sdk/pull/5303) + * Fix parsing issue in event tile preview for appearance tab + [\#5302](https://github.com/matrix-org/matrix-react-sdk/pull/5302) + * Track replyToEvent along with Cider state & history + [\#5284](https://github.com/matrix-org/matrix-react-sdk/pull/5284) + * Roving Tab Index should not interfere with inputs + [\#5299](https://github.com/matrix-org/matrix-react-sdk/pull/5299) + * Visual tweaks from 2020-10-06 polishing + [\#5298](https://github.com/matrix-org/matrix-react-sdk/pull/5298) + * Convert auth lifecycle to TS, remove dead ILAG code + [\#5296](https://github.com/matrix-org/matrix-react-sdk/pull/5296) + +Changes in [3.6.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.6.1) (2020-10-20) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.6.0...v3.6.1) + + * [Release] Adjust for new widget messaging APIs + [\#5342](https://github.com/matrix-org/matrix-react-sdk/pull/5342) + * [Release] Fix Jitsi OpenIDC auth + [\#5335](https://github.com/matrix-org/matrix-react-sdk/pull/5335) + Changes in [3.6.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.6.0) (2020-10-12) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.6.0-rc.1...v3.6.0) diff --git a/README.md b/README.md index 4db02418ba..73afe34df0 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ are currently filed against vector-im/element-web rather than this project). Translation Status ================== -[![Translation status](https://translate.riot.im/widgets/element-web/-/multi-auto.svg)](https://translate.riot.im/engage/element-web/?utm_source=widget) +[![Translation status](https://translate.element.io/widgets/element-web/-/multi-auto.svg)](https://translate.element.io/engage/element-web/?utm_source=widget) Developer Guide =============== diff --git a/package.json b/package.json index 3f073ce59c..a015728256 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.6.0", + "version": "3.8.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -79,7 +79,7 @@ "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.3", + "matrix-widget-api": "^0.1.0-beta.8", "minimist": "^1.2.5", "pako": "^1.0.11", "parse5": "^5.1.1", diff --git a/release.sh b/release.sh index e2cefcbe74..4742f00dea 100755 --- a/release.sh +++ b/release.sh @@ -32,9 +32,7 @@ do echo "Upgrading $i to $latestver..." yarn add -E $i@$latestver git add -u - # The `-e` flag opens the editor and gives you a chance to check - # the upgrade for correctness. - git commit -m "Upgrade $i to $latestver" -e + git commit -m "Upgrade $i to $latestver" fi fi done diff --git a/res/css/_common.scss b/res/css/_common.scss index 3346394edd..0317e89d20 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -17,6 +17,7 @@ limitations under the License. */ @import "./_font-sizes.scss"; +@import "./_font-weights.scss"; $hover-transition: 0.08s cubic-bezier(.46, .03, .52, .96); // quadratic @@ -208,12 +209,6 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { border: 0; } -/* applied to side-panels and messagepanel when in RoomSettings */ -.mx_fadable { - opacity: 1; - transition: opacity 0.2s ease-in-out; -} - // These are magic constants which are excluded from tinting, to let themes // (which only have CSS, unlike skins) tell the app what their non-tinted // colourscheme is by inspecting the stylesheet DOM. @@ -329,6 +324,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { .mx_Dialog_title { font-size: $font-22px; + font-weight: $font-semi-bold; line-height: $font-36px; color: $dialog-title-fg-color; } @@ -354,8 +350,8 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { background-color: $dialog-close-fg-color; cursor: pointer; position: absolute; - top: 4px; - right: 0px; + top: 10px; + right: 0; } .mx_Dialog_content { diff --git a/res/css/_components.scss b/res/css/_components.scss index 06cdbdcb4b..eae67a84a2 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -9,10 +9,12 @@ @import "./structures/_CustomRoomTagPanel.scss"; @import "./structures/_FilePanel.scss"; @import "./structures/_GenericErrorPage.scss"; +@import "./structures/_GroupFilterPanel.scss"; @import "./structures/_GroupView.scss"; @import "./structures/_HeaderButtons.scss"; @import "./structures/_HomePage.scss"; @import "./structures/_LeftPanel.scss"; +@import "./structures/_LeftPanelWidget.scss"; @import "./structures/_MainSplit.scss"; @import "./structures/_MatrixChat.scss"; @import "./structures/_MyGroups.scss"; @@ -26,7 +28,6 @@ @import "./structures/_ScrollPanel.scss"; @import "./structures/_SearchBox.scss"; @import "./structures/_TabbedView.scss"; -@import "./structures/_TagPanel.scss"; @import "./structures/_ToastContainer.scss"; @import "./structures/_UploadBar.scss"; @import "./structures/_UserMenu.scss"; @@ -51,11 +52,11 @@ @import "./views/avatars/_DecoratedRoomAvatar.scss"; @import "./views/avatars/_MemberStatusMessageAvatar.scss"; @import "./views/avatars/_PulsedAvatar.scss"; +@import "./views/avatars/_WidgetAvatar.scss"; @import "./views/context_menus/_IconizedContextMenu.scss"; @import "./views/context_menus/_MessageContextMenu.scss"; @import "./views/context_menus/_StatusMessageContextMenu.scss"; @import "./views/context_menus/_TagTileContextMenu.scss"; -@import "./views/context_menus/_WidgetContextMenu.scss"; @import "./views/dialogs/_AddressPickerDialog.scss"; @import "./views/dialogs/_Analytics.scss"; @import "./views/dialogs/_BugReportDialog.scss"; @@ -69,11 +70,13 @@ @import "./views/dialogs/_DeactivateAccountDialog.scss"; @import "./views/dialogs/_DevtoolsDialog.scss"; @import "./views/dialogs/_EditCommunityPrototypeDialog.scss"; +@import "./views/dialogs/_FeedbackDialog.scss"; @import "./views/dialogs/_GroupAddressPicker.scss"; @import "./views/dialogs/_IncomingSasDialog.scss"; @import "./views/dialogs/_InviteDialog.scss"; @import "./views/dialogs/_KeyboardShortcutsDialog.scss"; @import "./views/dialogs/_MessageEditHistoryDialog.scss"; +@import "./views/dialogs/_ModalWidgetDialog.scss"; @import "./views/dialogs/_NewSessionReviewDialog.scss"; @import "./views/dialogs/_RoomSettingsDialog.scss"; @import "./views/dialogs/_RoomSettingsDialogBridges.scss"; @@ -107,11 +110,11 @@ @import "./views/elements/_EventListSummary.scss"; @import "./views/elements/_Field.scss"; @import "./views/elements/_FormButton.scss"; -@import "./views/elements/_IconButton.scss"; @import "./views/elements/_ImageView.scss"; @import "./views/elements/_InfoTooltip.scss"; @import "./views/elements/_InlineSpinner.scss"; @import "./views/elements/_ManageIntegsButton.scss"; +@import "./views/elements/_MiniAvatarUploader.scss"; @import "./views/elements/_PowerSelector.scss"; @import "./views/elements/_ProgressBar.scss"; @import "./views/elements/_QRCode.scss"; @@ -136,6 +139,7 @@ @import "./views/groups/_GroupUserSettings.scss"; @import "./views/messages/_CreateEvent.scss"; @import "./views/messages/_DateSeparator.scss"; +@import "./views/messages/_EventTileBubble.scss"; @import "./views/messages/_MEmoteBody.scss"; @import "./views/messages/_MFileBody.scss"; @import "./views/messages/_MImageBody.scss"; @@ -179,6 +183,7 @@ @import "./views/rooms/_MemberList.scss"; @import "./views/rooms/_MessageComposer.scss"; @import "./views/rooms/_MessageComposerFormatBar.scss"; +@import "./views/rooms/_NewRoomIntro.scss"; @import "./views/rooms/_NotificationBadge.scss"; @import "./views/rooms/_PinnedEventTile.scss"; @import "./views/rooms/_PinnedEventsPanel.scss"; @@ -222,8 +227,9 @@ @import "./views/settings/tabs/user/_SecurityUserSettingsTab.scss"; @import "./views/settings/tabs/user/_VoiceUserSettingsTab.scss"; @import "./views/terms/_InlineTermsAgreement.scss"; +@import "./views/toasts/_AnalyticsToast.scss"; @import "./views/toasts/_NonUrgentEchoFailureToast.scss"; @import "./views/verification/_VerificationShowSas.scss"; @import "./views/voip/_CallContainer.scss"; @import "./views/voip/_CallView.scss"; -@import "./views/voip/_VideoView.scss"; +@import "./views/voip/_VideoFeed.scss"; diff --git a/res/css/structures/_CustomRoomTagPanel.scss b/res/css/structures/_CustomRoomTagPanel.scss index 3feb2565be..be1138cf5b 100644 --- a/res/css/structures/_CustomRoomTagPanel.scss +++ b/res/css/structures/_CustomRoomTagPanel.scss @@ -16,13 +16,8 @@ limitations under the License. // TODO: Update design for custom tags to match new designs -.mx_LeftPanel_tagPanelContainer { - display: flex; - flex-direction: column; -} - .mx_CustomRoomTagPanel { - background-color: $tagpanel-bg-color; + background-color: $groupFilterPanel-bg-color; max-height: 40vh; } diff --git a/res/css/structures/_TagPanel.scss b/res/css/structures/_GroupFilterPanel.scss similarity index 80% rename from res/css/structures/_TagPanel.scss rename to res/css/structures/_GroupFilterPanel.scss index cdca1f0764..e5a8ef6df2 100644 --- a/res/css/structures/_TagPanel.scss +++ b/res/css/structures/_GroupFilterPanel.scss @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_TagPanel { +.mx_GroupFilterPanel { flex: 1; - background-color: $tagpanel-bg-color; + background-color: $groupFilterPanel-bg-color; cursor: pointer; display: flex; @@ -26,49 +26,49 @@ limitations under the License. min-height: 0; } -.mx_TagPanel_items_selected { +.mx_GroupFilterPanel_items_selected { cursor: pointer; } -.mx_TagPanel .mx_TagPanel_divider { +.mx_GroupFilterPanel .mx_GroupFilterPanel_divider { height: 0px; width: 90%; border: none; - border-bottom: 1px solid $tagpanel-divider-color; + border-bottom: 1px solid $groupFilterPanel-divider-color; } -.mx_TagPanel .mx_TagPanel_scroller { +.mx_GroupFilterPanel .mx_GroupFilterPanel_scroller { flex-grow: 1; width: 100%; } -.mx_TagPanel .mx_TagPanel_tagTileContainer { +.mx_GroupFilterPanel .mx_GroupFilterPanel_tagTileContainer { display: flex; flex-direction: column; align-items: center; padding-top: 6px; } -.mx_TagPanel .mx_TagPanel_tagTileContainer > div { +.mx_GroupFilterPanel .mx_GroupFilterPanel_tagTileContainer > div { margin: 6px 0; } -.mx_TagPanel .mx_TagTile { +.mx_GroupFilterPanel .mx_TagTile { // opacity: 0.5; position: relative; } -.mx_TagPanel .mx_TagTile.mx_TagTile_prototype { +.mx_GroupFilterPanel .mx_TagTile.mx_TagTile_prototype { padding: 3px; } -.mx_TagPanel .mx_TagTile:focus, -.mx_TagPanel .mx_TagTile:hover, -.mx_TagPanel .mx_TagTile.mx_TagTile_selected { +.mx_GroupFilterPanel .mx_TagTile:focus, +.mx_GroupFilterPanel .mx_TagTile:hover, +.mx_GroupFilterPanel .mx_TagTile.mx_TagTile_selected { // opacity: 1; } -.mx_TagPanel .mx_TagTile.mx_TagTile_selected_prototype { +.mx_GroupFilterPanel .mx_TagTile.mx_TagTile_selected_prototype { background-color: $primary-bg-color; border-radius: 6px; } @@ -108,7 +108,7 @@ limitations under the License. } } -.mx_TagPanel .mx_TagTile_plus { +.mx_GroupFilterPanel .mx_TagTile_plus { margin-bottom: 12px; height: 32px; width: 32px; @@ -132,7 +132,7 @@ limitations under the License. } } -.mx_TagPanel .mx_TagTile.mx_TagTile_selected::before { +.mx_GroupFilterPanel .mx_TagTile.mx_TagTile_selected::before { content: ''; height: 100%; background-color: $accent-color; @@ -142,7 +142,7 @@ limitations under the License. border-radius: 0 3px 3px 0; } -.mx_TagPanel .mx_TagTile.mx_AccessibleButton:focus { +.mx_GroupFilterPanel .mx_TagTile.mx_AccessibleButton:focus { filter: none; } diff --git a/res/css/structures/_HomePage.scss b/res/css/structures/_HomePage.scss index 04527bff48..9f72213d1a 100644 --- a/res/css/structures/_HomePage.scss +++ b/res/css/structures/_HomePage.scss @@ -26,9 +26,10 @@ limitations under the License. .mx_HomePage_default { text-align: center; + display: flex; .mx_HomePage_default_wrapper { - padding: 25vh 0 12px; + margin: auto; } img { @@ -50,56 +51,54 @@ limitations under the License. color: $muted-fg-color; } + .mx_MiniAvatarUploader { + margin: 0 auto; + } + .mx_HomePage_default_buttons { - margin: 80px auto 0; + margin: 60px auto 0; width: fit-content; .mx_AccessibleButton { padding: 73px 8px 15px; // top: 20px top padding + 40px icon + 13px margin - width: 104px; // 120px - 2* 8px - margin: 0 39px; // 55px - 2* 8px + width: 160px; + height: 132px; + margin: 20px; position: relative; display: inline-block; border-radius: 8px; vertical-align: top; word-break: break-word; + box-sizing: border-box; font-weight: 600; font-size: $font-15px; line-height: $font-20px; - color: $muted-fg-color; - - &:hover { - color: $accent-color; - background: rgba($accent-color, 0.06); - - &::before { - background-color: $accent-color; - } - } + color: #fff; // on all themes + background-color: $accent-color; &::before { top: 20px; - left: 40px; // (120px-40px)/2 + left: 60px; // (160px-40px)/2 width: 40px; height: 40px; content: ''; position: absolute; - background-color: $muted-fg-color; + background-color: #fff; // on all themes mask-repeat: no-repeat; mask-size: contain; } &.mx_HomePage_button_sendDm::before { - mask-image: url('$(res)/img/feather-customised/message-circle.svg'); + mask-image: url('$(res)/img/element-icons/feedback.svg'); } &.mx_HomePage_button_explore::before { - mask-image: url('$(res)/img/feather-customised/explore.svg'); + mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); } &.mx_HomePage_button_createGroup::before { - mask-image: url('$(res)/img/feather-customised/group.svg'); + mask-image: url('$(res)/img/element-icons/community-members.svg'); } } } diff --git a/res/css/structures/_LeftPanel.scss b/res/css/structures/_LeftPanel.scss index 5112d07c46..1424d9cda0 100644 --- a/res/css/structures/_LeftPanel.scss +++ b/res/css/structures/_LeftPanel.scss @@ -14,29 +14,30 @@ See the License for the specific language governing permissions and limitations under the License. */ -$tagPanelWidth: 56px; // only applies in this file, used for calculations +$groupFilterPanelWidth: 56px; // only applies in this file, used for calculations .mx_LeftPanel { background-color: $roomlist-bg-color; min-width: 260px; max-width: 50%; - // Create a row-based flexbox for the TagPanel and the room list + // Create a row-based flexbox for the GroupFilterPanel and the room list display: flex; - .mx_LeftPanel_tagPanelContainer { + .mx_LeftPanel_GroupFilterPanelContainer { flex-grow: 0; flex-shrink: 0; - flex-basis: $tagPanelWidth; + flex-basis: $groupFilterPanelWidth; height: 100%; - // Create another flexbox so the TagPanel fills the container + // Create another flexbox so the GroupFilterPanel fills the container display: flex; + flex-direction: column; - // TagPanel handles its own CSS + // GroupFilterPanel handles its own CSS } - &:not(.mx_LeftPanel_hasTagPanel) { + &:not(.mx_LeftPanel_hasGroupFilterPanel) { .mx_LeftPanel_roomListContainer { width: 100%; } @@ -45,7 +46,7 @@ $tagPanelWidth: 56px; // only applies in this file, used for calculations // Note: The 'room list' in this context is actually everything that isn't the tag // panel, such as the menu options, breadcrumbs, filtering, etc .mx_LeftPanel_roomListContainer { - width: calc(100% - $tagPanelWidth); + width: calc(100% - $groupFilterPanelWidth); background-color: $roomlist-bg-color; // Create another flexbox (this time a column) for the room list components @@ -169,10 +170,10 @@ $tagPanelWidth: 56px; // only applies in this file, used for calculations min-width: unset; // We have to forcefully set the width to override the resizer's style attribute. - &.mx_LeftPanel_hasTagPanel { - width: calc(68px + $tagPanelWidth) !important; + &.mx_LeftPanel_hasGroupFilterPanel { + width: calc(68px + $groupFilterPanelWidth) !important; } - &:not(.mx_LeftPanel_hasTagPanel) { + &:not(.mx_LeftPanel_hasGroupFilterPanel) { width: 68px !important; } diff --git a/res/css/structures/_LeftPanelWidget.scss b/res/css/structures/_LeftPanelWidget.scss new file mode 100644 index 0000000000..4df651d7b6 --- /dev/null +++ b/res/css/structures/_LeftPanelWidget.scss @@ -0,0 +1,145 @@ +/* +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. +*/ + +.mx_LeftPanelWidget { + // largely based on RoomSublist + margin-left: 8px; + margin-bottom: 4px; + + .mx_LeftPanelWidget_headerContainer { + display: flex; + align-items: center; + + height: 24px; + color: $roomlist-header-color; + margin-top: 4px; + + .mx_LeftPanelWidget_stickable { + flex: 1; + max-width: 100%; + + display: flex; + align-items: center; + } + + .mx_LeftPanelWidget_headerText { + flex: 1; + max-width: calc(100% - 16px); + line-height: $font-16px; + font-size: $font-13px; + font-weight: 600; + + // Ellipsize any text overflow + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + + .mx_LeftPanelWidget_collapseBtn { + display: inline-block; + position: relative; + width: 14px; + height: 14px; + margin-right: 6px; + + &::before { + content: ''; + width: 18px; + height: 18px; + position: absolute; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background-color: $roomlist-header-color; + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); + } + + &.mx_LeftPanelWidget_collapseBtn_collapsed::before { + transform: rotate(-90deg); + } + } + } + } + + .mx_LeftPanelWidget_resizeBox { + position: relative; + + display: flex; + flex-direction: column; + overflow: visible; // let the resize handle out + } + + .mx_AppTileFullWidth { + flex: 1 0 0; + overflow: hidden; + // need this to be flex otherwise the overflow hidden from above + // sometimes vertically centers the clipped list ... no idea why it would do this + // as the box model should be top aligned. Happens in both FF and Chromium + display: flex; + flex-direction: column; + box-sizing: border-box; + + mask-image: linear-gradient(0deg, transparent, black 4px); + } + + .mx_LeftPanelWidget_resizerHandle { + cursor: ns-resize; + border-radius: 3px; + + // Override styles from library + width: unset !important; + height: 4px !important; + + position: absolute; + top: -24px !important; // override from library - puts it in the margin-top of the headerContainer + + // Together, these make the bar 64px wide + // These are also overridden from the library + left: calc(50% - 32px) !important; + right: calc(50% - 32px) !important; + } + + &:hover .mx_LeftPanelWidget_resizerHandle { + opacity: 0.8; + background-color: $primary-fg-color; + } + + .mx_LeftPanelWidget_maximizeButton { + margin-left: 8px; + margin-right: 7px; + position: relative; + width: 24px; + height: 24px; + border-radius: 32px; + + &::before { + content: ''; + width: 16px; + height: 16px; + position: absolute; + top: 4px; + left: 4px; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/feather-customised/widget/maximise.svg'); + background: $muted-fg-color; + } + } +} + +.mx_LeftPanelWidget_maximizeButtonTooltip { + margin-top: -3px; +} diff --git a/res/css/structures/_MatrixChat.scss b/res/css/structures/_MatrixChat.scss index f4e46a8e94..812a7f8472 100644 --- a/res/css/structures/_MatrixChat.scss +++ b/res/css/structures/_MatrixChat.scss @@ -79,7 +79,6 @@ limitations under the License. height: 100%; } -.mx_MatrixChat > .mx_LeftPanel2:hover + .mx_ResizeHandle_horizontal, .mx_MatrixChat > .mx_ResizeHandle_horizontal:hover { position: relative; diff --git a/res/css/structures/_RoomStatusBar.scss b/res/css/structures/_RoomStatusBar.scss index cd4390ee5c..2d5359c0eb 100644 --- a/res/css/structures/_RoomStatusBar.scss +++ b/res/css/structures/_RoomStatusBar.scss @@ -153,16 +153,6 @@ limitations under the License. display: block; } -.mx_RoomStatusBar_isAlone { - height: 50px; - line-height: $font-50px; - - color: $primary-fg-color; - opacity: 0.5; - overflow-y: hidden; - display: block; -} - .mx_MatrixChat_useCompactLayout { .mx_RoomStatusBar { min-height: 40px; diff --git a/res/css/structures/_UserMenu.scss b/res/css/structures/_UserMenu.scss index fecac40e4e..6a352d46a3 100644 --- a/res/css/structures/_UserMenu.scss +++ b/res/css/structures/_UserMenu.scss @@ -230,6 +230,10 @@ limitations under the License. align-items: center; justify-content: center; } + + &.mx_UserMenu_contextMenu_hostingLink { + padding-top: 0; + } } .mx_IconizedContextMenu_icon { diff --git a/res/css/views/auth/_InteractiveAuthEntryComponents.scss b/res/css/views/auth/_InteractiveAuthEntryComponents.scss index 05cddf2c48..0a5ac9b2bc 100644 --- a/res/css/views/auth/_InteractiveAuthEntryComponents.scss +++ b/res/css/views/auth/_InteractiveAuthEntryComponents.scss @@ -14,6 +14,35 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_InteractiveAuthEntryComponents_emailWrapper { + padding-right: 60px; + position: relative; + margin-top: 32px; + margin-bottom: 32px; + + &::before, &::after { + position: absolute; + width: 116px; + height: 116px; + content: ""; + right: -10px; + } + + &::before { + background-color: rgba(244, 246, 250, 0.91); + border-radius: 50%; + top: -20px; + } + + &::after { + background-image: url('$(res)/img/element-icons/email-prompt.svg'); + background-repeat: no-repeat; + background-position: center; + background-size: contain; + top: -25px; + } +} + .mx_InteractiveAuthEntryComponents_msisdnWrapper { text-align: center; } diff --git a/src/dispatcher/payloads/AppTileActionPayload.ts b/res/css/views/avatars/_WidgetAvatar.scss similarity index 72% rename from src/dispatcher/payloads/AppTileActionPayload.ts rename to res/css/views/avatars/_WidgetAvatar.scss index 3cdb0f8c1f..8e5cfb54d8 100644 --- a/src/dispatcher/payloads/AppTileActionPayload.ts +++ b/res/css/views/avatars/_WidgetAvatar.scss @@ -14,10 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ActionPayload } from "../payloads"; -import { Action } from "../actions"; - -export interface AppTileActionPayload extends ActionPayload { - action: Action.AppTileDelete | Action.AppTileRevoke; - widgetId: string; +.mx_WidgetAvatar { + border-radius: 4px; } diff --git a/res/css/views/dialogs/_FeedbackDialog.scss b/res/css/views/dialogs/_FeedbackDialog.scss new file mode 100644 index 0000000000..fd225dd882 --- /dev/null +++ b/res/css/views/dialogs/_FeedbackDialog.scss @@ -0,0 +1,121 @@ +/* +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. +*/ + +.mx_FeedbackDialog { + hr { + margin: 24px 0; + border-color: $input-border-color; + } + + .mx_Dialog_content { + margin-bottom: 24px; + + > h2 { + margin-bottom: 32px; + } + } + + .mx_FeedbackDialog_section { + position: relative; + padding-left: 52px; + + > p { + color: $tertiary-fg-color; + } + + .mx_AccessibleButton_kind_link { + padding: 0; + font-size: inherit; + } + + a, .mx_AccessibleButton_kind_link { + color: $accent-color; + text-decoration: underline; + } + + &::before, &::after { + content: ""; + position: absolute; + width: 40px; + height: 40px; + left: 0; + top: 0; + } + + &::before { + background-color: $icon-button-color; + border-radius: 20px; + } + + &::after { + background: $avatar-initial-color; // TODO + mask-position: center; + mask-size: 24px; + mask-repeat: no-repeat; + } + } + + .mx_FeedbackDialog_reportBug { + &::after { + mask-image: url('$(res)/img/feather-customised/bug.svg'); + } + } + + .mx_FeedbackDialog_rateApp { + .mx_RadioButton { + display: inline-flex; + font-size: 20px; + transition: font-size 1s, border .5s; + border-radius: 50%; + border: 2px solid transparent; + margin-top: 12px; + margin-bottom: 24px; + vertical-align: top; + cursor: pointer; + + input[type="radio"] + div { + display: none; + } + + .mx_RadioButton_content { + background: $icon-button-color; + width: 40px; + height: 40px; + text-align: center; + line-height: 40px; + border-radius: 20px; + margin: 5px; + } + + .mx_RadioButton_spacer { + display: none; + } + + & + .mx_RadioButton { + margin-left: 16px; + } + } + + .mx_RadioButton_checked { + font-size: 24px; + border-color: $accent-color; + } + + &::after { + mask-image: url('$(res)/img/element-icons/feedback.svg'); + } + } +} diff --git a/res/css/views/dialogs/_InviteDialog.scss b/res/css/views/dialogs/_InviteDialog.scss index b9063f46b9..d8ff56663a 100644 --- a/res/css/views/dialogs/_InviteDialog.scss +++ b/res/css/views/dialogs/_InviteDialog.scss @@ -27,37 +27,29 @@ limitations under the License. padding-left: 8px; overflow-x: hidden; overflow-y: auto; + display: flex; + flex-wrap: wrap; .mx_InviteDialog_userTile { + margin: 6px 6px 0 0; display: inline-block; - float: left; - position: relative; - top: 7px; + min-width: max-content; // prevent manipulation by flexbox } - // Using a textarea for this element, to circumvent autofill - // Mostly copied from AddressPickerDialog - textarea, - textarea:focus { - height: 34px; - line-height: $font-34px; + // Mostly copied from AddressPickerDialog; overrides bunch of our default text input styles + > input[type="text"] { + margin: 6px 0 !important; + height: 24px; + line-height: $font-24px; font-size: $font-14px; padding-left: 12px; - margin: 0 !important; border: 0 !important; outline: 0 !important; resize: none; - overflow: hidden; box-sizing: border-box; - word-wrap: nowrap; - - // Roughly fill about 2/5ths of the available space. This is to try and 'fill' the - // remaining space after a bunch of pills, but is a bit hacky. Ideally we'd have - // support for "fill remaining width", but traditional tricks don't work with what - // we're pushing into this "field". Flexbox just makes things worse. The theory is - // that users won't need more than about 2/5ths of the input to find the person - // they're looking for. - width: 40%; + min-width: 40%; + flex: 1 !important; + color: $primary-fg-color !important; } } @@ -148,6 +140,10 @@ limitations under the License. } } + .mx_InviteDialog_roomTile_nameStack { + display: inline-block; + } + .mx_InviteDialog_roomTile_name { font-weight: 600; font-size: $font-14px; diff --git a/res/css/views/context_menus/_WidgetContextMenu.scss b/res/css/views/dialogs/_ModalWidgetDialog.scss similarity index 51% rename from res/css/views/context_menus/_WidgetContextMenu.scss rename to res/css/views/dialogs/_ModalWidgetDialog.scss index 60b7b93f99..aa2dd0d395 100644 --- a/res/css/views/context_menus/_WidgetContextMenu.scss +++ b/res/css/views/dialogs/_ModalWidgetDialog.scss @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundaction C.I.C. +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. @@ -14,23 +14,29 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_WidgetContextMenu { - padding: 6px; +.mx_ModalWidgetDialog { + .mx_ModalWidgetDialog_warning { + margin-bottom: 24px; - .mx_WidgetContextMenu_option { - padding: 3px 6px 3px 6px; - cursor: pointer; - white-space: nowrap; + > img { + vertical-align: middle; + margin-right: 8px; + } } - .mx_WidgetContextMenu_separator { - margin-top: 0; - margin-bottom: 0; - border-bottom-style: none; - border-left-style: none; - border-right-style: none; - border-top-style: solid; - border-top-width: 1px; - border-color: $menu-border-color; + .mx_ModalWidgetDialog_buttons { + float: right; + margin-top: 24px; + + .mx_AccessibleButton + .mx_AccessibleButton { + margin-left: 8px; + } + } + + iframe { + width: 100%; + height: 450px; + border: 0; + border-radius: 8px; } } diff --git a/res/css/views/elements/_AccessibleButton.scss b/res/css/views/elements/_AccessibleButton.scss index 96269cea43..9c26f8f120 100644 --- a/res/css/views/elements/_AccessibleButton.scss +++ b/res/css/views/elements/_AccessibleButton.scss @@ -25,7 +25,7 @@ limitations under the License. .mx_AccessibleButton_hasKind { padding: 7px 18px; text-align: center; - border-radius: 4px; + border-radius: 8px; display: inline-block; font-size: $font-14px; } diff --git a/res/css/views/elements/_IconButton.scss b/res/css/views/elements/_IconButton.scss deleted file mode 100644 index d8ebbeb65e..0000000000 --- a/res/css/views/elements/_IconButton.scss +++ /dev/null @@ -1,55 +0,0 @@ -/* -Copyright 2019 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. -*/ - -.mx_IconButton { - width: 32px; - height: 32px; - border-radius: 100%; - background-color: $accent-bg-color; - // don't shrink or grow if in a flex container - flex: 0 0 auto; - - &.mx_AccessibleButton_disabled { - background-color: none; - - &::before { - background-color: lightgrey; - } - } - - &:hover { - opacity: 90%; - } - - &::before { - content: ""; - display: block; - width: 100%; - height: 100%; - mask-repeat: no-repeat; - mask-position: center; - mask-size: 55%; - background-color: $accent-color; - } - - &.mx_IconButton_icon_check::before { - mask-image: url('$(res)/img/feather-customised/check.svg'); - } - - &.mx_IconButton_icon_edit::before { - mask-image: url('$(res)/img/feather-customised/edit.svg'); - } -} diff --git a/res/css/views/elements/_MiniAvatarUploader.scss b/res/css/views/elements/_MiniAvatarUploader.scss new file mode 100644 index 0000000000..2502977331 --- /dev/null +++ b/res/css/views/elements/_MiniAvatarUploader.scss @@ -0,0 +1,56 @@ +/* +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. +*/ + +.mx_MiniAvatarUploader { + position: relative; + width: min-content; + + &::before, &::after { + content: ''; + position: absolute; + + height: 26px; + width: 26px; + + right: -6px; + bottom: -6px; + } + + &::before { + background-color: $primary-bg-color; + border-radius: 50%; + z-index: 1; + } + + &::after { + background-color: $secondary-fg-color; + mask-position: center; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/element-icons/camera.svg'); + mask-size: 16px; + z-index: 2; + } + + &.mx_MiniAvatarUploader_busy::after { + background: url("$(res)/img/spinner.gif") no-repeat center; + background-size: 80%; + mask: unset; + } +} + +.mx_MiniAvatarUploader_input { + display: none; +} diff --git a/res/css/views/messages/_CreateEvent.scss b/res/css/views/messages/_CreateEvent.scss index d45645863f..cb2bf841dd 100644 --- a/res/css/views/messages/_CreateEvent.scss +++ b/res/css/views/messages/_CreateEvent.scss @@ -1,5 +1,5 @@ /* -Copyright 2018 New Vector Ltd +Copyright 2018, 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. @@ -15,25 +15,8 @@ limitations under the License. */ .mx_CreateEvent { - background-color: $info-plinth-bg-color; - padding-left: 20px; - padding-right: 20px; - padding-top: 10px; - padding-bottom: 10px; -} - -.mx_CreateEvent_image { - float: left; - margin-right: 20px; - width: 72px; - height: 34px; - - background-color: $primary-fg-color; - mask: url('$(res)/img/room-continuation.svg'); - mask-repeat: no-repeat; - mask-position: center; -} - -.mx_CreateEvent_header { - font-weight: bold; + &::before { + background-color: $composer-e2e-icon-color; + mask-image: url('$(res)/img/element-icons/chat-bubbles.svg'); + } } diff --git a/res/css/views/messages/_EventTileBubble.scss b/res/css/views/messages/_EventTileBubble.scss new file mode 100644 index 0000000000..e0f5d521cb --- /dev/null +++ b/res/css/views/messages/_EventTileBubble.scss @@ -0,0 +1,60 @@ +/* +Copyright 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. +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. +*/ + +.mx_EventTileBubble { + background-color: $dark-panel-bg-color; + padding: 10px; + border-radius: 8px; + margin: 10px auto; + max-width: 75%; + box-sizing: border-box; + display: grid; + grid-template-columns: 24px minmax(0, 1fr) min-content; + + &::before, &::after { + position: relative; + grid-column: 1; + grid-row: 1 / 3; + width: 16px; + height: 16px; + content: ""; + top: 0; + bottom: 0; + left: 0; + right: 0; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + margin-top: 4px; + } + + .mx_EventTileBubble_title, .mx_EventTileBubble_subtitle { + overflow-wrap: break-word; + } + + .mx_EventTileBubble_title { + font-weight: 600; + font-size: $font-15px; + grid-column: 2; + grid-row: 1; + } + + .mx_EventTileBubble_subtitle { + font-size: $font-12px; + grid-column: 2; + grid-row: 2; + } +} diff --git a/res/css/views/messages/_MJitsiWidgetEvent.scss b/res/css/views/messages/_MJitsiWidgetEvent.scss index 3e51e89744..bea8651543 100644 --- a/res/css/views/messages/_MJitsiWidgetEvent.scss +++ b/res/css/views/messages/_MJitsiWidgetEvent.scss @@ -15,41 +15,8 @@ limitations under the License. */ .mx_MJitsiWidgetEvent { - display: grid; - grid-template-columns: 24px minmax(0, 1fr) min-content; - &::before { - grid-column: 1; - grid-row: 1 / 3; - width: 16px; - height: 16px; - content: ""; - top: 0; - bottom: 0; - left: 0; - right: 0; - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; background-color: $composer-e2e-icon-color; // XXX: Variable abuse - margin-top: 4px; mask-image: url('$(res)/img/element-icons/call/video-call.svg'); } - - .mx_MJitsiWidgetEvent_title { - font-weight: 600; - font-size: $font-15px; - grid-column: 2; - grid-row: 1; - } - - .mx_MJitsiWidgetEvent_subtitle { - grid-column: 2; - grid-row: 2; - } - - .mx_MJitsiWidgetEvent_title, - .mx_MJitsiWidgetEvent_subtitle { - overflow-wrap: break-word; - } } diff --git a/res/css/views/messages/_common_CryptoEvent.scss b/res/css/views/messages/_common_CryptoEvent.scss index 09c78ae5b4..4faa4b594f 100644 --- a/res/css/views/messages/_common_CryptoEvent.scss +++ b/res/css/views/messages/_common_CryptoEvent.scss @@ -15,28 +15,6 @@ limitations under the License. */ .mx_cryptoEvent { - display: grid; - grid-template-columns: 24px minmax(0, 1fr) min-content; - - &.mx_cryptoEvent_icon::before, - &.mx_cryptoEvent_icon::after { - grid-column: 1; - grid-row: 1 / 3; - width: 16px; - height: 16px; - content: ""; - top: 0; - bottom: 0; - left: 0; - right: 0; - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; - mask-image: url('$(res)/img/e2e/normal.svg'); - background-color: $composer-e2e-icon-color; - margin-top: 4px; - } - // white infill for the transparency &.mx_cryptoEvent_icon::before { background-color: #ffffff; @@ -46,6 +24,11 @@ limitations under the License. mask-size: 90%; } + &.mx_cryptoEvent_icon::after { + mask-image: url('$(res)/img/e2e/normal.svg'); + background-color: $composer-e2e-icon-color; + } + &.mx_cryptoEvent_icon_verified::after { mask-image: url("$(res)/img/e2e/verified.svg"); background-color: $accent-color; @@ -56,25 +39,6 @@ limitations under the License. background-color: $notice-primary-color; } - .mx_cryptoEvent_title, .mx_cryptoEvent_subtitle, .mx_cryptoEvent_state { - overflow-wrap: break-word; - } - - .mx_cryptoEvent_title { - font-weight: 600; - font-size: $font-15px; - grid-column: 2; - grid-row: 1; - } - - .mx_cryptoEvent_subtitle { - grid-column: 2; - grid-row: 2; - } - - .mx_cryptoEvent_state, .mx_cryptoEvent_subtitle { - font-size: $font-12px; - } .mx_cryptoEvent_state, .mx_cryptoEvent_buttons { grid-column: 3; @@ -92,5 +56,7 @@ limitations under the License. margin: auto 0; text-align: center; color: $notice-secondary-color; + overflow-wrap: break-word; + font-size: $font-12px; } } diff --git a/res/css/views/right_panel/_BaseCard.scss b/res/css/views/right_panel/_BaseCard.scss index 3ff3b52531..9a5a59bda8 100644 --- a/res/css/views/right_panel/_BaseCard.scss +++ b/res/css/views/right_panel/_BaseCard.scss @@ -128,6 +128,13 @@ limitations under the License. mask-size: 20px; mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); } + + &.mx_AccessibleButton_disabled { + padding-right: 12px; + &::after { + content: unset; + } + } } } diff --git a/res/css/views/right_panel/_RoomSummaryCard.scss b/res/css/views/right_panel/_RoomSummaryCard.scss index 0031d3a64c..36882f4e8b 100644 --- a/res/css/views/right_panel/_RoomSummaryCard.scss +++ b/res/css/views/right_panel/_RoomSummaryCard.scss @@ -110,28 +110,107 @@ limitations under the License. .mx_RoomSummaryCard_appsGroup { .mx_RoomSummaryCard_Button { - padding-left: 12px; + // this button is special so we have to override some of the original styling + // as we will be applying it in its children + padding: 0; + height: auto; color: $tertiary-fg-color; - span { - color: $primary-fg-color; + .mx_RoomSummaryCard_icon_app { + padding: 10px 48px 10px 12px; // based on typical mx_RoomSummaryCard_Button padding + text-overflow: ellipsis; + overflow: hidden; + + .mx_BaseAvatar_image { + vertical-align: top; + margin-right: 12px; + } + + span { + color: $primary-fg-color; + } } - img { - vertical-align: top; - margin-right: 12px; - border-radius: 4px; + .mx_RoomSummaryCard_app_pinToggle, + .mx_RoomSummaryCard_app_options { + position: absolute; + top: 0; + height: 100%; // to give bigger interactive zone + width: 24px; + padding: 12px 4px; + box-sizing: border-box; + min-width: 24px; // prevent flexbox crushing + + &:hover { + &::after { + content: ''; + position: absolute; + height: 24px; + width: 24px; + top: 8px; // equal to padding-top of parent + left: 0; + border-radius: 12px; + background-color: rgba(141, 151, 165, 0.1); + } + } + + &::before { + content: ''; + position: absolute; + height: 16px; + width: 16px; + mask-repeat: no-repeat; + mask-position: center; + mask-size: 16px; + background-color: $icon-button-color; + } + } + + .mx_RoomSummaryCard_app_pinToggle { + right: 24px; + + &::before { + mask-image: url('$(res)/img/element-icons/room/pin-upright.svg'); + } + } + + .mx_RoomSummaryCard_app_options { + right: 48px; + display: none; + + &::before { + mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); + } + } + + &.mx_RoomSummaryCard_Button_pinned { + &::after { + opacity: 0.2; + } + + .mx_RoomSummaryCard_app_pinToggle::before { + background-color: $accent-color; + } + } + + &:hover { + .mx_RoomSummaryCard_icon_app { + padding-right: 72px; + } + + .mx_RoomSummaryCard_app_options { + display: unset; + } } &::before { content: unset; } - } - .mx_RoomSummaryCard_icon_app_pinned::after { - mask-image: url('$(res)/img/element-icons/room/pin-upright.svg'); - background-color: $accent-color; - transform: unset; + &::after { + top: 8px; // re-align based on the height change + pointer-events: none; // pass through to the real button + } } } diff --git a/res/css/views/right_panel/_UserInfo.scss b/res/css/views/right_panel/_UserInfo.scss index f20c9b7868..87420ae4e7 100644 --- a/res/css/views/right_panel/_UserInfo.scss +++ b/res/css/views/right_panel/_UserInfo.scss @@ -173,26 +173,12 @@ limitations under the License. margin: 6px 0; - .mx_IconButton, .mx_Spinner { - margin-left: 20px; - width: 16px; - height: 16px; - - &::before { - mask-size: 80%; - } - } - .mx_UserInfo_roleDescription { display: flex; justify-content: center; align-items: center; // try to make it the same height as the dropdown margin: 11px 0 12px 0; - - .mx_IconButton { - margin-left: 6px; - } } .mx_Field { diff --git a/res/css/views/right_panel/_WidgetCard.scss b/res/css/views/right_panel/_WidgetCard.scss index 315fd5213c..a90e744a5a 100644 --- a/res/css/views/right_panel/_WidgetCard.scss +++ b/res/css/views/right_panel/_WidgetCard.scss @@ -24,34 +24,35 @@ limitations under the License. border: 0; } - &.mx_WidgetCard_noEdit { - .mx_AccessibleButton_kind_secondary { - margin: 0 12px; + .mx_BaseCard_header { + display: inline-flex; - &:first-child { - // expand the Pin to room primary action - flex-grow: 1; - } + & > h2 { + margin-right: 0; + flex-grow: 1; } - } - .mx_WidgetCard_optionsButton { - position: relative; - height: 18px; - width: 26px; - - &::before { - content: ""; - position: absolute; - width: 20px; + .mx_WidgetCard_optionsButton { + position: relative; + margin-right: 44px; height: 20px; - top: 6px; - left: 20px; - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; - mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); - background-color: $secondary-fg-color; + width: 20px; + min-width: 20px; // prevent crushing by the flexbox + padding: 0; + + &::before { + content: ""; + position: absolute; + width: 20px; + height: 20px; + top: 0; + left: 4px; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); + background-color: $secondary-fg-color; + } } } } diff --git a/res/css/views/rooms/_AppsDrawer.scss b/res/css/views/rooms/_AppsDrawer.scss index 451704bd88..8731d22660 100644 --- a/res/css/views/rooms/_AppsDrawer.scss +++ b/res/css/views/rooms/_AppsDrawer.scss @@ -47,11 +47,27 @@ $MiniAppTileHeight: 200px; opacity: 0.8; background: $primary-fg-color; } + + .mx_ResizeHandle_horizontal::before { + position: absolute; + left: 3px; + top: 50%; + transform: translate(0, -50%); + + height: 64px; // to match width of the ones on roomlist + width: 4px; + border-radius: 4px; + + content: ''; + + background-color: $primary-fg-color; + opacity: 0.8; + } } } -.mx_AppsDrawer_hidden { - display: none; +.mx_AppsContainer_resizer { + margin-bottom: 8px; } .mx_AppsContainer { @@ -60,53 +76,71 @@ $MiniAppTileHeight: 200px; align-items: stretch; justify-content: center; height: 100%; - margin-bottom: 8px; + width: 100%; + flex: 1; + min-height: 0; + + .mx_AppTile:first-of-type { + border-left-width: 8px; + border-radius: 10px 0 0 10px; + } + .mx_AppTile:last-of-type { + border-right-width: 8px; + border-radius: 0 10px 10px 0; + } + + .mx_ResizeHandle_horizontal { + position: relative; + + > div { + width: 0; + } + } } -.mx_AppsDrawer_minimised .mx_AppsContainer { - // override the re-resizable inline styles - height: inherit !important; - min-height: inherit !important; -} +// TODO this should be 300px but that's too large +$MinWidth: 240px; -.mx_AddWidget_button { - order: 2; - cursor: pointer; - padding: 0; - margin: -3px auto 5px 0; - color: $accent-color; - font-size: $font-12px; -} +.mx_AppsDrawer_2apps .mx_AppTile { + width: 50%; -.mx_SetAppURLDialog_input { - border-radius: 3px; - border: 1px solid $input-border-color; - padding: 9px; - color: $primary-hairline-color; - background-color: $primary-bg-color; - font-size: $font-15px; + &:nth-child(3) { + flex-grow: 1; + width: 0 !important; + min-width: $MinWidth !important; + } +} +.mx_AppsDrawer_3apps .mx_AppTile { + width: 33%; + + &:nth-child(3) { + flex-grow: 1; + width: 0 !important; + min-width: $MinWidth !important; + } } .mx_AppTile { width: 50%; - border: 5px solid $widget-menu-bar-bg-color; - border-radius: 4px; + min-width: $MinWidth; + border: 8px solid $widget-menu-bar-bg-color; + border-left-width: 5px; + border-right-width: 5px; display: flex; flex-direction: column; - - & + .mx_AppTile { - margin-left: 5px; - } + box-sizing: border-box; + background-color: $widget-menu-bar-bg-color; } .mx_AppTileFullWidth { - width: 100%; + width: 100% !important; // to override the inline style set by the resizer margin: 0; padding: 0; border: 5px solid $widget-menu-bar-bg-color; border-radius: 8px; display: flex; flex-direction: column; + background-color: $widget-menu-bar-bg-color; } .mx_AppTile_mini { @@ -118,12 +152,6 @@ $MiniAppTileHeight: 200px; height: $MiniAppTileHeight; } -.mx_AppTile.mx_AppTile_minimised, -.mx_AppTileFullWidth.mx_AppTile_minimised, -.mx_AppTile_mini.mx_AppTile_minimised { - height: 14px; -} - .mx_AppTile .mx_AppTile_persistedWrapper, .mx_AppTileFullWidth .mx_AppTile_persistedWrapper, .mx_AppTile_mini .mx_AppTile_persistedWrapper { @@ -143,19 +171,20 @@ $MiniAppTileHeight: 200px; flex-direction: row; align-items: center; justify-content: space-between; - cursor: pointer; width: 100%; -} - -.mx_AppTileMenuBar_expanded { - padding-bottom: 5px; + padding-top: 2px; + padding-bottom: 8px; } .mx_AppTileMenuBarTitle { - display: flex; - flex-direction: row; - align-items: center; - pointer-events: none; + line-height: 20px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + .mx_WidgetAvatar { + margin-right: 12px; + } } .mx_AppTileMenuBarTitle > :last-child { @@ -179,37 +208,20 @@ $MiniAppTileHeight: 200px; margin: 0 3px; } -.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_minimise { - mask-image: url('$(res)/img/feather-customised/widget/minimise.svg'); - background-color: $accent-color; -} - -.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_maximise { - mask-image: url('$(res)/img/feather-customised/widget/maximise.svg'); - background-color: $accent-color; -} - .mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_popout { mask-image: url('$(res)/img/feather-customised/widget/external-link.svg'); } .mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_menu { - mask-image: url('$(res)/img/icon_context.svg'); -} - -.mx_AppTileMenuBarWidgetDelete { - filter: none; -} - -.mx_AppTileMenuBarWidget:hover { - border: 1px solid $primary-fg-color; - border-radius: 2px; + mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); } .mx_AppTileBody { height: 100%; width: 100%; overflow: hidden; + border-radius: 8px; + background-color: $widget-body-bg-color; } .mx_AppTileBody_mini { @@ -242,75 +254,8 @@ $MiniAppTileHeight: 200px; display: block; } -.mx_AppTileMenuBarWidgetPadding { - margin-right: 5px; -} - -.mx_AppIconTile { - background-color: $lightbox-bg-color; - border: 1px solid rgba(0, 0, 0, 0); - width: 200px; - box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2); - transition: 0.3s; - border-radius: 3px; - margin: 5px; - display: inline-block; -} - -.mx_AppIconTile.mx_AppIconTile_active { - color: $accent-color; - border-color: $accent-color; -} - -.mx_AppIconTile:hover { - border: 1px solid $accent-color; - box-shadow: 0 0 10px 5px rgba(200, 200, 200, 0.5); -} - -.mx_AppIconTile_content { - padding: 2px 16px; - height: 60px; - overflow: hidden; -} - -.mx_AppIconTile_content h4 { - margin-top: 5px; - margin-bottom: 2px; -} - -.mx_AppIconTile_content p { - margin-top: 0; - margin-bottom: 5px; - font-size: smaller; -} - -.mx_AppIconTile_image { - padding: 10px; - max-width: 100px; - max-height: 100px; - width: auto; - height: auto; -} - -.mx_AppIconTile_imageContainer { - text-align: center; - width: 100%; - background-color: white; - border-radius: 3px 3px 0 0; - height: 155px; - display: flex; - justify-content: center; - align-items: center; -} - -form.mx_Custom_Widget_Form div { - margin-top: 10px; - margin-bottom: 10px; -} - .mx_AppPermissionWarning { text-align: center; - background-color: $widget-menu-bar-bg-color; display: flex; height: 100%; flex-direction: column; @@ -375,6 +320,10 @@ form.mx_Custom_Widget_Form div { font-weight: bold; position: relative; height: 100%; + + // match bg of border so that the cut corners have the right fill + background-color: $widget-body-bg-color !important; + border-radius: 8px; } .mx_AppLoading .mx_Spinner { @@ -402,10 +351,6 @@ form.mx_Custom_Widget_Form div { display: none; } -.mx_AppsDrawer_minimised .mx_AppsContainer_resizerHandle { - display: none; -} - /* Avoid apptile iframes capturing mouse event focus when resizing */ .mx_AppsDrawer_resizing iframe { pointer-events: none; diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 3b9a491db5..429ac7ed4b 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -25,15 +25,6 @@ $left-gutter: 64px; position: relative; } -.mx_EventTile_bubble { - background-color: $dark-panel-bg-color; - padding: 10px; - border-radius: 5px; - margin: 10px auto; - max-width: 75%; - box-sizing: border-box; -} - .mx_EventTile.mx_EventTile_info { padding-top: 0px; } @@ -131,9 +122,10 @@ $left-gutter: 64px; grid-template-columns: 1fr 100px; .mx_EventTile_line { - margin-right: 0px; + margin-right: 0; grid-column: 1 / 3; - padding: 0; + // override default padding of mx_EventTile_line so that we can be centered + padding: 0 !important; } .mx_EventTile_msgOption { diff --git a/res/css/views/rooms/_MemberInfo.scss b/res/css/views/rooms/_MemberInfo.scss index fb082843f1..182c280217 100644 --- a/res/css/views/rooms/_MemberInfo.scss +++ b/res/css/views/rooms/_MemberInfo.scss @@ -70,7 +70,7 @@ limitations under the License. } .mx_MemberInfo_avatar { - background: $tagpanel-bg-color; + background: $groupFilterPanel-bg-color; margin-bottom: 16px; } diff --git a/res/css/views/rooms/_NewRoomIntro.scss b/res/css/views/rooms/_NewRoomIntro.scss new file mode 100644 index 0000000000..4322ba341c --- /dev/null +++ b/res/css/views/rooms/_NewRoomIntro.scss @@ -0,0 +1,67 @@ +/* +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. +*/ + +.mx_NewRoomIntro { + margin: 40px 0 48px 64px; + + .mx_MiniAvatarUploader_hasAvatar:not(.mx_MiniAvatarUploader_busy):not(:hover) { + &::before, &::after { + content: unset; + } + } + + .mx_AccessibleButton_kind_link { + padding: 0; + font-size: inherit; + } + + .mx_NewRoomIntro_buttons { + margin-top: 28px; + + .mx_AccessibleButton { + line-height: $font-24px; + + &::before { + content: ''; + display: inline-block; + background-color: $button-fg-color; + mask-position: center; + mask-repeat: no-repeat; + mask-size: 20px; + width: 20px; + height: 20px; + margin-right: 5px; + vertical-align: text-bottom; + } + } + + .mx_NewRoomIntro_inviteButton::before { + mask-image: url('$(res)/img/element-icons/room/invite.svg'); + } + } + + > h2 { + margin-top: 24px; + font-size: $font-24px; + font-weight: 600; + } + + > p { + margin: 0; + font-size: $font-15px; + color: $secondary-fg-color; + } +} diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss index d240877507..a23a44906f 100644 --- a/res/css/views/rooms/_RoomHeader.scss +++ b/res/css/views/rooms/_RoomHeader.scss @@ -241,6 +241,13 @@ limitations under the License. width: 26px; } +.mx_RoomHeader_appsButton::before { + mask-image: url('$(res)/img/element-icons/room/apps.svg'); +} +.mx_RoomHeader_appsButton_highlight::before { + background-color: $accent-color; +} + .mx_RoomHeader_searchButton::before { mask-image: url('$(res)/img/element-icons/room/search-inset.svg'); } diff --git a/res/css/views/rooms/_RoomList.scss b/res/css/views/rooms/_RoomList.scss index 78e7307bc0..6ea99585d2 100644 --- a/res/css/views/rooms/_RoomList.scss +++ b/res/css/views/rooms/_RoomList.scss @@ -33,7 +33,6 @@ limitations under the License. div:first-child { font-weight: $font-semi-bold; - margin-bottom: 8px; } .mx_AccessibleButton { @@ -41,6 +40,7 @@ limitations under the License. position: relative; padding: 0 0 0 24px; font-size: inherit; + margin-top: 8px; &::before { content: ''; @@ -53,6 +53,13 @@ limitations under the License. mask-position: center; mask-size: contain; mask-repeat: no-repeat; + } + + &.mx_RoomList_explorePrompt_startChat::before { + mask-image: url('$(res)/img/element-icons/feedback.svg'); + } + + &.mx_RoomList_explorePrompt_explore::before { mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); } } diff --git a/res/css/views/rooms/_RoomSublist.scss b/res/css/views/rooms/_RoomSublist.scss index 543940fb78..27c7c7d0f7 100644 --- a/res/css/views/rooms/_RoomSublist.scss +++ b/res/css/views/rooms/_RoomSublist.scss @@ -59,10 +59,6 @@ limitations under the License. width: calc(100% - 22px); } - &.mx_RoomSublist_headerContainer_stickyBottom { - bottom: 0; - } - // We don't have a top style because the top is dependent on the room list header's // height, and is therefore calculated in JS. // The class, mx_RoomSublist_headerContainer_stickyTop, is applied though. @@ -387,3 +383,22 @@ limitations under the License. .mx_RoomSublist_addRoomTooltip { margin-top: -3px; } + +.mx_RoomSublist_skeletonUI { + position: relative; + margin-left: 4px; + height: 288px; + + &::before { + background: $roomsublist-skeleton-ui-bg; + + width: 100%; + height: 100%; + + content: ''; + position: absolute; + mask-repeat: repeat-y; + mask-size: auto 48px; + mask-image: url('$(res)/img/element-icons/roomlist/skeleton-ui.svg'); + } +} diff --git a/res/css/views/rooms/_Stickers.scss b/res/css/views/rooms/_Stickers.scss index d99276b70a..94f42efe83 100644 --- a/res/css/views/rooms/_Stickers.scss +++ b/res/css/views/rooms/_Stickers.scss @@ -16,6 +16,10 @@ border-bottom: none; } + .mx_AppTileMenuBar { + padding: 0; + } + iframe { // Sticker picker depends on the fixed height previously used for all tiles height: 273px; diff --git a/res/css/views/settings/_AvatarSetting.scss b/res/css/views/settings/_AvatarSetting.scss index 4a1f57a00e..a350605ab1 100644 --- a/res/css/views/settings/_AvatarSetting.scss +++ b/res/css/views/settings/_AvatarSetting.scss @@ -85,6 +85,7 @@ limitations under the License. .mx_AvatarSetting_avatarPlaceholder { display: block; height: 90px; + width: inherit; border-radius: 90px; cursor: pointer; } diff --git a/src/extend.js b/res/css/views/toasts/_AnalyticsToast.scss similarity index 66% rename from src/extend.js rename to res/css/views/toasts/_AnalyticsToast.scss index 263d802ab6..fdbe7f1c76 100644 --- a/src/extend.js +++ b/res/css/views/toasts/_AnalyticsToast.scss @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +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. @@ -14,13 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - -export default function(dest, src) { - for (const i in src) { - if (src.hasOwnProperty(i)) { - dest[i] = src[i]; - } +.mx_AnalyticsToast { + .mx_AccessibleButton_kind_danger { + background: none; + color: $accent-color; + } + + .mx_AccessibleButton_kind_primary { + background: $accent-color; + color: #ffffff; } - return dest; } diff --git a/res/css/views/voip/_CallContainer.scss b/res/css/views/voip/_CallContainer.scss index 759797ae7b..eec8a1f188 100644 --- a/res/css/views/voip/_CallContainer.scss +++ b/res/css/views/voip/_CallContainer.scss @@ -33,11 +33,11 @@ limitations under the License. pointer-events: initial; // restore pointer events so the user can leave/interact cursor: pointer; - .mx_VideoView { + .mx_CallView_video { width: 350px; } - .mx_VideoView_localVideoFeed { + .mx_VideoFeed_local { border-radius: 8px; overflow: hidden; } diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index f6f3d40308..2aeaaa87dc 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -92,3 +92,10 @@ limitations under the License. background-color: $primary-fg-color; } } + +.mx_CallView_video { + width: 100%; + position: relative; + z-index: 30; +} + diff --git a/res/css/views/voip/_VideoView.scss b/res/css/views/voip/_VideoFeed.scss similarity index 72% rename from res/css/views/voip/_VideoView.scss rename to res/css/views/voip/_VideoFeed.scss index feb60f4763..e5e3587dac 100644 --- a/res/css/views/voip/_VideoView.scss +++ b/res/css/views/voip/_VideoFeed.scss @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +Copyright 2015, 2016, 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. @@ -14,23 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_VideoView { - width: 100%; - position: relative; - z-index: 30; -} - -.mx_VideoView video { +.mx_VideoFeed video { width: 100%; } -.mx_VideoView_remoteVideoFeed { +.mx_VideoFeed_remote { width: 100%; background-color: #000; z-index: 50; } -.mx_VideoView_localVideoFeed { +.mx_VideoFeed_local { width: 25%; height: 25%; position: absolute; @@ -39,11 +33,11 @@ limitations under the License. z-index: 100; } -.mx_VideoView_localVideoFeed video { +.mx_VideoFeed_local video { width: auto; height: 100%; } -.mx_VideoView_localVideoFeed.mx_VideoView_localVideoFeed_flipped video { +.mx_VideoFeed_mirror video { transform: scale(-1, 1); } diff --git a/res/img/element-icons/camera.svg b/res/img/element-icons/camera.svg new file mode 100644 index 0000000000..92d1f91dec --- /dev/null +++ b/res/img/element-icons/camera.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/res/img/element-icons/chat-bubbles.svg b/res/img/element-icons/chat-bubbles.svg new file mode 100644 index 0000000000..ac9db61f29 --- /dev/null +++ b/res/img/element-icons/chat-bubbles.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/res/img/element-icons/email-prompt.svg b/res/img/element-icons/email-prompt.svg new file mode 100644 index 0000000000..19b8f82449 --- /dev/null +++ b/res/img/element-icons/email-prompt.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/res/img/element-icons/feedback.svg b/res/img/element-icons/feedback.svg new file mode 100644 index 0000000000..3ee20d18d9 --- /dev/null +++ b/res/img/element-icons/feedback.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/room/apps.svg b/res/img/element-icons/room/apps.svg new file mode 100644 index 0000000000..c90704752c --- /dev/null +++ b/res/img/element-icons/room/apps.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/img/element-icons/room/default_app.svg b/res/img/element-icons/room/default_app.svg index 08734170df..baf9bc37fa 100644 --- a/res/img/element-icons/room/default_app.svg +++ b/res/img/element-icons/room/default_app.svg @@ -1,11 +1,21 @@ - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/res/img/element-icons/room/default_cal.svg b/res/img/element-icons/room/default_cal.svg index 5bced115cf..fc440b4553 100644 --- a/res/img/element-icons/room/default_cal.svg +++ b/res/img/element-icons/room/default_cal.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/res/img/element-icons/room/default_clock.svg b/res/img/element-icons/room/default_clock.svg index cc21716d15..c7f453aadd 100644 --- a/res/img/element-icons/room/default_clock.svg +++ b/res/img/element-icons/room/default_clock.svg @@ -1,5 +1,5 @@ - - - - + + + + diff --git a/res/img/element-icons/room/default_doc.svg b/res/img/element-icons/room/default_doc.svg index 93e7507be3..aff393ffd5 100644 --- a/res/img/element-icons/room/default_doc.svg +++ b/res/img/element-icons/room/default_doc.svg @@ -1,4 +1,4 @@ - - + + diff --git a/res/img/element-icons/room/default_video.svg b/res/img/element-icons/room/default_video.svg new file mode 100644 index 0000000000..022f1f43b1 --- /dev/null +++ b/res/img/element-icons/room/default_video.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/res/img/element-icons/room/integrations.svg b/res/img/element-icons/room/integrations.svg deleted file mode 100644 index 3a39506411..0000000000 --- a/res/img/element-icons/room/integrations.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/res/img/element-icons/roomlist/skeleton-ui.svg b/res/img/element-icons/roomlist/skeleton-ui.svg new file mode 100644 index 0000000000..e95692536c --- /dev/null +++ b/res/img/element-icons/roomlist/skeleton-ui.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/res/img/element-icons/warning-badge.svg b/res/img/element-icons/warning-badge.svg new file mode 100644 index 0000000000..ac5991f221 --- /dev/null +++ b/res/img/element-icons/warning-badge.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/res/img/feather-customised/bug.svg b/res/img/feather-customised/bug.svg new file mode 100644 index 0000000000..babc4fed0e --- /dev/null +++ b/res/img/feather-customised/bug.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/icon_context.svg b/res/img/icon_context.svg deleted file mode 100644 index 600c5bbd1d..0000000000 --- a/res/img/icon_context.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/res/img/room-continuation.svg b/res/img/room-continuation.svg deleted file mode 100644 index dc7e15462a..0000000000 --- a/res/img/room-continuation.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 331b5f4692..6350439a4f 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -39,7 +39,7 @@ $info-plinth-fg-color: #888; $preview-bar-bg-color: $header-panel-bg-color; -$tagpanel-bg-color: rgba(38, 39, 43, 0.82); +$groupFilterPanel-bg-color: rgba(38, 39, 43, 0.82); $inverted-bg-color: $base-color; // used by AddressSelector @@ -98,7 +98,7 @@ $roomheader-color: $text-primary-color; $roomheader-bg-color: $bg-color; $roomheader-addroom-bg-color: rgba(92, 100, 112, 0.3); $roomheader-addroom-fg-color: $text-primary-color; -$tagpanel-button-color: $header-panel-text-primary-color; +$groupFilterPanel-button-color: $header-panel-text-primary-color; $groupheader-button-color: $header-panel-text-primary-color; $rightpanel-button-color: $header-panel-text-primary-color; $icon-button-color: #8E99A4; @@ -117,8 +117,9 @@ $roomlist-filter-active-bg-color: $bg-color; $roomlist-bg-color: rgba(33, 38, 44, 0.90); $roomlist-header-color: $tertiary-fg-color; $roomsublist-divider-color: $primary-fg-color; +$roomsublist-skeleton-ui-bg: linear-gradient(180deg, #3e444c 0%, #3e444c00 100%); -$tagpanel-divider-color: $roomlist-header-color; +$groupFilterPanel-divider-color: $roomlist-header-color; $roomtile-preview-color: $secondary-fg-color; $roomtile-default-badge-bg-color: #61708b; @@ -131,6 +132,7 @@ $notice-secondary-color: $roomlist-header-color; $panel-divider-color: transparent; $widget-menu-bar-bg-color: $header-panel-bg-color; +$widget-body-bg-color: rgba(141, 151, 165, 0.2); // event tile lifecycle $event-sending-color: $text-secondary-color; @@ -187,7 +189,7 @@ $reaction-row-button-selected-border-color: $accent-color; $kbd-border-color: #000000; -$tooltip-timeline-bg-color: $tagpanel-bg-color; +$tooltip-timeline-bg-color: $groupFilterPanel-bg-color; $tooltip-timeline-fg-color: #ffffff; $interactive-tooltip-bg-color: $base-color; @@ -202,7 +204,7 @@ $appearance-tab-border-color: $room-highlight-color; // blur amounts for left left panel (only for element theme, used in _mods.scss) $roomlist-background-blur-amount: 60px; -$tagpanel-background-blur-amount: 30px; +$groupFilterPanel-background-blur-amount: 30px; $composer-shadow-color: rgba(0, 0, 0, 0.28); diff --git a/res/themes/dark/css/dark.scss b/res/themes/dark/css/dark.scss index 6d9dc7352c..f9695018e4 100644 --- a/res/themes/dark/css/dark.scss +++ b/res/themes/dark/css/dark.scss @@ -3,7 +3,7 @@ @import "../../light/css/_fonts.scss"; @import "../../light/css/_light.scss"; // important this goes before _mods, -// as $tagpanel-background-blur-amount and +// as $groupFilterPanel-background-blur-amount and // $roomlist-background-blur-amount // are overridden in _dark.scss @import "_dark.scss"; diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index 14ce264bc0..716d8c7385 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -37,8 +37,8 @@ $info-plinth-fg-color: #888; $preview-bar-bg-color: $header-panel-bg-color; -$tagpanel-bg-color: $base-color; -$inverted-bg-color: $tagpanel-bg-color; +$groupFilterPanel-bg-color: $base-color; +$inverted-bg-color: $groupFilterPanel-bg-color; // used by AddressSelector $selected-color: $room-highlight-color; @@ -95,7 +95,7 @@ $topleftmenu-color: $text-primary-color; $roomheader-color: $text-primary-color; $roomheader-addroom-bg-color: #3c4556; // $search-placeholder-color at 0.5 opacity $roomheader-addroom-fg-color: $text-primary-color; -$tagpanel-button-color: $header-panel-text-primary-color; +$groupFilterPanel-button-color: $header-panel-text-primary-color; $groupheader-button-color: $header-panel-text-primary-color; $rightpanel-button-color: $header-panel-text-primary-color; $icon-button-color: $header-panel-text-primary-color; @@ -114,8 +114,9 @@ $roomlist-filter-active-bg-color: $roomlist-button-bg-color; $roomlist-bg-color: $header-panel-bg-color; $roomsublist-divider-color: $primary-fg-color; +$roomsublist-skeleton-ui-bg: linear-gradient(180deg, #3e444c 0%, #3e444c00 100%); -$tagpanel-divider-color: $roomlist-header-color; +$groupFilterPanel-divider-color: $roomlist-header-color; $roomtile-preview-color: #9e9e9e; $roomtile-default-badge-bg-color: #61708b; @@ -126,6 +127,7 @@ $roomtile-selected-bg-color: #1A1D23; $panel-divider-color: $header-panel-border-color; $widget-menu-bar-bg-color: $header-panel-bg-color; +$widget-body-bg-color: #1A1D23; // event tile lifecycle $event-sending-color: $text-secondary-color; @@ -182,7 +184,7 @@ $reaction-row-button-selected-border-color: $accent-color; $kbd-border-color: #000000; -$tooltip-timeline-bg-color: $tagpanel-bg-color; +$tooltip-timeline-bg-color: $groupFilterPanel-bg-color; $tooltip-timeline-fg-color: #ffffff; $interactive-tooltip-bg-color: $base-color; diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index b030fb7423..8c42c5c97f 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -67,8 +67,8 @@ $preview-bar-bg-color: #f7f7f7; $secondary-accent-color: #f2f5f8; $tertiary-accent-color: #d3efe1; -$tagpanel-bg-color: #27303a; -$inverted-bg-color: $tagpanel-bg-color; +$groupFilterPanel-bg-color: #27303a; +$inverted-bg-color: $groupFilterPanel-bg-color; // used by RoomDirectory permissions $plinth-bg-color: $secondary-accent-color; @@ -162,7 +162,7 @@ $roomheader-color: #45474a; $roomheader-bg-color: $primary-bg-color; $roomheader-addroom-bg-color: #91a1c0; $roomheader-addroom-fg-color: $accent-fg-color; -$tagpanel-button-color: #91a1c0; +$groupFilterPanel-button-color: #91a1c0; $groupheader-button-color: #91a1c0; $rightpanel-button-color: #91a1c0; $icon-button-color: #91a1c0; @@ -181,8 +181,9 @@ $roomlist-filter-active-bg-color: $roomlist-button-bg-color; $roomlist-bg-color: $header-panel-bg-color; $roomlist-header-color: $primary-fg-color; $roomsublist-divider-color: $primary-fg-color; +$roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%); -$tagpanel-divider-color: $roomlist-header-color; +$groupFilterPanel-divider-color: $roomlist-header-color; $roomtile-preview-color: #9e9e9e; $roomtile-default-badge-bg-color: #61708b; @@ -208,6 +209,7 @@ $panel-divider-color: #dee1f3; // ******************** $widget-menu-bar-bg-color: $secondary-accent-color; +$widget-body-bg-color: #fff; // ******************** @@ -305,7 +307,7 @@ $reaction-row-button-selected-border-color: $accent-color; $kbd-border-color: $reaction-row-button-border-color; -$tooltip-timeline-bg-color: $tagpanel-bg-color; +$tooltip-timeline-bg-color: $groupFilterPanel-bg-color; $tooltip-timeline-fg-color: #ffffff; $interactive-tooltip-bg-color: #27303a; diff --git a/res/themes/light-custom/css/_custom.scss b/res/themes/light-custom/css/_custom.scss index 6bb46e8a67..1b9254d100 100644 --- a/res/themes/light-custom/css/_custom.scss +++ b/res/themes/light-custom/css/_custom.scss @@ -49,7 +49,7 @@ $roomtile-selected-bg-color: var(--roomlist-highlights-color); // // --sidebar-color $interactive-tooltip-bg-color: var(--sidebar-color); -$tagpanel-bg-color: var(--sidebar-color); +$groupFilterPanel-bg-color: var(--sidebar-color); $tooltip-timeline-bg-color: var(--sidebar-color); $dialog-backdrop-color: var(--sidebar-color-50pct); $roomlist-button-bg-color: var(--sidebar-color-15pct); diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 140783212d..5437a6de1c 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -62,7 +62,7 @@ $preview-bar-bg-color: #f7f7f7; $secondary-accent-color: #f2f5f8; $tertiary-accent-color: #d3efe1; -$tagpanel-bg-color: rgba(232, 232, 232, 0.77); +$groupFilterPanel-bg-color: rgba(232, 232, 232, 0.77); // used by RoomDirectory permissions $plinth-bg-color: $secondary-accent-color; @@ -156,7 +156,7 @@ $roomheader-color: #45474a; $roomheader-bg-color: $primary-bg-color; $roomheader-addroom-bg-color: rgba(92, 100, 112, 0.2); $roomheader-addroom-fg-color: #5c6470; -$tagpanel-button-color: #91A1C0; +$groupFilterPanel-button-color: #91A1C0; $groupheader-button-color: #91A1C0; $rightpanel-button-color: #91A1C0; $icon-button-color: #C1C6CD; @@ -175,8 +175,9 @@ $roomlist-filter-active-bg-color: #ffffff; $roomlist-bg-color: rgba(245, 245, 245, 0.90); $roomlist-header-color: $tertiary-fg-color; $roomsublist-divider-color: $primary-fg-color; +$roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%); -$tagpanel-divider-color: $roomlist-header-color; +$groupFilterPanel-divider-color: $roomlist-header-color; $roomtile-preview-color: $secondary-fg-color; $roomtile-default-badge-bg-color: #61708b; @@ -208,6 +209,7 @@ $pinned-color: $notice-secondary-color; // ******************** $widget-menu-bar-bg-color: $secondary-accent-color; +$widget-body-bg-color: #FFF; // ******************** @@ -320,7 +322,7 @@ $appearance-tab-border-color: $input-darker-bg-color; // blur amounts for left left panel (only for element theme, used in _mods.scss) $roomlist-background-blur-amount: 40px; -$tagpanel-background-blur-amount: 20px; +$groupFilterPanel-background-blur-amount: 20px; $composer-shadow-color: rgba(0, 0, 0, 0.04); diff --git a/res/themes/light/css/_mods.scss b/res/themes/light/css/_mods.scss index 9a59acba8e..30aaeedf8f 100644 --- a/res/themes/light/css/_mods.scss +++ b/res/themes/light/css/_mods.scss @@ -6,14 +6,14 @@ @supports (backdrop-filter: none) { .mx_LeftPanel { - background-image: var(--avatar-url); + background-image: var(--avatar-url, unset); background-repeat: no-repeat; background-size: cover; background-position: left top; } - .mx_TagPanel { - backdrop-filter: blur($tagpanel-background-blur-amount); + .mx_GroupFilterPanel { + backdrop-filter: blur($groupFilterPanel-background-blur-amount); } .mx_LeftPanel .mx_LeftPanel_roomListContainer { diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 93be0fafc0..741798761f 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -32,6 +32,10 @@ import type {Renderer} from "react-dom"; import RightPanelStore from "../stores/RightPanelStore"; import WidgetStore from "../stores/WidgetStore"; import CallHandler from "../CallHandler"; +import {Analytics} from "../Analytics"; +import CountlyAnalytics from "../CountlyAnalytics"; +import UserActivity from "../UserActivity"; +import {ModalWidgetStore} from "../stores/ModalWidgetStore"; declare global { interface Window { @@ -56,11 +60,22 @@ declare global { mxRightPanelStore: RightPanelStore; mxWidgetStore: WidgetStore; mxCallHandler: CallHandler; + mxAnalytics: Analytics; + mxCountlyAnalytics: typeof CountlyAnalytics; + mxUserActivity: UserActivity; + mxModalWidgetStore: ModalWidgetStore; } interface Document { // https://developer.mozilla.org/en-US/docs/Web/API/Document/hasStorageAccess hasStorageAccess?: () => Promise; + + // Safari & IE11 only have this prefixed: we used prefixed versions + // previously so let's continue to support them for now + webkitExitFullscreen(): Promise; + msExitFullscreen(): Promise; + readonly webkitFullscreenElement: Element | null; + readonly msFullscreenElement: Element | null; } interface Navigator { @@ -90,4 +105,20 @@ declare global { interface HTMLAudioElement { type?: string; } + + interface Element { + // Safari & IE11 only have this prefixed: we used prefixed versions + // previously so let's continue to support them for now + webkitRequestFullScreen(options?: FullscreenOptions): Promise; + msRequestFullscreen(options?: FullscreenOptions): Promise; + } + + interface Error { + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/fileName + fileName?: string; + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/lineNumber + lineNumber?: number; + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/columnNumber + columnNumber?: number; + } } diff --git a/src/Analytics.js b/src/Analytics.tsx similarity index 74% rename from src/Analytics.js rename to src/Analytics.tsx index 135cc2eb7a..212bfd3757 100644 --- a/src/Analytics.js +++ b/src/Analytics.tsx @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; -import { getCurrentLanguage, _t, _td } from './languageHandler'; +import {getCurrentLanguage, _t, _td, IVariables} from './languageHandler'; import PlatformPeg from './PlatformPeg'; import SdkConfig from './SdkConfig'; import Modal from './Modal'; @@ -27,7 +27,7 @@ const hashRegex = /#\/(groups?|room|user|settings|register|login|forgot_password const hashVarRegex = /#\/(group|room|user)\/.*$/; // Remove all but the first item in the hash path. Redact unexpected hashes. -function getRedactedHash(hash) { +function getRedactedHash(hash: string): string { // Don't leak URLs we aren't expecting - they could contain tokens/PII const match = hashRegex.exec(hash); if (!match) { @@ -44,7 +44,7 @@ function getRedactedHash(hash) { // Return the current origin, path and hash separated with a `/`. This does // not include query parameters. -function getRedactedUrl() { +function getRedactedUrl(): string { const { origin, hash } = window.location; let { pathname } = window.location; @@ -56,7 +56,25 @@ function getRedactedUrl() { return origin + pathname + getRedactedHash(hash); } -const customVariables = { +interface IData { + /* eslint-disable camelcase */ + gt_ms?: string; + e_c?: string; + e_a?: string; + e_n?: string; + e_v?: string; + ping?: string; + /* eslint-enable camelcase */ +} + +interface IVariable { + id: number; + expl: string; // explanation + example: string; // example value + getTextVariables?(): IVariables; // object to pass as 2nd argument to `_t` +} + +const customVariables: Record = { // The Matomo installation at https://matomo.riot.im is currently configured // with a limit of 10 custom variables. 'App Platform': { @@ -120,7 +138,7 @@ const customVariables = { }, }; -function whitelistRedact(whitelist, str) { +function whitelistRedact(whitelist: string[], str: string): string { if (whitelist.includes(str)) return str; return ''; } @@ -130,7 +148,7 @@ const CREATION_TS_KEY = "mx_Riot_Analytics_cts"; const VISIT_COUNT_KEY = "mx_Riot_Analytics_vc"; const LAST_VISIT_TS_KEY = "mx_Riot_Analytics_lvts"; -function getUid() { +function getUid(): string { try { let data = localStorage && localStorage.getItem(UID_KEY); if (!data && localStorage) { @@ -145,32 +163,36 @@ function getUid() { const HEARTBEAT_INTERVAL = 30 * 1000; // seconds -class Analytics { +export class Analytics { + private baseUrl: URL = null; + private siteId: string = null; + private visitVariables: Record = {}; // {[id: number]: [name: string, value: string]} + private firstPage = true; + private heartbeatIntervalID: number = null; + + private readonly creationTs: string; + private readonly lastVisitTs: string; + private readonly visitCount: string; + constructor() { - this.baseUrl = null; - this.siteId = null; - this.visitVariables = {}; - - this.firstPage = true; - this._heartbeatIntervalID = null; - this.creationTs = localStorage && localStorage.getItem(CREATION_TS_KEY); if (!this.creationTs && localStorage) { - localStorage.setItem(CREATION_TS_KEY, this.creationTs = new Date().getTime()); + localStorage.setItem(CREATION_TS_KEY, this.creationTs = String(new Date().getTime())); } this.lastVisitTs = localStorage && localStorage.getItem(LAST_VISIT_TS_KEY); - this.visitCount = localStorage && localStorage.getItem(VISIT_COUNT_KEY) || 0; + this.visitCount = localStorage && localStorage.getItem(VISIT_COUNT_KEY) || "0"; + this.visitCount = String(parseInt(this.visitCount, 10) + 1); // increment if (localStorage) { - localStorage.setItem(VISIT_COUNT_KEY, parseInt(this.visitCount, 10) + 1); + localStorage.setItem(VISIT_COUNT_KEY, this.visitCount); } } - get disabled() { + public get disabled() { return !this.baseUrl; } - canEnable() { + public canEnable() { const config = SdkConfig.get(); return navigator.doNotTrack !== "1" && config && config.piwik && config.piwik.url && config.piwik.siteId; } @@ -179,67 +201,67 @@ class Analytics { * Enable Analytics if initialized but disabled * otherwise try and initalize, no-op if piwik config missing */ - async enable() { + public async enable() { if (!this.disabled) return; if (!this.canEnable()) return; const config = SdkConfig.get(); this.baseUrl = new URL("piwik.php", config.piwik.url); // set constants - this.baseUrl.searchParams.set("rec", 1); // rec is required for tracking + this.baseUrl.searchParams.set("rec", "1"); // rec is required for tracking this.baseUrl.searchParams.set("idsite", config.piwik.siteId); // rec is required for tracking - this.baseUrl.searchParams.set("apiv", 1); // API version to use - this.baseUrl.searchParams.set("send_image", 0); // we want a 204, not a tiny GIF + this.baseUrl.searchParams.set("apiv", "1"); // API version to use + this.baseUrl.searchParams.set("send_image", "0"); // we want a 204, not a tiny GIF // set user parameters this.baseUrl.searchParams.set("_id", getUid()); // uuid this.baseUrl.searchParams.set("_idts", this.creationTs); // first ts - this.baseUrl.searchParams.set("_idvc", parseInt(this.visitCount, 10)+ 1); // visit count + this.baseUrl.searchParams.set("_idvc", this.visitCount); // visit count if (this.lastVisitTs) { this.baseUrl.searchParams.set("_viewts", this.lastVisitTs); // last visit ts } const platform = PlatformPeg.get(); - this._setVisitVariable('App Platform', platform.getHumanReadableName()); + this.setVisitVariable('App Platform', platform.getHumanReadableName()); try { - this._setVisitVariable('App Version', await platform.getAppVersion()); + this.setVisitVariable('App Version', await platform.getAppVersion()); } catch (e) { - this._setVisitVariable('App Version', 'unknown'); + this.setVisitVariable('App Version', 'unknown'); } - this._setVisitVariable('Chosen Language', getCurrentLanguage()); + this.setVisitVariable('Chosen Language', getCurrentLanguage()); const hostname = window.location.hostname; if (hostname === 'riot.im') { - this._setVisitVariable('Instance', window.location.pathname); + this.setVisitVariable('Instance', window.location.pathname); } else if (hostname.endsWith('.element.io')) { - this._setVisitVariable('Instance', hostname.replace('.element.io', '')); + this.setVisitVariable('Instance', hostname.replace('.element.io', '')); } let installedPWA = "unknown"; try { // Known to work at least for desktop Chrome - installedPWA = window.matchMedia('(display-mode: standalone)').matches; + installedPWA = String(window.matchMedia('(display-mode: standalone)').matches); } catch (e) { } - this._setVisitVariable('Installed PWA', installedPWA); + this.setVisitVariable('Installed PWA', installedPWA); let touchInput = "unknown"; try { // MDN claims broad support across browsers - touchInput = window.matchMedia('(pointer: coarse)').matches; + touchInput = String(window.matchMedia('(pointer: coarse)').matches); } catch (e) { } - this._setVisitVariable('Touch Input', touchInput); + this.setVisitVariable('Touch Input', touchInput); // start heartbeat - this._heartbeatIntervalID = window.setInterval(this.ping.bind(this), HEARTBEAT_INTERVAL); + this.heartbeatIntervalID = window.setInterval(this.ping.bind(this), HEARTBEAT_INTERVAL); } /** * Disable Analytics, stop the heartbeat and clear identifiers from localStorage */ - disable() { + public disable() { if (this.disabled) return; this.trackEvent('Analytics', 'opt-out'); - window.clearInterval(this._heartbeatIntervalID); + window.clearInterval(this.heartbeatIntervalID); this.baseUrl = null; this.visitVariables = {}; localStorage.removeItem(UID_KEY); @@ -248,7 +270,7 @@ class Analytics { localStorage.removeItem(LAST_VISIT_TS_KEY); } - async _track(data) { + private async _track(data: IData) { if (this.disabled) return; const now = new Date(); @@ -264,13 +286,13 @@ class Analytics { s: now.getSeconds(), }; - const url = new URL(this.baseUrl); + const url = new URL(this.baseUrl.toString()); // copy for (const key in params) { url.searchParams.set(key, params[key]); } try { - await window.fetch(url, { + await window.fetch(url.toString(), { method: "GET", mode: "no-cors", cache: "no-cache", @@ -281,14 +303,14 @@ class Analytics { } } - ping() { + public ping() { this._track({ - ping: 1, + ping: "1", }); - localStorage.setItem(LAST_VISIT_TS_KEY, new Date().getTime()); // update last visit ts + localStorage.setItem(LAST_VISIT_TS_KEY, String(new Date().getTime())); // update last visit ts } - trackPageChange(generationTimeMs) { + public trackPageChange(generationTimeMs?: number) { if (this.disabled) return; if (this.firstPage) { // De-duplicate first page @@ -303,11 +325,11 @@ class Analytics { } this._track({ - gt_ms: generationTimeMs, + gt_ms: String(generationTimeMs), }); } - trackEvent(category, action, name, value) { + public trackEvent(category: string, action: string, name?: string, value?: string) { if (this.disabled) return; this._track({ e_c: category, @@ -317,12 +339,12 @@ class Analytics { }); } - _setVisitVariable(key, value) { + private setVisitVariable(key: keyof typeof customVariables, value: string) { if (this.disabled) return; this.visitVariables[customVariables[key].id] = [key, value]; } - setLoggedIn(isGuest, homeserverUrl, identityServerUrl) { + public setLoggedIn(isGuest: boolean, homeserverUrl: string) { if (this.disabled) return; const config = SdkConfig.get(); @@ -330,16 +352,16 @@ class Analytics { const whitelistedHSUrls = config.piwik.whitelistedHSUrls || []; - this._setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In'); - this._setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl)); + this.setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In'); + this.setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl)); } - setBreadcrumbs(state) { + public setBreadcrumbs(state: boolean) { if (this.disabled) return; - this._setVisitVariable('Breadcrumbs', state ? 'enabled' : 'disabled'); + this.setVisitVariable('Breadcrumbs', state ? 'enabled' : 'disabled'); } - showDetailsModal = () => { + public showDetailsModal = () => { let rows = []; if (!this.disabled) { rows = Object.values(this.visitVariables); @@ -360,7 +382,7 @@ class Analytics { 'e.g. ', {}, { - CurrentPageURL: getRedactedUrl(), + CurrentPageURL: getRedactedUrl, }, ), }, @@ -401,7 +423,7 @@ class Analytics { }; } -if (!global.mxAnalytics) { - global.mxAnalytics = new Analytics(); +if (!window.mxAnalytics) { + window.mxAnalytics = new Analytics(); } -export default global.mxAnalytics; +export default window.mxAnalytics; diff --git a/src/Avatar.js b/src/Avatar.ts similarity index 85% rename from src/Avatar.js rename to src/Avatar.ts index 1c1182b98d..60bdfdcf75 100644 --- a/src/Avatar.js +++ b/src/Avatar.ts @@ -14,14 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; +import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; +import {RoomMember} from "matrix-js-sdk/src/models/room-member"; +import {User} from "matrix-js-sdk/src/models/user"; +import {Room} from "matrix-js-sdk/src/models/room"; + import {MatrixClientPeg} from './MatrixClientPeg'; import DMRoomMap from './utils/DMRoomMap'; -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; + +export type ResizeMethod = "crop" | "scale"; // Not to be used for BaseAvatar urls as that has similar default avatar fallback already -export function avatarUrlForMember(member, width, height, resizeMethod) { - let url; +export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) { + let url: string; if (member && member.getAvatarUrl) { url = member.getAvatarUrl( MatrixClientPeg.get().getHomeserverUrl(), @@ -41,7 +46,7 @@ export function avatarUrlForMember(member, width, height, resizeMethod) { return url; } -export function avatarUrlForUser(user, width, height, resizeMethod) { +export function avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) { const url = getHttpUriForMxc( MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl, Math.floor(width * window.devicePixelRatio), @@ -54,14 +59,14 @@ export function avatarUrlForUser(user, width, height, resizeMethod) { return url; } -function isValidHexColor(color) { +function isValidHexColor(color: string): boolean { return typeof color === "string" && - (color.length === 7 || color.lengh === 9) && + (color.length === 7 || color.length === 9) && color.charAt(0) === "#" && !color.substr(1).split("").some(c => isNaN(parseInt(c, 16))); } -function urlForColor(color) { +function urlForColor(color: string): string { const size = 40; const canvas = document.createElement("canvas"); canvas.width = size; @@ -79,9 +84,9 @@ function urlForColor(color) { // XXX: Ideally we'd clear this cache when the theme changes // but since this function is at global scope, it's a bit // hard to install a listener here, even if there were a clear event to listen to -const colorToDataURLCache = new Map(); +const colorToDataURLCache = new Map(); -export function defaultAvatarUrlForString(s) { +export function defaultAvatarUrlForString(s: string): string { if (!s) return ""; // XXX: should never happen but empirically does by evidence of a rageshake const defaultColors = ['#0DBD8B', '#368bd6', '#ac3ba8']; let total = 0; @@ -113,7 +118,7 @@ export function defaultAvatarUrlForString(s) { * @param {string} name * @return {string} the first letter */ -export function getInitialLetter(name) { +export function getInitialLetter(name: string): string { if (!name) { // XXX: We should find out what causes the name to sometimes be falsy. console.trace("`name` argument to `getInitialLetter` not supplied"); @@ -146,7 +151,7 @@ export function getInitialLetter(name) { return firstChar.toUpperCase(); } -export function avatarUrlForRoom(room, width, height, resizeMethod) { +export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) { if (!room) return null; // null-guard const explicitRoomAvatar = room.getAvatarUrl( diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index 4d06c5df73..0a1f06f0b3 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -24,6 +24,7 @@ import {ActionPayload} from "./dispatcher/payloads"; import {CheckUpdatesPayload} from "./dispatcher/payloads/CheckUpdatesPayload"; import {Action} from "./dispatcher/actions"; import {hideToast as hideUpdateToast} from "./toasts/UpdateToast"; +import {MatrixClientPeg} from "./MatrixClientPeg"; export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url"; export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url"; @@ -105,6 +106,9 @@ export default abstract class BasePlatform { * @param newVersion the version string to check */ protected shouldShowUpdate(newVersion: string): boolean { + // If the user registered on this client in the last 24 hours then do not show them the update toast + if (MatrixClientPeg.userRegisteredWithinLastHours(24)) return false; + try { const [version, deferUntil] = JSON.parse(localStorage.getItem(UPDATE_DEFER_KEY)); return newVersion !== version || Date.now() > deferUntil; diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 6b66a614d2..710eded2cd 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -59,8 +59,7 @@ import {MatrixClientPeg} from './MatrixClientPeg'; import PlatformPeg from './PlatformPeg'; import Modal from './Modal'; import { _t } from './languageHandler'; -// @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising -import Matrix from 'matrix-js-sdk'; +import { createNewMatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import dis from './dispatcher/dispatcher'; import WidgetUtils from './utils/WidgetUtils'; import WidgetEchoStore from './stores/WidgetEchoStore'; @@ -77,7 +76,9 @@ import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import WidgetStore from "./stores/WidgetStore"; import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore"; import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions"; -import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty } from "matrix-js-sdk/lib/webrtc/call"; +import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty, CallType } from "matrix-js-sdk/src/webrtc/call"; +import Analytics from './Analytics'; +import CountlyAnalytics from "./CountlyAnalytics"; enum AudioID { Ring = 'ringAudio', @@ -96,6 +97,21 @@ export enum PlaceCallType { ScreenSharing = 'screensharing', } +function getRemoteAudioElement(): HTMLAudioElement { + // this needs to be somewhere at the top of the DOM which + // always exists to avoid audio interruptions. + // Might as well just use DOM. + const remoteAudioElement = document.getElementById("remoteAudio") as HTMLAudioElement; + if (!remoteAudioElement) { + console.error( + "Failed to find remoteAudio element - cannot play audio!" + + "You need to add an to the DOM.", + ); + return null; + } + return remoteAudioElement; +} + export default class CallHandler { private calls = new Map(); private audioPromises = new Map>(); @@ -179,8 +195,19 @@ export default class CallHandler { } } + private matchesCallForThisRoom(call: MatrixCall) { + // We don't allow placing more than one call per room, but that doesn't mean there + // can't be more than one, eg. in a glare situation. This checks that the given call + // is the call we consider 'the' call for its room. + const callForThisRoom = this.getCallForRoom(call.roomId); + return callForThisRoom && call.callId === callForThisRoom.callId; + } + private setCallListeners(call: MatrixCall) { call.on(CallEvent.Error, (err) => { + if (!this.matchesCallForThisRoom(call)) return; + + Analytics.trackEvent('voip', 'callError', 'error', err); console.error("Call error:", err); if ( MatrixClientPeg.get().getTurnServers().length === 0 && @@ -196,9 +223,15 @@ export default class CallHandler { }); }); call.on(CallEvent.Hangup, () => { + if (!this.matchesCallForThisRoom(call)) return; + + Analytics.trackEvent('voip', 'callHangup'); + this.removeCallForRoom(call.roomId); }); call.on(CallEvent.State, (newState: CallState, oldState: CallState) => { + if (!this.matchesCallForThisRoom(call)) return; + this.setCallState(call, newState); switch (oldState) { @@ -218,21 +251,63 @@ export default class CallHandler { this.play(AudioID.Ringback); break; case CallState.Ended: + Analytics.trackEvent('voip', 'callEnded', 'hangupReason', call.hangupReason); this.removeCallForRoom(call.roomId); if (oldState === CallState.InviteSent && ( call.hangupParty === CallParty.Remote || (call.hangupParty === CallParty.Local && call.hangupReason === CallErrorCode.InviteTimeout) )) { this.play(AudioID.Busy); - Modal.createTrackedDialog('Call Handler', 'Call Timeout', ErrorDialog, { - title: _t('Call Timeout'), - description: _t('The remote side failed to pick up') + '.', + let title; + let description; + if (call.hangupReason === CallErrorCode.UserHangup) { + title = _t("Call Declined"); + description = _t("The other party declined the call."); + } else if (call.hangupReason === CallErrorCode.InviteTimeout) { + title = _t("Call Failed"); + // XXX: full stop appended as some relic here, but these + // strings need proper input from design anyway, so let's + // not change this string until we have a proper one. + description = _t('The remote side failed to pick up') + '.'; + } else { + title = _t("Call Failed"); + description = _t("The call could not be established"); + } + + Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, { + title, description, + }); + } else if (call.hangupReason === CallErrorCode.AnsweredElsewhere) { + this.play(AudioID.Busy); + Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, { + title: _t("Answered Elsewhere"), + description: _t("The call was answered on another device."), }); } else { this.play(AudioID.CallEnd); } } }); + call.on(CallEvent.Replaced, (newCall: MatrixCall) => { + if (!this.matchesCallForThisRoom(call)) return; + + console.log(`Call ID ${call.callId} is being replaced by call ID ${newCall.callId}`); + + if (call.state === CallState.Ringing) { + this.pause(AudioID.Ring); + } else if (call.state === CallState.InviteSent) { + this.pause(AudioID.Ringback); + } + + this.calls.set(newCall.roomId, newCall); + this.setCallListeners(newCall); + this.setCallState(newCall, newCall.state); + }); + } + + private setCallAudioElement(call: MatrixCall) { + const audioElement = getRemoteAudioElement(); + if (audioElement) call.setRemoteAudioElement(audioElement); } private setCallState(call: MatrixCall, status: CallState) { @@ -285,9 +360,13 @@ export default class CallHandler { roomId: string, type: PlaceCallType, localElement: HTMLVideoElement, remoteElement: HTMLVideoElement, ) { - const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), roomId); + Analytics.trackEvent('voip', 'placeCall', 'type', type); + CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false); + const call = createNewMatrixCall(MatrixClientPeg.get(), roomId); this.calls.set(roomId, call); this.setCallListeners(call); + this.setCallAudioElement(call); + if (type === PlaceCallType.Voice) { call.placeVoiceCall(); } else if (type === 'video') { @@ -362,6 +441,8 @@ export default class CallHandler { break; case 'place_conference_call': console.info("Place conference call in %s", payload.room_id); + Analytics.trackEvent('voip', 'placeConferenceCall'); + CountlyAnalytics.instance.trackStartCall(payload.room_id, payload.type === PlaceCallType.Video, true); this.startCallApp(payload.room_id, payload.type); break; case 'end_conference': @@ -388,27 +469,37 @@ export default class CallHandler { } const call = payload.call as MatrixCall; + Analytics.trackEvent('voip', 'receiveCall', 'type', call.type); this.calls.set(call.roomId, call) this.setCallListeners(call); + this.setCallAudioElement(call); } break; case 'hangup': + case 'reject': if (!this.calls.get(payload.room_id)) { return; // no call to hangup } - this.calls.get(payload.room_id).hangup(CallErrorCode.UserHangup, false) + if (payload.action === 'reject') { + this.calls.get(payload.room_id).reject(); + } else { + this.calls.get(payload.room_id).hangup(CallErrorCode.UserHangup, false); + } this.removeCallForRoom(payload.room_id); break; - case 'answer': + case 'answer': { if (!this.calls.has(payload.room_id)) { return; // no call to answer } - this.calls.get(payload.room_id).answer(); + const call = this.calls.get(payload.room_id); + call.answer(); + CountlyAnalytics.instance.trackJoinCall(payload.room_id, call.type === CallType.Video, false); dis.dispatch({ action: "view_room", room_id: payload.room_id, }); break; + } } } diff --git a/src/ContentMessages.tsx b/src/ContentMessages.tsx index eb8fff0eb1..5409a606de 100644 --- a/src/ContentMessages.tsx +++ b/src/ContentMessages.tsx @@ -17,7 +17,6 @@ limitations under the License. */ import React from "react"; -import extend from './extend'; import dis from './dispatcher/dispatcher'; import {MatrixClientPeg} from './MatrixClientPeg'; import {MatrixClient} from "matrix-js-sdk/src/client"; @@ -32,6 +31,7 @@ import Spinner from "./components/views/elements/Spinner"; // Polyfill for Canvas.toBlob API using Canvas.toDataURL import "blueimp-canvas-to-blob"; import { Action } from "./dispatcher/actions"; +import CountlyAnalytics from "./CountlyAnalytics"; const MAX_WIDTH = 800; const MAX_HEIGHT = 600; @@ -369,10 +369,13 @@ export default class ContentMessages { private mediaConfig: IMediaConfig = null; sendStickerContentToRoom(url: string, roomId: string, info: string, text: string, matrixClient: MatrixClient) { - return MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => { + const startTime = CountlyAnalytics.getTimestamp(); + const prom = MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => { console.warn(`Failed to send content with URL ${url} to room ${roomId}`, e); throw e; }); + CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, {msgtype: "m.sticker"}); + return prom; } getUploadLimit() { @@ -480,6 +483,7 @@ export default class ContentMessages { } private sendContentToRoom(file: File, roomId: string, matrixClient: MatrixClient, promBefore: Promise) { + const startTime = CountlyAnalytics.getTimestamp(); const content: IContent = { body: file.name || 'Attachment', info: { @@ -497,7 +501,7 @@ export default class ContentMessages { if (file.type.indexOf('image/') === 0) { content.msgtype = 'm.image'; infoForImageFile(matrixClient, roomId, file).then((imageInfo) => { - extend(content.info, imageInfo); + Object.assign(content.info, imageInfo); resolve(); }, (e) => { console.error(e); @@ -510,7 +514,7 @@ export default class ContentMessages { } else if (file.type.indexOf('video/') === 0) { content.msgtype = 'm.video'; infoForVideoFile(matrixClient, roomId, file).then((videoInfo) => { - extend(content.info, videoInfo); + Object.assign(content.info, videoInfo); resolve(); }, (e) => { content.msgtype = 'm.file'; @@ -564,7 +568,9 @@ export default class ContentMessages { return promBefore; }).then(function() { if (upload.canceled) throw new UploadCanceledError(); - return matrixClient.sendMessage(roomId, content); + const prom = matrixClient.sendMessage(roomId, content); + CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, content); + return prom; }, function(err) { error = err; if (!upload.canceled) { diff --git a/src/CountlyAnalytics.ts b/src/CountlyAnalytics.ts new file mode 100644 index 0000000000..b4727bc88b --- /dev/null +++ b/src/CountlyAnalytics.ts @@ -0,0 +1,973 @@ +/* +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 {randomString} from "matrix-js-sdk/src/randomstring"; + +import {getCurrentLanguage} from './languageHandler'; +import PlatformPeg from './PlatformPeg'; +import SdkConfig from './SdkConfig'; +import {MatrixClientPeg} from "./MatrixClientPeg"; +import {sleep} from "./utils/promise"; +import RoomViewStore from "./stores/RoomViewStore"; + +// polyfill textencoder if necessary +import * as TextEncodingUtf8 from 'text-encoding-utf-8'; +let TextEncoder = window.TextEncoder; +if (!TextEncoder) { + TextEncoder = TextEncodingUtf8.TextEncoder; +} + +const INACTIVITY_TIME = 20; // seconds +const HEARTBEAT_INTERVAL = 5_000; // ms +const SESSION_UPDATE_INTERVAL = 60; // seconds +const MAX_PENDING_EVENTS = 1000; + +enum Orientation { + Landscape = "landscape", + Portrait = "portrait", +} + +/* eslint-disable camelcase */ +interface IMetrics { + _resolution?: string; + _app_version?: string; + _density?: number; + _ua?: string; + _locale?: string; +} + +interface IEvent { + key: string; + count: number; + sum?: number; + dur?: number; + segmentation?: Record; + timestamp?: number; // TODO should we use the timestamp when we start or end for the event timestamp + hour?: unknown; + dow?: unknown; +} + +interface IViewEvent extends IEvent { + key: "[CLY]_view"; +} + +interface IOrientationEvent extends IEvent { + key: "[CLY]_orientation"; + segmentation: { + mode: Orientation; + }; +} + +interface IStarRatingEvent extends IEvent { + key: "[CLY]_star_rating"; + segmentation: { + // we just care about collecting feedback, no need to associate with a feedback widget + widget_id?: string; + contactMe?: boolean; + email?: string; + rating: 1 | 2 | 3 | 4 | 5; + comment: string; + }; +} + +type Value = string | number | boolean; + +interface IOperationInc { + "$inc": number; +} +interface IOperationMul { + "$mul": number; +} +interface IOperationMax { + "$max": number; +} +interface IOperationMin { + "$min": number; +} +interface IOperationSetOnce { + "$setOnce": Value; +} +interface IOperationPush { + "$push": Value | Value[]; +} +interface IOperationAddToSet { + "$addToSet": Value | Value[]; +} +interface IOperationPull { + "$pull": Value | Value[]; +} + +type Operation = + IOperationInc | + IOperationMul | + IOperationMax | + IOperationMin | + IOperationSetOnce | + IOperationPush | + IOperationAddToSet | + IOperationPull; + +interface IUserDetails { + name?: string; + username?: string; + email?: string; + organization?: string; + phone?: string; + picture?: string; + gender?: string; + byear?: number; + custom?: Record; // `.` and `$` will be stripped out +} + +interface ICrash { + _resolution?: string; + _app_version: string; + + _ram_current?: number; + _ram_total?: number; + _disk_current?: number; + _disk_total?: number; + _orientation?: Orientation; + + _online?: boolean; + _muted?: boolean; + _background?: boolean; + _view?: string; + + _name?: string; + _error: string; + _nonfatal?: boolean; + _logs?: string; + _run?: number; + + _custom?: Record; +} + +interface IParams { + // APP_KEY of an app for which to report + app_key: string; + // User identifier + device_id: string; + + // Should provide value 1 to indicate session start + begin_session?: number; + // JSON object as string to provide metrics to track with the user + metrics?: string; + // Provides session duration in seconds, can be used as heartbeat to update current sessions duration, recommended time every 60 seconds + session_duration?: number; + // Should provide value 1 to indicate session end + end_session?: number; + + // 10 digit UTC timestamp for recording past data. + timestamp?: number; + // current user local hour (0 - 23) + hour?: number; + // day of the week (0-sunday, 1 - monday, ... 6 - saturday) + dow?: number; + + // JSON array as string containing event objects + events?: string; // IEvent[] + // JSON object as string containing information about users + user_details?: string; + + // provide when changing device ID, so server would merge the data + old_device_id?: string; + + // See ICrash + crash?: string; +} + +interface IRoomSegments extends Record { + room_id: string; // hashed + num_users: number; + is_encrypted: boolean; + is_public: boolean; +} + +interface ISendMessageEvent extends IEvent { + key: "send_message"; + dur: number; // how long it to send (until remote echo) + segmentation: IRoomSegments & { + is_edit: boolean; + is_reply: boolean; + msgtype: string; + format?: string; + }; +} + +interface IRoomDirectoryEvent extends IEvent { + key: "room_directory"; +} + +interface IRoomDirectoryDoneEvent extends IEvent { + key: "room_directory_done"; + dur: number; // time spent in the room directory modal +} + +interface IRoomDirectorySearchEvent extends IEvent { + key: "room_directory_search"; + sum: number; // number of search results + segmentation: { + query_length: number; + query_num_words: number; + }; +} + +interface IStartCallEvent extends IEvent { + key: "start_call"; + segmentation: IRoomSegments & { + is_video: boolean; + is_jitsi: boolean; + }; +} + +interface IJoinCallEvent extends IEvent { + key: "join_call"; + segmentation: IRoomSegments & { + is_video: boolean; + is_jitsi: boolean; + }; +} + +interface IBeginInviteEvent extends IEvent { + key: "begin_invite"; + segmentation: IRoomSegments; +} + +interface ISendInviteEvent extends IEvent { + key: "send_invite"; + sum: number; // quantity that was invited + segmentation: IRoomSegments; +} + +interface ICreateRoomEvent extends IEvent { + key: "create_room"; + dur: number; // how long it took to create (until remote echo) + segmentation: { + room_id: string; // hashed + num_users: number; + is_encrypted: boolean; + is_public: boolean; + } +} + +interface IJoinRoomEvent extends IEvent { + key: "join_room"; + dur: number; // how long it took to join (until remote echo) + segmentation: { + room_id: string; // hashed + num_users: number; + is_encrypted: boolean; + is_public: boolean; + type: "room_directory" | "slash_command" | "link" | "invite"; + }; +} +/* eslint-enable camelcase */ + +const hashHex = async (input: string): Promise => { + const buf = new TextEncoder().encode(input); + const digestBuf = await window.crypto.subtle.digest("sha-256", buf); + return [...new Uint8Array(digestBuf)].map((b: number) => b.toString(16).padStart(2, "0")).join(""); +}; + +const knownScreens = new Set([ + "register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory", + "start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group", +]); + +interface IViewData { + name: string; + url: string; + meta: Record; +} + +// Apply fn to all hash path parts after the 1st one +async function getViewData(anonymous = true): Promise { + const rand = randomString(8); + const { origin, hash } = window.location; + let { pathname } = window.location; + + // Redact paths which could contain unexpected PII + if (origin.startsWith('file://')) { + pathname = `//`; // XXX: inject rand because Count.ly doesn't like X->X transitions + } + + let [_, screen, ...parts] = hash.split("/"); + + if (!knownScreens.has(screen)) { + screen = ``; + } + + for (let i = 0; i < parts.length; i++) { + parts[i] = anonymous ? `` : await hashHex(parts[i]); + } + + const hashStr = `${_}/${screen}/${parts.join("/")}`; + const url = origin + pathname + hashStr; + + const meta = {}; + + let name = "$/" + hash; + switch (screen) { + case "room": { + name = "view_room"; + const roomId = RoomViewStore.getRoomId(); + name += " " + parts[0]; // XXX: workaround Count.ly missing X->X transitions + meta["room_id"] = parts[0]; + Object.assign(meta, getRoomStats(roomId)); + break; + } + } + + return { name, url, meta }; +} + +const getRoomStats = (roomId: string) => { + const cli = MatrixClientPeg.get(); + const room = cli?.getRoom(roomId); + + return { + "num_users": room?.getJoinedMemberCount(), + "is_encrypted": cli?.isRoomEncrypted(roomId), + // eslint-disable-next-line camelcase + "is_public": room?.currentState.getStateEvents("m.room.join_rules", "")?.getContent()?.join_rule === "public", + } +} + +// async wrapper for regex-powered String.prototype.replace +const strReplaceAsync = async (str: string, regex: RegExp, fn: (...args: string[]) => Promise) => { + const promises: Promise[] = []; + // dry-run to calculate the replace values + str.replace(regex, (...args: string[]) => { + promises.push(fn(...args)); + return ""; + }); + const values = await Promise.all(promises); + return str.replace(regex, () => values.shift()); +}; + +export default class CountlyAnalytics { + private baseUrl: URL = null; + private appKey: string = null; + private userKey: string = null; + private anonymous: boolean; + private appPlatform: string; + private appVersion = "unknown"; + + private initTime = CountlyAnalytics.getTimestamp(); + private firstPage = true; + private heartbeatIntervalId: NodeJS.Timeout; + private activityIntervalId: NodeJS.Timeout; + private trackTime = true; + private lastBeat: number; + private storedDuration = 0; + private lastView: string; + private lastViewTime = 0; + private lastViewStoredDuration = 0; + private sessionStarted = false; + private heartbeatEnabled = false; + private inactivityCounter = 0; + private pendingEvents: IEvent[] = []; + + private static internalInstance = new CountlyAnalytics(); + + public static get instance(): CountlyAnalytics { + return CountlyAnalytics.internalInstance; + } + + public get disabled() { + return !this.baseUrl; + } + + public canEnable() { + const config = SdkConfig.get(); + return Boolean(navigator.doNotTrack !== "1" && config?.countly?.url && config?.countly?.appKey); + } + + private async changeUserKey(userKey: string, merge = false) { + const oldUserKey = this.userKey; + this.userKey = userKey; + if (oldUserKey && merge) { + await this.request({ old_device_id: oldUserKey }); + } + } + + public async enable(anonymous = true) { + if (!this.disabled && this.anonymous === anonymous) return; + if (!this.canEnable()) return; + + if (!this.disabled) { + // flush request queue as our userKey is going to change, no need to await it + this.request(); + } + + const config = SdkConfig.get(); + this.baseUrl = new URL("/i", config.countly.url); + this.appKey = config.countly.appKey; + + this.anonymous = anonymous; + if (anonymous) { + await this.changeUserKey(randomString(64)) + } else { + await this.changeUserKey(await hashHex(MatrixClientPeg.get().getUserId()), true); + } + + const platform = PlatformPeg.get(); + this.appPlatform = platform.getHumanReadableName(); + try { + this.appVersion = await platform.getAppVersion(); + } catch (e) { + console.warn("Failed to get app version, using 'unknown'"); + } + + // start heartbeat + this.heartbeatIntervalId = setInterval(this.heartbeat.bind(this), HEARTBEAT_INTERVAL); + this.trackSessions(); + this.trackErrors(); + } + + public async disable() { + if (this.disabled) return; + await this.track("Opt-Out" ); + this.endSession(); + window.clearInterval(this.heartbeatIntervalId); + window.clearTimeout(this.activityIntervalId) + this.baseUrl = null; + // remove listeners bound in trackSessions() + window.removeEventListener("beforeunload", this.endSession); + window.removeEventListener("unload", this.endSession); + window.removeEventListener("visibilitychange", this.onVisibilityChange); + window.removeEventListener("mousemove", this.onUserActivity); + window.removeEventListener("click", this.onUserActivity); + window.removeEventListener("keydown", this.onUserActivity); + window.removeEventListener("scroll", this.onUserActivity); + } + + public reportFeedback(rating: 1 | 2 | 3 | 4 | 5, comment: string) { + this.track("[CLY]_star_rating", { rating, comment }, null, {}, true); + } + + public trackPageChange(generationTimeMs?: number) { + if (this.disabled) return; + // TODO use generationTimeMs + this.trackPageView(); + } + + private async trackPageView() { + this.reportViewDuration(); + + await sleep(0); // XXX: we sleep here because otherwise we get the old hash and not the new one + const viewData = await getViewData(this.anonymous); + + const page = viewData.name; + this.lastView = page; + this.lastViewTime = CountlyAnalytics.getTimestamp(); + const segments = { + ...viewData.meta, + name: page, + visit: 1, + domain: window.location.hostname, + view: viewData.url, + segment: this.appPlatform, + start: this.firstPage, + }; + + if (this.firstPage) { + this.firstPage = false; + } + + this.track("[CLY]_view", segments); + } + + public static getTimestamp() { + return Math.floor(new Date().getTime() / 1000); + } + + // store the last ms timestamp returned + // we do this to prevent the ts from ever decreasing in the case of system time changing + private lastMsTs = 0; + + private getMsTimestamp() { + const ts = new Date().getTime(); + if (this.lastMsTs >= ts) { + // increment ts as to keep our data points well-ordered + this.lastMsTs++; + } else { + this.lastMsTs = ts; + } + return this.lastMsTs; + } + + public async recordError(err: Error | string, fatal = false) { + if (this.disabled || this.anonymous) return; + + let error = ""; + if (typeof err === "object") { + if (typeof err.stack !== "undefined") { + error = err.stack; + } else { + if (typeof err.name !== "undefined") { + error += err.name + ":"; + } + if (typeof err.message !== "undefined") { + error += err.message + "\n"; + } + if (typeof err.fileName !== "undefined") { + error += "in " + err.fileName + "\n"; + } + if (typeof err.lineNumber !== "undefined") { + error += "on " + err.lineNumber; + } + if (typeof err.columnNumber !== "undefined") { + error += ":" + err.columnNumber; + } + } + } else { + error = err + ""; + } + + // sanitize the error from identifiers + error = await strReplaceAsync(error, /([!@+#]).+?:[\w:.]+/g, async (substring: string, glyph: string) => { + return glyph + await hashHex(substring.substring(1)); + }); + + const metrics = this.getMetrics(); + const ob: ICrash = { + _resolution: metrics?._resolution, + _error: error, + _app_version: this.appVersion, + _run: CountlyAnalytics.getTimestamp() - this.initTime, + _nonfatal: !fatal, + _view: this.lastView, + }; + + if (typeof navigator.onLine !== "undefined") { + ob._online = navigator.onLine; + } + + ob._background = document.hasFocus(); + + this.request({ crash: JSON.stringify(ob) }); + } + + private trackErrors() { + //override global uncaught error handler + window.onerror = (msg, url, line, col, err) => { + if (typeof err !== "undefined") { + this.recordError(err, false); + } else { + let error = ""; + if (typeof msg !== "undefined") { + error += msg + "\n"; + } + if (typeof url !== "undefined") { + error += "at " + url; + } + if (typeof line !== "undefined") { + error += ":" + line; + } + if (typeof col !== "undefined") { + error += ":" + col; + } + error += "\n"; + + try { + const stack = []; + // eslint-disable-next-line no-caller + let f = arguments.callee.caller; + while (f) { + stack.push(f.name); + f = f.caller; + } + error += stack.join("\n"); + } catch (ex) { + //silent error + } + this.recordError(error, false); + } + }; + + window.addEventListener('unhandledrejection', (event) => { + this.recordError(new Error(`Unhandled rejection (reason: ${event.reason?.stack || event.reason}).`), true); + }); + } + + private heartbeat() { + const args: Pick = {}; + + // extend session if needed + if (this.sessionStarted && this.trackTime) { + const last = CountlyAnalytics.getTimestamp(); + if (last - this.lastBeat >= SESSION_UPDATE_INTERVAL) { + args.session_duration = last - this.lastBeat; + this.lastBeat = last; + } + } + + // process event queue + if (this.pendingEvents.length > 0 || args.session_duration) { + this.request(args); + } + } + + private async request( + args: Omit + & Partial> = {}, + ) { + const request: IParams = { + app_key: this.appKey, + device_id: this.userKey, + ...this.getTimeParams(), + ...args, + }; + + if (this.pendingEvents.length > 0) { + const EVENT_BATCH_SIZE = 10; + const events = this.pendingEvents.splice(0, EVENT_BATCH_SIZE); + request.events = JSON.stringify(events); + } + + const params = new URLSearchParams(request as {}); + + try { + await window.fetch(this.baseUrl.toString(), { + method: "POST", + mode: "no-cors", + cache: "no-cache", + redirect: "follow", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params, + }); + } catch (e) { + console.error("Analytics error: ", e); + } + } + + private getTimeParams(): Pick { + const date = new Date(); + return { + timestamp: this.getMsTimestamp(), + hour: date.getHours(), + dow: date.getDay(), + }; + } + + private queue(args: Omit & Partial>) { + const {count = 1, ...rest} = args; + const ev = { + ...this.getTimeParams(), + ...rest, + count, + platform: this.appPlatform, + app_version: this.appVersion, + } + + this.pendingEvents.push(ev); + if (this.pendingEvents.length > MAX_PENDING_EVENTS) { + this.pendingEvents.shift(); + } + } + + private getOrientation = (): Orientation => { + return window.innerWidth > window.innerHeight ? Orientation.Landscape : Orientation.Portrait; + }; + + private reportOrientation = () => { + this.track("[CLY]_orientation", { + mode: this.getOrientation(), + }); + }; + + private startTime() { + if (!this.trackTime) { + this.trackTime = true; + this.lastBeat = CountlyAnalytics.getTimestamp() - this.storedDuration; + this.lastViewTime = CountlyAnalytics.getTimestamp() - this.lastViewStoredDuration; + this.lastViewStoredDuration = 0; + } + } + + private stopTime() { + if (this.trackTime) { + this.trackTime = false; + this.storedDuration = CountlyAnalytics.getTimestamp() - this.lastBeat; + this.lastViewStoredDuration = CountlyAnalytics.getTimestamp() - this.lastViewTime; + } + } + + private getMetrics(): IMetrics { + if (this.anonymous) return undefined; + const metrics: IMetrics = {}; + + // getting app version + metrics._app_version = this.appVersion; + metrics._ua = navigator.userAgent; + + // getting resolution + if (screen.width && screen.height) { + metrics._resolution = `${screen.width}x${screen.height}`; + } + + // getting density ratio + if (window.devicePixelRatio) { + metrics._density = window.devicePixelRatio; + } + + // getting locale + metrics._locale = getCurrentLanguage(); + + return metrics; + } + + private async beginSession(heartbeat = true) { + if (!this.sessionStarted) { + this.reportOrientation(); + window.addEventListener("resize", this.reportOrientation); + + this.lastBeat = CountlyAnalytics.getTimestamp(); + this.sessionStarted = true; + this.heartbeatEnabled = heartbeat; + + const userDetails: IUserDetails = { + custom: { + "home_server": MatrixClientPeg.get() && MatrixClientPeg.getHomeserverName(), // TODO hash? + "anonymous": this.anonymous, + }, + }; + + const request: Parameters[0] = { + begin_session: 1, + user_details: JSON.stringify(userDetails), + } + + const metrics = this.getMetrics(); + if (metrics) { + request.metrics = JSON.stringify(metrics); + } + + await this.request(request); + } + } + + private reportViewDuration() { + if (this.lastView) { + this.track("[CLY]_view", { + name: this.lastView, + }, null, { + dur: this.trackTime ? CountlyAnalytics.getTimestamp() - this.lastViewTime : this.lastViewStoredDuration, + }); + this.lastView = null; + } + } + + private endSession = () => { + if (this.sessionStarted) { + window.removeEventListener("resize", this.reportOrientation) + + this.reportViewDuration(); + this.request({ + end_session: 1, + session_duration: CountlyAnalytics.getTimestamp() - this.lastBeat, + }); + } + this.sessionStarted = false; + }; + + private onVisibilityChange = () => { + if (document.hidden) { + this.stopTime(); + } else { + this.startTime(); + } + }; + + private onUserActivity = () => { + if (this.inactivityCounter >= INACTIVITY_TIME) { + this.startTime(); + } + this.inactivityCounter = 0; + }; + + private trackSessions() { + this.beginSession(); + this.startTime(); + + window.addEventListener("beforeunload", this.endSession); + window.addEventListener("unload", this.endSession); + window.addEventListener("visibilitychange", this.onVisibilityChange); + window.addEventListener("mousemove", this.onUserActivity); + window.addEventListener("click", this.onUserActivity); + window.addEventListener("keydown", this.onUserActivity); + window.addEventListener("scroll", this.onUserActivity); + + this.activityIntervalId = setInterval(() => { + this.inactivityCounter++; + if (this.inactivityCounter >= INACTIVITY_TIME) { + this.stopTime(); + } + }, 60_000); + } + + public trackBeginInvite(roomId: string) { + this.track("begin_invite", {}, roomId); + } + + public trackSendInvite(startTime: number, roomId: string, qty: number) { + this.track("send_invite", {}, roomId, { + dur: CountlyAnalytics.getTimestamp() - startTime, + sum: qty, + }); + } + + public async trackRoomCreate(startTime: number, roomId: string) { + if (this.disabled) return; + + let endTime = CountlyAnalytics.getTimestamp(); + const cli = MatrixClientPeg.get(); + if (!cli.getRoom(roomId)) { + await new Promise(resolve => { + const handler = (room) => { + if (room.roomId === roomId) { + cli.off("Room", handler); + resolve(); + } + }; + cli.on("Room", handler); + }); + endTime = CountlyAnalytics.getTimestamp(); + } + + this.track("create_room", {}, roomId, { + dur: endTime - startTime, + }); + } + + public trackRoomJoin(startTime: number, roomId: string, type: IJoinRoomEvent["segmentation"]["type"]) { + this.track("join_room", { type }, roomId, { + dur: CountlyAnalytics.getTimestamp() - startTime, + }); + } + + public async trackSendMessage( + startTime: number, + // eslint-disable-next-line camelcase + sendPromise: Promise<{event_id: string}>, + roomId: string, + isEdit: boolean, + isReply: boolean, + content: {format?: string, msgtype: string}, + ) { + if (this.disabled) return; + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(roomId); + + const eventId = (await sendPromise).event_id; + let endTime = CountlyAnalytics.getTimestamp(); + + if (!room.findEventById(eventId)) { + await new Promise(resolve => { + const handler = (ev) => { + if (ev.getId() === eventId) { + room.off("Room.localEchoUpdated", handler); + resolve(); + } + }; + + room.on("Room.localEchoUpdated", handler); + }); + endTime = CountlyAnalytics.getTimestamp(); + } + + this.track("send_message", { + is_edit: isEdit, + is_reply: isReply, + msgtype: content.msgtype, + format: content.format, + }, roomId, { + dur: endTime - startTime, + }); + } + + public trackStartCall(roomId: string, isVideo = false, isJitsi = false) { + this.track("start_call", { + is_video: isVideo, + is_jitsi: isJitsi, + }, roomId); + } + + public trackJoinCall(roomId: string, isVideo = false, isJitsi = false) { + this.track("join_call", { + is_video: isVideo, + is_jitsi: isJitsi, + }, roomId); + } + + public trackRoomDirectoryBegin() { + this.track("room_directory"); + } + + public trackRoomDirectory(startTime: number) { + this.track("room_directory_done", {}, null, { + dur: CountlyAnalytics.getTimestamp() - startTime, + }); + } + + public trackRoomDirectorySearch(numResults: number, query: string) { + this.track("room_directory_search", { + query_length: query.length, + query_num_words: query.split(" ").length, + }, null, { + sum: numResults, + }); + } + + public async track( + key: E["key"], + segments?: Omit, + roomId?: string, + args?: Partial>, + anonymous = false, + ) { + if (this.disabled && !anonymous) return; + + let segmentation = segments || {}; + + if (roomId) { + segmentation = { + room_id: await hashHex(roomId), + ...getRoomStats(roomId), + ...segments, + }; + } + + this.queue({ + key, + count: 1, + segmentation, + ...args, + }); + + // if this event can be sent anonymously and we are disabled then dispatch it right away + if (this.disabled && anonymous) { + await this.request({ device_id: randomString(64) }); + } + } +} + +// expose on window for easy access from the console +window.mxCountlyAnalytics = CountlyAnalytics; diff --git a/src/DateUtils.js b/src/DateUtils.ts similarity index 85% rename from src/DateUtils.js rename to src/DateUtils.ts index 108697238c..9b1edf0775 100644 --- a/src/DateUtils.js +++ b/src/DateUtils.ts @@ -17,7 +17,7 @@ limitations under the License. import { _t } from './languageHandler'; -function getDaysArray() { +function getDaysArray(): string[] { return [ _t('Sun'), _t('Mon'), @@ -29,7 +29,7 @@ function getDaysArray() { ]; } -function getMonthsArray() { +function getMonthsArray(): string[] { return [ _t('Jan'), _t('Feb'), @@ -46,11 +46,11 @@ function getMonthsArray() { ]; } -function pad(n) { +function pad(n: number): string { return (n < 10 ? '0' : '') + n; } -function twelveHourTime(date, showSeconds=false) { +function twelveHourTime(date: Date, showSeconds = false): string { let hours = date.getHours() % 12; const minutes = pad(date.getMinutes()); const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM'); @@ -62,7 +62,7 @@ function twelveHourTime(date, showSeconds=false) { return `${hours}:${minutes}${ampm}`; } -export function formatDate(date, showTwelveHour=false) { +export function formatDate(date: Date, showTwelveHour = false): string { const now = new Date(); const days = getDaysArray(); const months = getMonthsArray(); @@ -86,7 +86,7 @@ export function formatDate(date, showTwelveHour=false) { return formatFullDate(date, showTwelveHour); } -export function formatFullDateNoTime(date) { +export function formatFullDateNoTime(date: Date): string { const days = getDaysArray(); const months = getMonthsArray(); return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s', { @@ -97,7 +97,7 @@ export function formatFullDateNoTime(date) { }); } -export function formatFullDate(date, showTwelveHour=false) { +export function formatFullDate(date: Date, showTwelveHour = false): string { const days = getDaysArray(); const months = getMonthsArray(); return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', { @@ -109,14 +109,14 @@ export function formatFullDate(date, showTwelveHour=false) { }); } -export function formatFullTime(date, showTwelveHour=false) { +export function formatFullTime(date: Date, showTwelveHour = false): string { if (showTwelveHour) { return twelveHourTime(date, true); } return pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds()); } -export function formatTime(date, showTwelveHour=false) { +export function formatTime(date: Date, showTwelveHour = false): string { if (showTwelveHour) { return twelveHourTime(date); } @@ -124,7 +124,7 @@ export function formatTime(date, showTwelveHour=false) { } const MILLIS_IN_DAY = 86400000; -export function wantsDateSeparator(prevEventDate, nextEventDate) { +export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean { if (!nextEventDate || !prevEventDate) { return false; } diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index c503247bf7..07bfd4858a 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -29,6 +29,7 @@ import EMOJIBASE_REGEX from 'emojibase-regex'; import url from 'url'; 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"; @@ -171,7 +172,10 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag // because transformTags is used _before_ we filter by allowedSchemesByTag and // we don't want to allow images with `https?` `src`s. - if (!attribs.src || !attribs.src.startsWith('mxc://')) { + // We also drop inline images (as if they were not present at all) when the "show + // images" preference is disabled. Future work might expose some UI to reveal them + // like standalone image events have. + if (!attribs.src || !attribs.src.startsWith('mxc://') || !SettingsStore.getValue("showImages")) { return { tagName, attribs: {}}; } attribs.src = MatrixClientPeg.get().mxcUrlToHttp( diff --git a/src/ImageUtils.js b/src/ImageUtils.ts similarity index 90% rename from src/ImageUtils.js rename to src/ImageUtils.ts index c0f7b94b81..9bfab37193 100644 --- a/src/ImageUtils.js +++ b/src/ImageUtils.ts @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +Copyright 2015, 2016, 2020 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. @@ -14,8 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - /** * Returns the actual height that an image of dimensions (fullWidth, fullHeight) * will occupy if resized to fit inside a thumbnail bounding box of size @@ -30,11 +28,11 @@ limitations under the License. * consume in the timeline, when performing scroll offset calcuations * (e.g. scroll locking) */ -export function thumbHeight(fullWidth, fullHeight, thumbWidth, thumbHeight) { +export function thumbHeight(fullWidth: number, fullHeight: number, thumbWidth: number, thumbHeight: number) { if (!fullWidth || !fullHeight) { // Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even // log this because it's spammy - return undefined; + return null; } if (fullWidth < thumbWidth && fullHeight < thumbHeight) { // no scaling needs to be applied diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index f2cd1bce9e..7469624f5c 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -23,6 +23,7 @@ import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { MatrixClient } from "matrix-js-sdk/src/client"; import {IMatrixClientCreds, MatrixClientPeg} from './MatrixClientPeg'; +import SecurityCustomisations from "./customisations/Security"; import EventIndexPeg from './indexing/EventIndexPeg'; import createMatrixClient from './utils/createMatrixClient'; import Analytics from './Analytics'; @@ -46,6 +47,7 @@ import DeviceListener from "./DeviceListener"; 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"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -567,6 +569,8 @@ function persistCredentialsToLocalStorage(credentials: IMatrixClientCreds): void localStorage.setItem("mx_device_id", credentials.deviceId); } + SecurityCustomisations.persistCredentials?.(credentials); + console.log(`Session persisted for ${credentials.userId}`); } @@ -577,6 +581,10 @@ let _isLoggingOut = false; */ export function logout(): void { if (!MatrixClientPeg.get()) return; + if (!CountlyAnalytics.instance.disabled) { + // user has logged out, fall back to anonymous + CountlyAnalytics.instance.enable(/* anonymous = */ true); + } if (MatrixClientPeg.get().isGuest()) { // logout doesn't work for guest sessions diff --git a/src/Login.ts b/src/Login.ts index 38d78feab6..ae4aa226ed 100644 --- a/src/Login.ts +++ b/src/Login.ts @@ -22,6 +22,7 @@ limitations under the License. import Matrix from "matrix-js-sdk"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { IMatrixClientCreds } from "./MatrixClientPeg"; +import SecurityCustomisations from "./customisations/Security"; interface ILoginOptions { defaultDeviceDisplayName?: string; @@ -222,11 +223,15 @@ export async function sendLoginRequest( } } - return { + const creds: IMatrixClientCreds = { homeserverUrl: hsUrl, identityServerUrl: isUrl, userId: data.user_id, deviceId: data.device_id, accessToken: data.access_token, }; + + SecurityCustomisations.examineLoginResponse?.(data, creds); + + return creds; } diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 5bb10dfa89..bb4be663b6 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -34,6 +34,7 @@ import * as StorageManager from './utils/StorageManager'; import IdentityAuthClient from './IdentityAuthClient'; import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from './SecurityManager'; import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode"; +import SecurityCustomisations from "./customisations/Security"; export interface IMatrixClientCreds { homeserverUrl: string; @@ -100,6 +101,12 @@ export interface IMatrixClientPeg { */ currentUserIsJustRegistered(): boolean; + /** + * If the current user has been registered by this device then this + * returns a boolean of whether it was within the last N hours given. + */ + userRegisteredWithinLastHours(hours: number): boolean; + /** * Replace this MatrixClientPeg's client with a client instance that has * homeserver / identity server URLs and active credentials @@ -150,6 +157,9 @@ class _MatrixClientPeg implements IMatrixClientPeg { public setJustRegisteredUserId(uid: string): void { this.justRegisteredUserId = uid; + if (uid) { + window.localStorage.setItem("mx_registration_time", String(new Date().getTime())); + } } public currentUserIsJustRegistered(): boolean { @@ -159,6 +169,15 @@ class _MatrixClientPeg implements IMatrixClientPeg { ); } + public userRegisteredWithinLastHours(hours: number): boolean { + try { + const date = new Date(window.localStorage.getItem("mx_registration_time")); + return ((new Date().getTime() - date.getTime()) / 36e5) <= hours; + } catch (e) { + return false; + } + } + public replaceUsingCreds(creds: IMatrixClientCreds): void { this.currentClientCreds = creds; this.createClient(creds); @@ -273,7 +292,10 @@ class _MatrixClientPeg implements IMatrixClientPeg { // These are always installed regardless of the labs flag so that // cross-signing features can toggle on without reloading and also be // accessed immediately after login. - Object.assign(opts.cryptoCallbacks, crossSigningCallbacks); + const customisedCallbacks = { + getDehydrationKey: SecurityCustomisations.getDehydrationKey, + }; + Object.assign(opts.cryptoCallbacks, crossSigningCallbacks, customisedCallbacks); this.matrixClient = createMatrixClient(opts); diff --git a/src/Modal.tsx b/src/Modal.tsx index b6074e4807..ab582b9b22 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -28,7 +28,7 @@ import AsyncWrapper from './AsyncWrapper'; const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer"; -interface IModal { +export interface IModal { elem: React.ReactNode; className?: string; beforeClosePromise?: Promise; @@ -38,7 +38,7 @@ interface IModal { close(...args: T): void; } -interface IHandle { +export interface IHandle { finished: Promise; close(...args: T): void; } diff --git a/src/Notifier.ts b/src/Notifier.ts index 2643de1abc..1899896f9b 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -218,7 +218,7 @@ export const Notifier = { // calculated value. It is determined based upon whether or not the master rule is enabled // and other flags. Setting it here would cause a circular reference. - Analytics.trackEvent('Notifier', 'Set Enabled', enable); + Analytics.trackEvent('Notifier', 'Set Enabled', String(enable)); // make sure that we persist the current setting audio_enabled setting // before changing anything @@ -287,7 +287,7 @@ export const Notifier = { setPromptHidden: function(hidden: boolean, persistent = true) { this.toolbarHidden = hidden; - Analytics.trackEvent('Notifier', 'Set Toolbar Hidden', hidden); + Analytics.trackEvent('Notifier', 'Set Toolbar Hidden', String(hidden)); hideNotificationsToast(); diff --git a/src/Presence.js b/src/Presence.ts similarity index 65% rename from src/Presence.js rename to src/Presence.ts index 42bca35f96..660bb0ac94 100644 --- a/src/Presence.js +++ b/src/Presence.ts @@ -19,30 +19,34 @@ limitations under the License. import {MatrixClientPeg} from "./MatrixClientPeg"; import dis from "./dispatcher/dispatcher"; import Timer from './utils/Timer'; +import {ActionPayload} from "./dispatcher/payloads"; - // Time in ms after that a user is considered as unavailable/away +// Time in ms after that a user is considered as unavailable/away const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins -const PRESENCE_STATES = ["online", "offline", "unavailable"]; + +enum State { + Online = "online", + Offline = "offline", + Unavailable = "unavailable", +} class Presence { - constructor() { - this._activitySignal = null; - this._unavailableTimer = null; - this._onAction = this._onAction.bind(this); - this._dispatcherRef = null; - } + private unavailableTimer: Timer = null; + private dispatcherRef: string = null; + private state: State = null; + /** * Start listening the user activity to evaluate his presence state. * Any state change will be sent to the homeserver. */ - async start() { - this._unavailableTimer = new Timer(UNAVAILABLE_TIME_MS); + public async start() { + this.unavailableTimer = new Timer(UNAVAILABLE_TIME_MS); // the user_activity_start action starts the timer - this._dispatcherRef = dis.register(this._onAction); - while (this._unavailableTimer) { + this.dispatcherRef = dis.register(this.onAction); + while (this.unavailableTimer) { try { - await this._unavailableTimer.finished(); - this.setState("unavailable"); + await this.unavailableTimer.finished(); + this.setState(State.Unavailable); } catch (e) { /* aborted, stop got called */ } } } @@ -50,14 +54,14 @@ class Presence { /** * Stop tracking user activity */ - stop() { - if (this._dispatcherRef) { - dis.unregister(this._dispatcherRef); - this._dispatcherRef = null; + public stop() { + if (this.dispatcherRef) { + dis.unregister(this.dispatcherRef); + this.dispatcherRef = null; } - if (this._unavailableTimer) { - this._unavailableTimer.abort(); - this._unavailableTimer = null; + if (this.unavailableTimer) { + this.unavailableTimer.abort(); + this.unavailableTimer = null; } } @@ -65,14 +69,14 @@ class Presence { * Get the current presence state. * @returns {string} the presence state (see PRESENCE enum) */ - getState() { + public getState() { return this.state; } - _onAction(payload) { + private onAction = (payload: ActionPayload) => { if (payload.action === 'user_activity') { - this.setState("online"); - this._unavailableTimer.restart(); + this.setState(State.Online); + this.unavailableTimer.restart(); } } @@ -81,13 +85,11 @@ class Presence { * If the state has changed, the homeserver will be notified. * @param {string} newState the new presence state (see PRESENCE enum) */ - async setState(newState) { + private async setState(newState: State) { if (newState === this.state) { return; } - if (PRESENCE_STATES.indexOf(newState) === -1) { - throw new Error("Bad presence state: " + newState); - } + const oldState = this.state; this.state = newState; diff --git a/src/Roles.js b/src/Roles.ts similarity index 87% rename from src/Roles.js rename to src/Roles.ts index 7cc3c880d7..b4be97fdce 100644 --- a/src/Roles.js +++ b/src/Roles.ts @@ -13,9 +13,10 @@ 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 { _t } from './languageHandler'; -export function levelRoleMap(usersDefault) { +export function levelRoleMap(usersDefault: number) { return { undefined: _t('Default'), 0: _t('Restricted'), @@ -25,7 +26,7 @@ export function levelRoleMap(usersDefault) { }; } -export function textualPowerLevel(level, usersDefault) { +export function textualPowerLevel(level: number, usersDefault: number): string { const LEVEL_ROLE_MAP = levelRoleMap(usersDefault); if (LEVEL_ROLE_MAP[level]) { return LEVEL_ROLE_MAP[level]; diff --git a/src/RoomInvite.js b/src/RoomInvite.js index 7eb7f5dbb2..06d3fb04e8 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -40,11 +40,11 @@ export function inviteMultipleToRoom(roomId, addrs) { return inviter.invite(addrs).then(states => Promise.resolve({states, inviter})); } -export function showStartChatInviteDialog() { +export function showStartChatInviteDialog(initialText) { // This dialog handles the room creation internally - we don't need to worry about it. const InviteDialog = sdk.getComponent("dialogs.InviteDialog"); Modal.createTrackedDialog( - 'Start DM', '', InviteDialog, {kind: KIND_DM}, + 'Start DM', '', InviteDialog, {kind: KIND_DM, initialText}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, ); } diff --git a/src/Rooms.js b/src/Rooms.js index 3da2b9bc14..955498faaa 100644 --- a/src/Rooms.js +++ b/src/Rooms.js @@ -21,6 +21,9 @@ import {MatrixClientPeg} from './MatrixClientPeg'; * if any. This could be the canonical alias if one exists, otherwise * an alias selected arbitrarily but deterministically from the list * of aliases. Otherwise return null; + * + * @param {Object} room The room object + * @returns {string} A display alias for the given room */ export function getDisplayAliasForRoom(room) { return room.getCanonicalAlias() || room.getAltAliases()[0]; diff --git a/src/Searching.js b/src/Searching.js index b1507e6a49..f65b8920b3 100644 --- a/src/Searching.js +++ b/src/Searching.js @@ -360,7 +360,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven let oldestEventFrom = previousSearchResult.oldestEventFrom; response.highlights = previousSearchResult.highlights; - if (localEvents && serverEvents) { + if (localEvents && serverEvents && serverEvents.results) { // This is a first search call, combine the events from the server and // the local index. Note where our oldest event came from, we shall // fetch the next batch of events from the other source. @@ -379,7 +379,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven oldestEventFrom = "local"; } combineEventSources(previousSearchResult, response, localEvents.results, cachedEvents); - } else if (serverEvents) { + } else if (serverEvents && serverEvents.results) { // This is a pagination call fetching more events from the server, // meaning that our oldest event was in the local index. // Change the source of the oldest event if our server event is older @@ -454,7 +454,7 @@ function combineResponses(previousSearchResult, localEvents = undefined, serverE return response; } -function restoreEncryptionInfo(searchResultSlice) { +function restoreEncryptionInfo(searchResultSlice = []) { for (let i = 0; i < searchResultSlice.length; i++) { const timeline = searchResultSlice[i].context.getTimeline(); @@ -517,7 +517,7 @@ async function combinedPagination(searchResult) { }, }; - const oldResultCount = searchResult.results.length; + const oldResultCount = searchResult.results ? searchResult.results.length : 0; // Let the client process the combined result. const result = client._processRoomEventsSearch(searchResult, response); diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index 4d277692df..220320470a 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -22,11 +22,12 @@ import {MatrixClientPeg} from './MatrixClientPeg'; import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase'; import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey'; import { _t } from './languageHandler'; -import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib"; +import { encodeBase64 } from "matrix-js-sdk/src/crypto/olmlib"; import { isSecureBackupRequired } from './utils/WellKnownUtils'; import AccessSecretStorageDialog from './components/views/dialogs/security/AccessSecretStorageDialog'; import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog'; import SettingsStore from "./settings/SettingsStore"; +import SecurityCustomisations from "./customisations/Security"; // This stores the secret storage private keys in memory for the JS SDK. This is // only meant to act as a cache to avoid prompting the user multiple times @@ -115,6 +116,13 @@ async function getSecretStorageKey( } } + const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.(); + if (keyFromCustomisations) { + console.log("Using key from security customisations (secret storage)") + cacheSecretStorageKey(keyId, keyInfo, keyFromCustomisations); + return [keyId, keyFromCustomisations]; + } + if (nonInteractive) { throw new Error("Could not unlock non-interactively"); } @@ -158,6 +166,12 @@ export async function getDehydrationKey( keyInfo: ISecretStorageKeyInfo, checkFunc: (Uint8Array) => void, ): Promise { + const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.(); + if (keyFromCustomisations) { + console.log("Using key from security customisations (dehydration)") + return keyFromCustomisations; + } + const inputToKey = makeInputToKey(keyInfo); const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "", AccessSecretStorageDialog, @@ -352,14 +366,19 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f } console.log("Setting dehydration key"); await cli.setDehydrationKey(secretStorageKeys[keyId], dehydrationKeyInfo, "Backup device"); + } else if (!keyId) { + console.warn("Not setting dehydration key: no SSSS key found"); } else { - console.log("Not setting dehydration key: no SSSS key found"); + console.log("Not setting dehydration key: feature disabled"); } } // `return await` needed here to ensure `finally` block runs after the // inner operation completes. return await func(); + } catch (e) { + SecurityCustomisations.catchAccessSecretStorageError?.(e); + console.error(e); } finally { // Clear secret storage key cache now that work is complete secretStorageBeingAccessed = false; diff --git a/src/Skinner.js b/src/Skinner.js index 87c5a7be7f..d17bc1782a 100644 --- a/src/Skinner.js +++ b/src/Skinner.js @@ -50,8 +50,8 @@ class Skinner { return null; } - // components have to be functions. - const validType = typeof comp === 'function'; + // components have to be functions or forwardRef objects with a render function. + const validType = typeof comp === 'function' || comp.render; if (!validType) { throw new Error(`Not a valid component: ${name} (type = ${typeof(comp)}).`); } diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index a6481d5b95..9af5ebcbfb 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -46,6 +46,7 @@ import { EffectiveMembership, getEffectiveMembership, leaveRoomBehaviour } from import SdkConfig from "./SdkConfig"; import SettingsStore from "./settings/SettingsStore"; import {UIFeature} from "./settings/UIFeature"; +import CallHandler from "./CallHandler"; // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 interface HTMLInputEvent extends Event { @@ -517,6 +518,7 @@ export const Commands = [ action: 'view_room', room_alias: roomAlias, auto_join: true, + _type: "slash_command", // instrumentation }); return success(); } else if (params[0][0] === '!') { @@ -531,6 +533,7 @@ export const Commands = [ }, via_servers: viaServers, // for the rejoin button auto_join: true, + _type: "slash_command", // instrumentation }); return success(); } else if (isPermalink) { @@ -555,6 +558,7 @@ export const Commands = [ const dispatch = { action: 'view_room', auto_join: true, + _type: "slash_command", // instrumentation }; if (entity[0] === '!') dispatch["room_id"] = entity; @@ -998,14 +1002,29 @@ export const Commands = [ description: _td("Opens chat with the given user"), args: "", runFn: function(roomId, userId) { - if (!userId || !userId.startsWith("@") || !userId.includes(":")) { + // easter-egg for now: look up phone numbers through the thirdparty API + // (very dumb phone number detection...) + const isPhoneNumber = userId && /^\+?[0123456789]+$/.test(userId); + if (!userId || (!userId.startsWith("@") || !userId.includes(":")) && !isPhoneNumber) { return reject(this.getUsage()); } return success((async () => { + if (isPhoneNumber) { + const results = await MatrixClientPeg.get().getThirdpartyUser('im.vector.protocol.pstn', { + 'm.id.phone': userId, + }); + if (!results || results.length === 0 || !results[0].userid) { + throw new Error("Unable to find Matrix ID for phone number"); + } + userId = results[0].userid; + } + + const roomId = await ensureDMExists(MatrixClientPeg.get(), userId); + dis.dispatch({ action: 'view_room', - room_id: await ensureDMExists(MatrixClientPeg.get(), userId), + room_id: roomId, }); })()); }, @@ -1039,6 +1058,32 @@ export const Commands = [ }, category: CommandCategories.actions, }), + new Command({ + command: "holdcall", + description: _td("Places the call in the current room on hold"), + category: CommandCategories.other, + runFn: function(roomId, args) { + const call = CallHandler.sharedInstance().getCallForRoom(roomId); + if (!call) { + return reject("No active call in this room"); + } + call.setRemoteOnHold(true); + return success(); + }, + }), + new Command({ + command: "unholdcall", + description: _td("Takes the call in the current room off hold"), + category: CommandCategories.other, + runFn: function(roomId, args) { + const call = CallHandler.sharedInstance().getCallForRoom(roomId); + if (!call) { + return reject("No active call in this room"); + } + call.setRemoteOnHold(false); + return success(); + }, + }), // Command definitions for autocompletion ONLY: // /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes diff --git a/src/TextForEvent.js b/src/TextForEvent.js index 34d40bf1fd..d86d88a697 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -198,59 +198,30 @@ function textForRelatedGroupsEvent(ev) { function textForServerACLEvent(ev) { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const prevContent = ev.getPrevContent(); - const changes = []; const current = ev.getContent(); const prev = { deny: Array.isArray(prevContent.deny) ? prevContent.deny : [], allow: Array.isArray(prevContent.allow) ? prevContent.allow : [], allow_ip_literals: !(prevContent.allow_ip_literals === false), }; + let text = ""; if (prev.deny.length === 0 && prev.allow.length === 0) { - text = `${senderDisplayName} set server ACLs for this room: `; + text = _t("%(senderDisplayName)s set the server ACLs for this room.", {senderDisplayName}); } else { - text = `${senderDisplayName} changed the server ACLs for this room: `; + text = _t("%(senderDisplayName)s changed the server ACLs for this room.", {senderDisplayName}); } if (!Array.isArray(current.allow)) { current.allow = []; } - /* If we know for sure everyone is banned, don't bother showing the diff view */ + + // If we know for sure everyone is banned, mark the room as obliterated if (current.allow.length === 0) { - return text + "🎉 All servers are banned from participating! This room can no longer be used."; + return text + " " + _t("🎉 All servers are banned from participating! This room can no longer be used."); } - if (!Array.isArray(current.deny)) { - current.deny = []; - } - - const bannedServers = current.deny.filter((srv) => typeof(srv) === 'string' && !prev.deny.includes(srv)); - const unbannedServers = prev.deny.filter((srv) => typeof(srv) === 'string' && !current.deny.includes(srv)); - const allowedServers = current.allow.filter((srv) => typeof(srv) === 'string' && !prev.allow.includes(srv)); - const unallowedServers = prev.allow.filter((srv) => typeof(srv) === 'string' && !current.allow.includes(srv)); - - if (bannedServers.length > 0) { - changes.push(`Servers matching ${bannedServers.join(", ")} are now banned.`); - } - - if (unbannedServers.length > 0) { - changes.push(`Servers matching ${unbannedServers.join(", ")} were removed from the ban list.`); - } - - if (allowedServers.length > 0) { - changes.push(`Servers matching ${allowedServers.join(", ")} are now allowed.`); - } - - if (unallowedServers.length > 0) { - changes.push(`Servers matching ${unallowedServers.join(", ")} were removed from the allowed list.`); - } - - if (prev.allow_ip_literals !== current.allow_ip_literals) { - const allowban = current.allow_ip_literals ? "allowed" : "banned"; - changes.push(`Participating from a server using an IP literal hostname is now ${allowban}.`); - } - - return text + changes.join(" "); + return text; } function textForMessageEvent(ev) { @@ -329,14 +300,27 @@ function textForCallHangupEvent(event) { reason = _t('(not supported by this browser)'); } else if (eventContent.reason) { if (eventContent.reason === "ice_failed") { + // We couldn't establish a connection at all reason = _t('(could not connect media)'); + } else if (eventContent.reason === "ice_timeout") { + // We established a connection but it died + reason = _t('(connection failed)'); + } else if (eventContent.reason === "user_media_failed") { + // The other side couldn't open capture devices + reason = _t("(their device couldn't start the camera / microphone)"); + } else if (eventContent.reason === "unknown_error") { + // An error code the other side doesn't have a way to express + // (as opposed to an error code they gave but we don't know about, + // in which case we show the error code) + reason = _t("(an error occurred)"); } else if (eventContent.reason === "invite_timeout") { reason = _t('(no answer)'); - } else if (eventContent.reason === "user hangup") { + } else if (eventContent.reason === "user hangup" || eventContent.reason === "user_hangup") { // workaround for https://github.com/vector-im/element-web/issues/5178 // it seems Android randomly sets a reason of "user hangup" which is // interpreted as an error code :( // https://github.com/vector-im/riot-android/issues/2623 + // Also the correct hangup code as of VoIP v1 (with underscore) reason = ''; } else { reason = _t('(unknown failure: %(reason)s)', {reason: eventContent.reason}); @@ -345,6 +329,11 @@ function textForCallHangupEvent(event) { return _t('%(senderName)s ended the call.', {senderName}) + ' ' + reason; } +function textForCallRejectEvent(event) { + const senderName = event.sender ? event.sender.name : _t('Someone'); + return _t('%(senderName)s declined the call.', {senderName}); +} + function textForCallInviteEvent(event) { const senderName = event.sender ? event.sender.name : _t('Someone'); // FIXME: Find a better way to determine this from the event? @@ -574,6 +563,7 @@ const handlers = { 'm.call.invite': textForCallInviteEvent, 'm.call.answer': textForCallAnswerEvent, 'm.call.hangup': textForCallHangupEvent, + 'm.call.reject': textForCallRejectEvent, }; const stateHandlers = { diff --git a/src/Unread.js b/src/Unread.js index cf131cac00..ddf225ac64 100644 --- a/src/Unread.js +++ b/src/Unread.js @@ -16,12 +16,14 @@ limitations under the License. import {MatrixClientPeg} from "./MatrixClientPeg"; import shouldHideEvent from './shouldHideEvent'; -import * as sdk from "./index"; import {haveTileForEvent} from "./components/views/rooms/EventTile"; /** * Returns true iff this event arriving in a room should affect the room's * count of unread messages + * + * @param {Object} ev The event + * @returns {boolean} True if the given event should affect the unread message count */ export function eventTriggersUnreadCount(ev) { if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) { diff --git a/src/UserActivity.js b/src/UserActivity.ts similarity index 61% rename from src/UserActivity.js rename to src/UserActivity.ts index 0174aebaf5..606075ec7c 100644 --- a/src/UserActivity.js +++ b/src/UserActivity.ts @@ -38,26 +38,23 @@ const RECENTLY_ACTIVE_THRESHOLD_MS = 2 * 60 * 1000; * see doc on the userActive* functions for what these mean. */ export default class UserActivity { - constructor(windowObj, documentObj) { - this._window = windowObj; - this._document = documentObj; + private readonly activeNowTimeout: Timer; + private readonly activeRecentlyTimeout: Timer; + private attachedActiveNowTimers: Timer[] = []; + private attachedActiveRecentlyTimers: Timer[] = []; + private lastScreenX = 0; + private lastScreenY = 0; - this._attachedActiveNowTimers = []; - this._attachedActiveRecentlyTimers = []; - this._activeNowTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS); - this._activeRecentlyTimeout = new Timer(RECENTLY_ACTIVE_THRESHOLD_MS); - this._onUserActivity = this._onUserActivity.bind(this); - this._onWindowBlurred = this._onWindowBlurred.bind(this); - this._onPageVisibilityChanged = this._onPageVisibilityChanged.bind(this); - this.lastScreenX = 0; - this.lastScreenY = 0; + constructor(private readonly window: Window, private readonly document: Document) { + this.activeNowTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS); + this.activeRecentlyTimeout = new Timer(RECENTLY_ACTIVE_THRESHOLD_MS); } static sharedInstance() { - if (global.mxUserActivity === undefined) { - global.mxUserActivity = new UserActivity(window, document); + if (window.mxUserActivity === undefined) { + window.mxUserActivity = new UserActivity(window, document); } - return global.mxUserActivity; + return window.mxUserActivity; } /** @@ -69,8 +66,8 @@ export default class UserActivity { * later on when the user does become active. * @param {Timer} timer the timer to use */ - timeWhileActiveNow(timer) { - this._timeWhile(timer, this._attachedActiveNowTimers); + public timeWhileActiveNow(timer: Timer) { + this.timeWhile(timer, this.attachedActiveNowTimers); if (this.userActiveNow()) { timer.start(); } @@ -85,14 +82,14 @@ export default class UserActivity { * later on when the user does become active. * @param {Timer} timer the timer to use */ - timeWhileActiveRecently(timer) { - this._timeWhile(timer, this._attachedActiveRecentlyTimers); + public timeWhileActiveRecently(timer: Timer) { + this.timeWhile(timer, this.attachedActiveRecentlyTimers); if (this.userActiveRecently()) { timer.start(); } } - _timeWhile(timer, attachedTimers) { + private timeWhile(timer: Timer, attachedTimers: Timer[]) { // important this happens first const index = attachedTimers.indexOf(timer); if (index === -1) { @@ -112,36 +109,36 @@ export default class UserActivity { /** * Start listening to user activity */ - start() { - this._document.addEventListener('mousedown', this._onUserActivity); - this._document.addEventListener('mousemove', this._onUserActivity); - this._document.addEventListener('keydown', this._onUserActivity); - this._document.addEventListener("visibilitychange", this._onPageVisibilityChanged); - this._window.addEventListener("blur", this._onWindowBlurred); - this._window.addEventListener("focus", this._onUserActivity); + public start() { + this.document.addEventListener('mousedown', this.onUserActivity); + this.document.addEventListener('mousemove', this.onUserActivity); + this.document.addEventListener('keydown', this.onUserActivity); + this.document.addEventListener("visibilitychange", this.onPageVisibilityChanged); + this.window.addEventListener("blur", this.onWindowBlurred); + this.window.addEventListener("focus", this.onUserActivity); // can't use document.scroll here because that's only the document // itself being scrolled. Need to use addEventListener's useCapture. // also this needs to be the wheel event, not scroll, as scroll is // fired when the view scrolls down for a new message. - this._window.addEventListener('wheel', this._onUserActivity, { - passive: true, capture: true, + this.window.addEventListener('wheel', this.onUserActivity, { + passive: true, + capture: true, }); } /** * Stop tracking user activity */ - stop() { - this._document.removeEventListener('mousedown', this._onUserActivity); - this._document.removeEventListener('mousemove', this._onUserActivity); - this._document.removeEventListener('keydown', this._onUserActivity); - this._window.removeEventListener('wheel', this._onUserActivity, { - passive: true, capture: true, + public stop() { + this.document.removeEventListener('mousedown', this.onUserActivity); + this.document.removeEventListener('mousemove', this.onUserActivity); + this.document.removeEventListener('keydown', this.onUserActivity); + this.window.removeEventListener('wheel', this.onUserActivity, { + capture: true, }); - - this._document.removeEventListener("visibilitychange", this._onPageVisibilityChanged); - this._window.removeEventListener("blur", this._onWindowBlurred); - this._window.removeEventListener("focus", this._onUserActivity); + this.document.removeEventListener("visibilitychange", this.onPageVisibilityChanged); + this.window.removeEventListener("blur", this.onWindowBlurred); + this.window.removeEventListener("focus", this.onUserActivity); } /** @@ -151,8 +148,8 @@ export default class UserActivity { * user's attention at any given moment. * @returns {boolean} true if user is currently 'active' */ - userActiveNow() { - return this._activeNowTimeout.isRunning(); + public userActiveNow() { + return this.activeNowTimeout.isRunning(); } /** @@ -163,27 +160,27 @@ export default class UserActivity { * (or they may have gone to make tea and left the window focused). * @returns {boolean} true if user has been active recently */ - userActiveRecently() { - return this._activeRecentlyTimeout.isRunning(); + public userActiveRecently() { + return this.activeRecentlyTimeout.isRunning(); } - _onPageVisibilityChanged(e) { - if (this._document.visibilityState === "hidden") { - this._activeNowTimeout.abort(); - this._activeRecentlyTimeout.abort(); + private onPageVisibilityChanged = e => { + if (this.document.visibilityState === "hidden") { + this.activeNowTimeout.abort(); + this.activeRecentlyTimeout.abort(); } else { - this._onUserActivity(e); + this.onUserActivity(e); } - } + }; - _onWindowBlurred() { - this._activeNowTimeout.abort(); - this._activeRecentlyTimeout.abort(); - } + private onWindowBlurred = () => { + this.activeNowTimeout.abort(); + this.activeRecentlyTimeout.abort(); + }; - _onUserActivity(event) { + private onUserActivity = (event: MouseEvent) => { // ignore anything if the window isn't focused - if (!this._document.hasFocus()) return; + if (!this.document.hasFocus()) return; if (event.screenX && event.type === "mousemove") { if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) { @@ -195,25 +192,25 @@ export default class UserActivity { } dis.dispatch({action: 'user_activity'}); - if (!this._activeNowTimeout.isRunning()) { - this._activeNowTimeout.start(); + if (!this.activeNowTimeout.isRunning()) { + this.activeNowTimeout.start(); dis.dispatch({action: 'user_activity_start'}); - this._runTimersUntilTimeout(this._attachedActiveNowTimers, this._activeNowTimeout); + UserActivity.runTimersUntilTimeout(this.attachedActiveNowTimers, this.activeNowTimeout); } else { - this._activeNowTimeout.restart(); + this.activeNowTimeout.restart(); } - if (!this._activeRecentlyTimeout.isRunning()) { - this._activeRecentlyTimeout.start(); + if (!this.activeRecentlyTimeout.isRunning()) { + this.activeRecentlyTimeout.start(); - this._runTimersUntilTimeout(this._attachedActiveRecentlyTimers, this._activeRecentlyTimeout); + UserActivity.runTimersUntilTimeout(this.attachedActiveRecentlyTimers, this.activeRecentlyTimeout); } else { - this._activeRecentlyTimeout.restart(); + this.activeRecentlyTimeout.restart(); } - } + }; - async _runTimersUntilTimeout(attachedTimers, timeout) { + private static async runTimersUntilTimeout(attachedTimers: Timer[], timeout: Timer) { attachedTimers.forEach((t) => t.start()); try { await timeout.finished(); diff --git a/src/WhoIsTyping.js b/src/WhoIsTyping.ts similarity index 72% rename from src/WhoIsTyping.js rename to src/WhoIsTyping.ts index d11cddf487..a8ca425ea8 100644 --- a/src/WhoIsTyping.js +++ b/src/WhoIsTyping.ts @@ -14,19 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {Room} from "matrix-js-sdk/src/models/room"; +import {RoomMember} from "matrix-js-sdk/src/models/room-member"; + import {MatrixClientPeg} from "./MatrixClientPeg"; import { _t } from './languageHandler'; -export function usersTypingApartFromMeAndIgnored(room) { - return usersTyping( - room, [MatrixClientPeg.get().credentials.userId].concat(MatrixClientPeg.get().getIgnoredUsers()), - ); +export function usersTypingApartFromMeAndIgnored(room: Room): RoomMember[] { + return usersTyping(room, [MatrixClientPeg.get().getUserId()].concat(MatrixClientPeg.get().getIgnoredUsers())); } -export function usersTypingApartFromMe(room) { - return usersTyping( - room, [MatrixClientPeg.get().credentials.userId], - ); +export function usersTypingApartFromMe(room: Room): RoomMember[] { + return usersTyping(room, [MatrixClientPeg.get().getUserId()]); } /** @@ -34,15 +33,11 @@ export function usersTypingApartFromMe(room) { * to exclude, return a list of user objects who are typing. * @param {Room} room: room object to get users from. * @param {string[]} exclude: list of user mxids to exclude. - * @returns {string[]} list of user objects who are typing. + * @returns {RoomMember[]} list of user objects who are typing. */ -export function usersTyping(room, exclude) { +export function usersTyping(room: Room, exclude: string[] = []): RoomMember[] { const whoIsTyping = []; - if (exclude === undefined) { - exclude = []; - } - const memberKeys = Object.keys(room.currentState.members); for (let i = 0; i < memberKeys.length; ++i) { const userId = memberKeys[i]; @@ -57,20 +52,21 @@ export function usersTyping(room, exclude) { return whoIsTyping; } -export function whoIsTypingString(whoIsTyping, limit) { +export function whoIsTypingString(whoIsTyping: RoomMember[], limit: number): string { let othersCount = 0; if (whoIsTyping.length > limit) { othersCount = whoIsTyping.length - limit + 1; } + if (whoIsTyping.length === 0) { return ''; } else if (whoIsTyping.length === 1) { return _t('%(displayName)s is typing …', {displayName: whoIsTyping[0].name}); } - const names = whoIsTyping.map(function(m) { - return m.name; - }); - if (othersCount>=1) { + + const names = whoIsTyping.map(m => m.name); + + if (othersCount >= 1) { return _t('%(names)s and %(count)s others are typing …', { names: names.slice(0, limit - 1).join(', '), count: othersCount, diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index 434b931296..b49a90d175 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -205,7 +205,7 @@ export const RovingTabIndexProvider: React.FC = ({children, handleHomeEn // onFocus should be called when the index gained focus in any manner // isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}` // ref should be passed to a DOM node which will be used for DOM compareDocumentPosition -export const useRovingTabIndex = (inputRef: Ref): [FocusHandler, boolean, Ref] => { +export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref] => { const context = useContext(RovingTabIndexContext); let ref = useRef(null); diff --git a/src/actions/TagOrderActions.ts b/src/actions/TagOrderActions.ts index c203172874..021cd11b55 100644 --- a/src/actions/TagOrderActions.ts +++ b/src/actions/TagOrderActions.ts @@ -17,14 +17,14 @@ limitations under the License. import Analytics from '../Analytics'; import { asyncAction } from './actionCreators'; -import TagOrderStore from '../stores/TagOrderStore'; +import GroupFilterOrderStore from '../stores/GroupFilterOrderStore'; import { AsyncActionPayload } from "../dispatcher/payloads"; import { MatrixClient } from "matrix-js-sdk/src/client"; export default class TagOrderActions { /** * Creates an action thunk that will do an asynchronous request to - * move a tag in TagOrderStore to destinationIx. + * move a tag in GroupFilterOrderStore to destinationIx. * * @param {MatrixClient} matrixClient the matrix client to set the * account data on. @@ -36,8 +36,8 @@ export default class TagOrderActions { */ public static moveTag(matrixClient: MatrixClient, tag: string, destinationIx: number): AsyncActionPayload { // Only commit tags if the state is ready, i.e. not null - let tags = TagOrderStore.getOrderedTags(); - let removedTags = TagOrderStore.getRemovedTagsAccountData() || []; + let tags = GroupFilterOrderStore.getOrderedTags(); + let removedTags = GroupFilterOrderStore.getRemovedTagsAccountData() || []; if (!tags) { return; } @@ -47,7 +47,7 @@ export default class TagOrderActions { removedTags = removedTags.filter((t) => t !== tag); - const storeId = TagOrderStore.getStoreId(); + const storeId = GroupFilterOrderStore.getStoreId(); return asyncAction('TagOrderActions.moveTag', () => { Analytics.trackEvent('TagOrderActions', 'commitTagOrdering'); @@ -83,8 +83,8 @@ export default class TagOrderActions { */ public static removeTag(matrixClient: MatrixClient, tag: string): AsyncActionPayload { // Don't change tags, just removedTags - const tags = TagOrderStore.getOrderedTags(); - const removedTags = TagOrderStore.getRemovedTagsAccountData() || []; + const tags = GroupFilterOrderStore.getOrderedTags(); + const removedTags = GroupFilterOrderStore.getRemovedTagsAccountData() || []; if (removedTags.includes(tag)) { // Return a thunk that doesn't do anything, we don't even need @@ -94,7 +94,7 @@ export default class TagOrderActions { removedTags.push(tag); - const storeId = TagOrderStore.getStoreId(); + const storeId = GroupFilterOrderStore.getStoreId(); return asyncAction('TagOrderActions.removeTag', () => { Analytics.trackEvent('TagOrderActions', 'removeTag'); diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js index 00aad2a0ce..b1a14062f4 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js @@ -32,6 +32,7 @@ import DialogButtons from "../../../../components/views/elements/DialogButtons"; import InlineSpinner from "../../../../components/views/elements/InlineSpinner"; import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog"; import { getSecureBackupSetupMethods, isSecureBackupRequired } from '../../../../utils/WellKnownUtils'; +import SecurityCustomisations from "../../../../customisations/Security"; const PHASE_LOADING = 0; const PHASE_LOADERROR = 1; @@ -99,7 +100,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this._passphraseField = createRef(); - this._fetchBackupInfo(); + MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange); + if (this.state.accountPassword) { // If we have an account password in memory, let's simplify and // assume it means password auth is also supported for device @@ -110,13 +112,27 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this._queryKeyUploadAuth(); } - MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange); + this._getInitialPhase(); } componentWillUnmount() { MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatusChange); } + _getInitialPhase() { + const keyFromCustomisations = SecurityCustomisations.createSecretStorageKey?.(); + if (keyFromCustomisations) { + console.log("Created key via customisations, jumping to bootstrap step"); + this._recoveryKey = { + privateKey: keyFromCustomisations, + }; + this._bootstrapSecretStorage(); + return; + } + + this._fetchBackupInfo(); + } + async _fetchBackupInfo() { try { const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); @@ -454,6 +470,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { value={CREATE_STORAGE_OPTION_KEY} name="keyPassphrase" checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_KEY} + onChange={this._onKeyPassphraseChange} outlined >
@@ -472,6 +489,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { value={CREATE_STORAGE_OPTION_PASSPHRASE} name="keyPassphrase" checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_PASSPHRASE} + onChange={this._onKeyPassphraseChange} outlined >
@@ -493,7 +511,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { "Safeguard against losing access to encrypted messages & data by " + "backing up encryption keys on your server.", )}

-
+
{optionKey} {optionPassphrase}
diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 884f77aba5..fa0d6682dd 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -416,8 +416,9 @@ export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None return menuOptions; }; -export const useContextMenu = (): [boolean, RefObject, () => void, () => void, (val: boolean) => void] => { - const button = useRef(null); +type ContextMenuTuple = [boolean, RefObject, () => void, () => void, (val: boolean) => void]; +export const useContextMenu = (): ContextMenuTuple => { + const button = useRef(null); const [isOpen, setIsOpen] = useState(false); const open = () => { setIsOpen(true); diff --git a/src/components/structures/TagPanel.js b/src/components/structures/GroupFilterPanel.js similarity index 86% rename from src/components/structures/TagPanel.js rename to src/components/structures/GroupFilterPanel.js index 135b2a1c5c..96aa1ba728 100644 --- a/src/components/structures/TagPanel.js +++ b/src/components/structures/GroupFilterPanel.js @@ -16,7 +16,7 @@ limitations under the License. */ import React from 'react'; -import TagOrderStore from '../../stores/TagOrderStore'; +import GroupFilterOrderStore from '../../stores/GroupFilterOrderStore'; import GroupActions from '../../actions/GroupActions'; @@ -31,7 +31,7 @@ import AutoHideScrollbar from "./AutoHideScrollbar"; import SettingsStore from "../../settings/SettingsStore"; import UserTagTile from "../views/elements/UserTagTile"; -class TagPanel extends React.Component { +class GroupFilterPanel extends React.Component { static contextType = MatrixClientContext; state = { @@ -44,13 +44,13 @@ class TagPanel extends React.Component { this.context.on("Group.myMembership", this._onGroupMyMembership); this.context.on("sync", this._onClientSync); - this._tagOrderStoreToken = TagOrderStore.addListener(() => { + this._groupFilterOrderStoreToken = GroupFilterOrderStore.addListener(() => { if (this.unmounted) { return; } this.setState({ - orderedTags: TagOrderStore.getOrderedTags() || [], - selectedTags: TagOrderStore.getSelectedTags(), + orderedTags: GroupFilterOrderStore.getOrderedTags() || [], + selectedTags: GroupFilterOrderStore.getSelectedTags(), }); }); // This could be done by anything with a matrix client @@ -61,8 +61,8 @@ class TagPanel extends React.Component { this.unmounted = true; this.context.removeListener("Group.myMembership", this._onGroupMyMembership); this.context.removeListener("sync", this._onClientSync); - if (this._tagOrderStoreToken) { - this._tagOrderStoreToken.remove(); + if (this._groupFilterOrderStoreToken) { + this._groupFilterOrderStoreToken.remove(); } } @@ -98,7 +98,7 @@ class TagPanel extends React.Component { return (
-
+
); } @@ -117,8 +117,8 @@ class TagPanel extends React.Component { }); const itemsSelected = this.state.selectedTags.length > 0; - const classes = classNames('mx_TagPanel', { - mx_TagPanel_items_selected: itemsSelected, + const classes = classNames('mx_GroupFilterPanel', { + mx_GroupFilterPanel_items_selected: itemsSelected, }); let createButton = ( @@ -141,7 +141,7 @@ class TagPanel extends React.Component { return
{ (provided, snapshot) => (
{ this.renderGlobalIcon() } @@ -168,4 +168,4 @@ class TagPanel extends React.Component {
; } } -export default TagPanel; +export default GroupFilterPanel; diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 5dadba983a..482b9f6da2 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -620,7 +620,7 @@ export default class GroupView extends React.Component { profileForm: newProfileForm, // Indicate that FlairStore needs to be poked to show this change - // in TagTile (TagPanel), Flair and GroupTile (MyGroups). + // in TagTile (GroupFilterPanel), Flair and GroupTile (MyGroups). avatarChanged: true, }); }).catch((e) => { @@ -649,7 +649,6 @@ export default class GroupView extends React.Component { editing: false, summary: null, }); - dis.dispatch({action: 'panel_disable'}); this._initGroupStore(this.props.groupId); if (this.state.avatarChanged) { @@ -870,10 +869,7 @@ export default class GroupView extends React.Component { { _t('Add rooms to this community') }
) :
; - const roomDetailListClassName = classnames({ - "mx_fadable": true, - "mx_fadable_faded": this.state.editing, - }); + return

@@ -884,9 +880,7 @@ export default class GroupView extends React.Component {

{ this.state.groupRoomsLoading ? : - + }
; } diff --git a/src/components/structures/HomePage.tsx b/src/components/structures/HomePage.tsx index a42032c9fe..d11944e470 100644 --- a/src/components/structures/HomePage.tsx +++ b/src/components/structures/HomePage.tsx @@ -15,20 +15,67 @@ limitations under the License. */ import * as React from "react"; +import {useContext, useState} from "react"; import AutoHideScrollbar from './AutoHideScrollbar'; -import { getHomePageUrl } from "../../utils/pages"; -import { _t } from "../../languageHandler"; +import {getHomePageUrl} from "../../utils/pages"; +import {_t} from "../../languageHandler"; import SdkConfig from "../../SdkConfig"; import * as sdk from "../../index"; import dis from "../../dispatcher/dispatcher"; -import { Action } from "../../dispatcher/actions"; +import {Action} from "../../dispatcher/actions"; +import BaseAvatar from "../views/avatars/BaseAvatar"; +import {OwnProfileStore} from "../../stores/OwnProfileStore"; +import AccessibleButton from "../views/elements/AccessibleButton"; +import {UPDATE_EVENT} from "../../stores/AsyncStore"; +import {useEventEmitter} from "../../hooks/useEventEmitter"; +import MatrixClientContext from "../../contexts/MatrixClientContext"; +import MiniAvatarUploader, {AVATAR_SIZE} from "../views/elements/MiniAvatarUploader"; const onClickSendDm = () => dis.dispatch({action: 'view_create_chat'}); const onClickExplore = () => dis.fire(Action.ViewRoomDirectory); const onClickNewRoom = () => dis.dispatch({action: 'view_create_room'}); -const HomePage = () => { +interface IProps { + justRegistered?: boolean; +} + +const getOwnProfile = (userId: string) => ({ + displayName: OwnProfileStore.instance.displayName || userId, + avatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_SIZE), +}); + +const UserWelcomeTop = () => { + const cli = useContext(MatrixClientContext); + const userId = cli.getUserId(); + const [ownProfile, setOwnProfile] = useState(getOwnProfile(userId)); + useEventEmitter(OwnProfileStore.instance, UPDATE_EVENT, () => { + setOwnProfile(getOwnProfile(userId)); + }); + + return
+ cli.setAvatarUrl(url)} + > + + + +

{ _t("Welcome %(name)s", { name: ownProfile.displayName }) }

+

{ _t("Now, let's help you get started") }

+
; +}; + +const HomePage: React.FC = ({ justRegistered = false }) => { const config = SdkConfig.get(); const pageUrl = getHomePageUrl(config); @@ -37,18 +84,27 @@ const HomePage = () => { return ; } - const brandingConfig = config.branding; - let logoUrl = "themes/element/img/logos/element-logo.svg"; - if (brandingConfig && brandingConfig.authHeaderLogoUrl) { - logoUrl = brandingConfig.authHeaderLogoUrl; + let introSection; + if (justRegistered) { + introSection = ; + } else { + const brandingConfig = config.branding; + let logoUrl = "themes/element/img/logos/element-logo.svg"; + if (brandingConfig && brandingConfig.authHeaderLogoUrl) { + logoUrl = brandingConfig.authHeaderLogoUrl; + } + + introSection = + {config.brand} +

{ _t("Welcome to %(appName)s", { appName: config.brand }) }

+

{ _t("Liberate your communication") }

+
; } - const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); + return
- {config.brand -

{ _t("Welcome to %(appName)s", { appName: config.brand || "Element" }) }

-

{ _t("Liberate your communication") }

+ { introSection }
{ _t("Send a Direct Message") } diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 090a64904c..4445ff3ff8 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -16,7 +16,7 @@ limitations under the License. import * as React from "react"; import { createRef } from "react"; -import TagPanel from "./TagPanel"; +import GroupFilterPanel from "./GroupFilterPanel"; import CustomRoomTagPanel from "./CustomRoomTagPanel"; import classNames from "classnames"; import dis from "../../dispatcher/dispatcher"; @@ -38,6 +38,7 @@ import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import { OwnProfileStore } from "../../stores/OwnProfileStore"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import RoomListNumResults from "../views/rooms/RoomListNumResults"; +import LeftPanelWidget from "./LeftPanelWidget"; interface IProps { isMinimized: boolean; @@ -46,7 +47,7 @@ interface IProps { interface IState { showBreadcrumbs: boolean; - showTagPanel: boolean; + showGroupFilterPanel: boolean; } // List of CSS classes which should be included in keyboard navigation within the room list @@ -60,7 +61,7 @@ const cssClasses = [ export default class LeftPanel extends React.Component { private listContainerRef: React.RefObject = createRef(); - private tagPanelWatcherRef: string; + private groupFilterPanelWatcherRef: string; private bgImageWatcherRef: string; private focusedElement = null; private isDoingStickyHeaders = false; @@ -70,7 +71,7 @@ export default class LeftPanel extends React.Component { this.state = { showBreadcrumbs: BreadcrumbsStore.instance.visible, - showTagPanel: SettingsStore.getValue('TagPanel.enableTagPanel'), + showGroupFilterPanel: SettingsStore.getValue('TagPanel.enableTagPanel'), }; BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate); @@ -78,8 +79,8 @@ export default class LeftPanel extends React.Component { OwnProfileStore.instance.on(UPDATE_EVENT, this.onBackgroundImageUpdate); this.bgImageWatcherRef = SettingsStore.watchSetting( "RoomList.backgroundImage", null, this.onBackgroundImageUpdate); - this.tagPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => { - this.setState({showTagPanel: SettingsStore.getValue("TagPanel.enableTagPanel")}); + this.groupFilterPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => { + this.setState({showGroupFilterPanel: SettingsStore.getValue("TagPanel.enableTagPanel")}); }); // We watch the middle panel because we don't actually get resized, the middle panel does. @@ -88,7 +89,7 @@ export default class LeftPanel extends React.Component { } public componentWillUnmount() { - SettingsStore.unwatchSetting(this.tagPanelWatcherRef); + SettingsStore.unwatchSetting(this.groupFilterPanelWatcherRef); SettingsStore.unwatchSetting(this.bgImageWatcherRef); BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate); RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); @@ -119,8 +120,11 @@ export default class LeftPanel extends React.Component { if (settingBgMxc) { avatarUrl = MatrixClientPeg.get().mxcUrlToHttp(settingBgMxc, avatarSize, avatarSize); } + const avatarUrlProp = `url(${avatarUrl})`; - if (document.body.style.getPropertyValue("--avatar-url") !== avatarUrlProp) { + if (!avatarUrl) { + document.body.style.removeProperty("--avatar-url"); + } else if (document.body.style.getPropertyValue("--avatar-url") !== avatarUrlProp) { document.body.style.setProperty("--avatar-url", avatarUrlProp); } }; @@ -139,7 +143,7 @@ export default class LeftPanel extends React.Component { const bottomEdge = list.offsetHeight + list.scrollTop; const sublists = list.querySelectorAll(".mx_RoomSublist"); - const headerRightMargin = 16; // calculated from margins and widths to align with non-sticky tiles + const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles const headerStickyWidth = list.clientWidth - headerRightMargin; // We track which styles we want on a target before making the changes to avoid @@ -210,10 +214,19 @@ export default class LeftPanel extends React.Component { if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) { header.classList.add("mx_RoomSublist_headerContainer_stickyBottom"); } + + const offset = window.innerHeight - (list.parentElement.offsetTop + list.parentElement.offsetHeight); + const newBottom = `${offset}px`; + if (header.style.bottom !== newBottom) { + header.style.bottom = newBottom; + } } else { if (header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) { header.classList.remove("mx_RoomSublist_headerContainer_stickyBottom"); } + if (header.style.bottom) { + header.style.removeProperty('bottom'); + } } if (style.stickyTop || style.stickyBottom) { @@ -375,9 +388,9 @@ export default class LeftPanel extends React.Component { } public render(): React.ReactNode { - const tagPanel = !this.state.showTagPanel ? null : ( -
- + const groupFilterPanel = !this.state.showGroupFilterPanel ? null : ( +
+ {SettingsStore.getValue("feature_custom_tags") ? : null}
); @@ -385,7 +398,6 @@ export default class LeftPanel extends React.Component { const roomList = { const containerClasses = classNames({ "mx_LeftPanel": true, - "mx_LeftPanel_hasTagPanel": !!tagPanel, + "mx_LeftPanel_hasGroupFilterPanel": !!groupFilterPanel, "mx_LeftPanel_minimized": this.props.isMinimized, }); @@ -405,7 +417,7 @@ export default class LeftPanel extends React.Component { return (
- {tagPanel} + {groupFilterPanel}
+ { !this.props.isMinimized && }
); diff --git a/src/components/structures/LeftPanelWidget.tsx b/src/components/structures/LeftPanelWidget.tsx new file mode 100644 index 0000000000..4daec76d08 --- /dev/null +++ b/src/components/structures/LeftPanelWidget.tsx @@ -0,0 +1,149 @@ +/* +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, {useContext, useEffect, useMemo} from "react"; +import {Resizable} from "re-resizable"; +import classNames from "classnames"; + +import AccessibleButton from "../views/elements/AccessibleButton"; +import {useRovingTabIndex} from "../../accessibility/RovingTabIndex"; +import {Key} from "../../Keyboard"; +import {useLocalStorageState} from "../../hooks/useLocalStorageState"; +import MatrixClientContext from "../../contexts/MatrixClientContext"; +import WidgetUtils, {IWidgetEvent} from "../../utils/WidgetUtils"; +import {useAccountData} from "../../hooks/useAccountData"; +import AppTile from "../views/elements/AppTile"; +import {useSettingValue} from "../../hooks/useSettings"; + +interface IProps { + onResize(): void; +} + +const MIN_HEIGHT = 100; +const MAX_HEIGHT = 500; // or 50% of the window height +const INITIAL_HEIGHT = 280; + +const LeftPanelWidget: React.FC = ({ onResize }) => { + const cli = useContext(MatrixClientContext); + + const mWidgetsEvent = useAccountData>(cli, "m.widgets"); + const leftPanelWidgetId = useSettingValue("Widgets.leftPanel"); + const app = useMemo(() => { + if (!mWidgetsEvent || !leftPanelWidgetId) return null; + const widgetConfig = Object.values(mWidgetsEvent).find(w => w.id === leftPanelWidgetId); + if (!widgetConfig) return null; + + return WidgetUtils.makeAppConfig( + widgetConfig.state_key, + widgetConfig.content, + widgetConfig.sender, + null, + widgetConfig.id); + }, [mWidgetsEvent, leftPanelWidgetId]); + + const [height, setHeight] = useLocalStorageState("left-panel-widget-height", INITIAL_HEIGHT); + const [expanded, setExpanded] = useLocalStorageState("left-panel-widget-expanded", true); + useEffect(onResize, [expanded]); + + const [onFocus, isActive, ref] = useRovingTabIndex(); + const tabIndex = isActive ? 0 : -1; + + if (!app) return null; + + let content; + if (expanded) { + content = { + setHeight(height + d.height); + }} + handleWrapperClass="mx_LeftPanelWidget_resizerHandles" + handleClasses={{top: "mx_LeftPanelWidget_resizerHandle"}} + className="mx_LeftPanelWidget_resizeBox" + enable={{ top: true }} + > + + ; + } + + return
+
{ + switch (ev.key) { + case Key.ARROW_LEFT: + ev.stopPropagation(); + setExpanded(false); + break; + case Key.ARROW_RIGHT: { + ev.stopPropagation(); + setExpanded(true); + break; + } + } + }} + > +
+ { + setExpanded(e => !e); + }} + > + + { WidgetUtils.getWidgetName(app) } + + + {/* Code for the maximise button for once we have full screen widgets */} + {/* { + }} + className="mx_LeftPanelWidget_maximizeButton" + tooltipClassName="mx_LeftPanelWidget_maximizeButtonTooltip" + title={_t("Maximize")} + />*/} +
+
+ + { content } +
; +}; + +export default LeftPanelWidget; diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 98921d03a1..ec5afd13f0 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -53,6 +53,7 @@ import NonUrgentToastContainer from "./NonUrgentToastContainer"; import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload"; import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; import Modal from "../../Modal"; +import { ICollapseConfig } from "../../resizer/distributors/collapse"; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -72,9 +73,6 @@ interface IProps { viaServers?: string[]; hideToSRUsers: boolean; resizeNotifier: ResizeNotifier; - middleDisabled: boolean; - leftDisabled: boolean; - rightDisabled: boolean; // eslint-disable-next-line camelcase page_type: string; autoJoin: boolean; @@ -91,6 +89,7 @@ interface IProps { currentUserId?: string; currentGroupId?: string; currentGroupIsNew?: boolean; + justRegistered?: boolean; } interface IUsageLimit { @@ -101,10 +100,6 @@ interface IUsageLimit { } interface IState { - mouseDown?: { - x: number; - y: number; - }; syncErrorData?: { error: { data: IUsageLimit; @@ -152,7 +147,6 @@ class LoggedInView extends React.Component { super(props, context); this.state = { - mouseDown: undefined, syncErrorData: undefined, // use compact timeline view useCompactLayout: SettingsStore.getValue('useCompactLayout'), @@ -214,12 +208,8 @@ class LoggedInView extends React.Component { }; _createResizer() { - const classNames = { - handle: "mx_ResizeHandle", - vertical: "mx_ResizeHandle_vertical", - reverse: "mx_ResizeHandle_reverse", - }; - const collapseConfig = { + let size; + const collapseConfig: ICollapseConfig = { toggleSize: 260 - 50, onCollapsed: (collapsed) => { if (collapsed) { @@ -229,22 +219,24 @@ class LoggedInView extends React.Component { dis.dispatch({action: "show_left_panel"}, true); } }, - onResized: (size) => { - window.localStorage.setItem("mx_lhs_size", '' + size); + onResized: (_size) => { + size = _size; this.props.resizeNotifier.notifyLeftHandleResized(); }, onResizeStart: () => { this.props.resizeNotifier.startResizing(); }, onResizeStop: () => { + window.localStorage.setItem("mx_lhs_size", '' + size); this.props.resizeNotifier.stopResizing(); }, }; - const resizer = new Resizer( - this._resizeContainer.current, - CollapseDistributor, - collapseConfig); - resizer.setClassNames(classNames); + const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig); + resizer.setClassNames({ + handle: "mx_ResizeHandle", + vertical: "mx_ResizeHandle_vertical", + reverse: "mx_ResizeHandle_reverse", + }); return resizer; } @@ -530,8 +522,8 @@ class LoggedInView extends React.Component { // Could be "GroupTile +groupId:domain" const draggableId = result.draggableId.split(' ').pop(); - // Dispatch synchronously so that the TagPanel receives an - // optimistic update from TagOrderStore before the previous + // Dispatch synchronously so that the GroupFilterPanel receives an + // optimistic update from GroupFilterOrderStore before the previous // state is shown. dis.dispatch(TagOrderActions.moveTag( this._matrixClient, @@ -562,48 +554,6 @@ class LoggedInView extends React.Component { ), true); }; - _onMouseDown = (ev) => { - // When the panels are disabled, clicking on them results in a mouse event - // which bubbles to certain elements in the tree. When this happens, close - // any settings page that is currently open (user/room/group). - if (this.props.leftDisabled && this.props.rightDisabled) { - const targetClasses = new Set(ev.target.className.split(' ')); - if ( - targetClasses.has('mx_MatrixChat') || - targetClasses.has('mx_MatrixChat_middlePanel') || - targetClasses.has('mx_RoomView') - ) { - this.setState({ - mouseDown: { - x: ev.pageX, - y: ev.pageY, - }, - }); - } - } - }; - - _onMouseUp = (ev) => { - if (!this.state.mouseDown) return; - - const deltaX = ev.pageX - this.state.mouseDown.x; - const deltaY = ev.pageY - this.state.mouseDown.y; - const distance = Math.sqrt((deltaX * deltaX) + (deltaY + deltaY)); - const maxRadius = 5; // People shouldn't be straying too far, hopefully - - // Note: we track how far the user moved their mouse to help - // combat against https://github.com/vector-im/element-web/issues/7158 - - if (distance < maxRadius) { - // This is probably a real click, and not a drag - dis.dispatch({ action: 'close_settings' }); - } - - // Always clear the mouseDown state to ensure we don't accidentally - // use stale values due to the mouseDown checks. - this.setState({mouseDown: null}); - }; - render() { const RoomView = sdk.getComponent('structures.RoomView'); const UserView = sdk.getComponent('structures.UserView'); @@ -623,7 +573,6 @@ class LoggedInView extends React.Component { oobData={this.props.roomOobData} viaServers={this.props.viaServers} key={this.props.currentRoomId || 'roomview'} - disabled={this.props.middleDisabled} resizeNotifier={this.props.resizeNotifier} />; break; @@ -637,7 +586,7 @@ class LoggedInView extends React.Component { break; case PageTypes.HomePage: - pageElement = ; + pageElement = ; break; case PageTypes.UserView: @@ -671,8 +620,6 @@ class LoggedInView extends React.Component { onKeyDown={this._onReactKeyDown} className='mx_MatrixChat_wrapper' aria-hidden={this.props.hideToSRUsers} - onMouseDown={this._onMouseDown} - onMouseUp={this._onMouseUp} > diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 4f5489d796..114347196a 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -29,6 +29,7 @@ import 'focus-visible'; import 'what-input'; import Analytics from "../../Analytics"; +import CountlyAnalytics from "../../CountlyAnalytics"; import { DecryptionFailureTracker } from "../../DecryptionFailureTracker"; import { MatrixClientPeg, IMatrixClientCreds } from "../../MatrixClientPeg"; import PlatformPeg from "../../PlatformPeg"; @@ -61,7 +62,7 @@ import DMRoomMap from '../../utils/DMRoomMap'; import ThemeWatcher from "../../settings/watchers/ThemeWatcher"; import { FontWatcher } from '../../settings/watchers/FontWatcher'; import { storeRoomAliasInCache } from '../../RoomAliasCache'; -import { defer, IDeferred } from "../../utils/promise"; +import { defer, IDeferred, sleep } from "../../utils/promise"; import ToastStore from "../../stores/ToastStore"; import * as StorageManager from "../../utils/StorageManager"; import type LoggedInViewType from "./LoggedInView"; @@ -86,37 +87,37 @@ import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore"; export enum Views { // a special initial state which is only used at startup, while we are // trying to re-animate a matrix client or register as a guest. - LOADING = 0, + LOADING, // we are showing the welcome view - WELCOME = 1, + WELCOME, // we are showing the login view - LOGIN = 2, + LOGIN, // we are showing the registration view - REGISTER = 3, - - // completing the registration flow - POST_REGISTRATION = 4, + REGISTER, // showing the 'forgot password' view - FORGOT_PASSWORD = 5, + FORGOT_PASSWORD, // showing flow to trust this new device with cross-signing - COMPLETE_SECURITY = 6, + COMPLETE_SECURITY, // flow to setup SSSS / cross-signing on this account - E2E_SETUP = 7, + E2E_SETUP, - // we are logged in with an active matrix client. - LOGGED_IN = 8, + // we are logged in with an active matrix client. The logged_in state also + // includes guests users as they too are logged in at the client level. + LOGGED_IN, // We are logged out (invalid token) but have our local state again. The user // should log back in to rehydrate the client. - SOFT_LOGOUT = 9, + SOFT_LOGOUT, } +const AUTH_SCREENS = ["register", "login", "forgot_password", "start_sso", "start_cas"]; + // Actions that are redirected through the onboarding process prior to being // re-dispatched. NOTE: some actions are non-trivial and would require // re-factoring to be included in this list in future. @@ -181,9 +182,6 @@ interface IState { currentUserId?: string; // this is persisted as mx_lhs_size, loaded in LoggedInView collapseLhs: boolean; - leftDisabled: boolean; - middleDisabled: boolean; - // the right panel's disabled state is tracked in its store. // Parameters used in the registration dance with the IS // eslint-disable-next-line camelcase register_client_secret?: string; @@ -202,6 +200,7 @@ interface IState { roomOobData?: object; viaServers?: string[]; pendingInitialSync?: boolean; + justRegistered?: boolean; } export default class MatrixChat extends React.PureComponent { @@ -236,8 +235,6 @@ export default class MatrixChat extends React.PureComponent { this.state = { view: Views.LOADING, collapseLhs: false, - leftDisabled: false, - middleDisabled: false, hideToSRUsers: false, @@ -354,6 +351,7 @@ export default class MatrixChat extends React.PureComponent { if (SettingsStore.getValue("analyticsOptIn")) { Analytics.enable(); } + CountlyAnalytics.instance.enable(/* anonymous = */ true); } // TODO: [REACT-WARNING] Replace with appropriate lifecycle stage @@ -368,6 +366,7 @@ export default class MatrixChat extends React.PureComponent { if (this.shouldTrackPageChange(prevState, this.state)) { const durationMs = this.stopPageChangeTimer(); Analytics.trackPageChange(durationMs); + CountlyAnalytics.instance.trackPageChange(durationMs); } if (this.focusComposer) { dis.fire(Action.FocusComposer); @@ -420,6 +419,8 @@ export default class MatrixChat extends React.PureComponent { } else { dis.dispatch({action: "view_welcome_page"}); } + } else if (SettingsStore.getValue("analyticsOptIn")) { + CountlyAnalytics.instance.enable(/* anonymous = */ false); } }); // Note we don't catch errors from this: we catch everything within @@ -478,6 +479,7 @@ export default class MatrixChat extends React.PureComponent { } const newState = { currentUserId: null, + justRegistered: false, }; Object.assign(newState, state); this.setState(newState); @@ -559,11 +561,6 @@ export default class MatrixChat extends React.PureComponent { ThemeController.isLogin = true; this.themeWatcher.recheck(); break; - case 'start_post_registration': - this.setState({ - view: Views.POST_REGISTRATION, - }); - break; case 'start_password_recovery': this.setStateForNewView({ view: Views.FORGOT_PASSWORD, @@ -650,8 +647,9 @@ export default class MatrixChat extends React.PureComponent { } case Action.ViewRoomDirectory: { const RoomDirectory = sdk.getComponent("structures.RoomDirectory"); - Modal.createTrackedDialog('Room directory', '', RoomDirectory, {}, - 'mx_RoomDirectory_dialogWrapper', false, true); + Modal.createTrackedDialog('Room directory', '', RoomDirectory, { + initialText: payload.initialText, + }, 'mx_RoomDirectory_dialogWrapper', false, true); // View the welcome or home page if we need something to look at this.viewSomethingBehindModal(); @@ -668,13 +666,13 @@ export default class MatrixChat extends React.PureComponent { this.viewWelcome(); break; case 'view_home_page': - this.viewHome(); + this.viewHome(payload.justRegistered); break; case 'view_start_chat_or_reuse': this.chatCreateOrReuse(payload.user_id); break; case 'view_create_chat': - showStartChatInviteDialog(); + showStartChatInviteDialog(payload.initialText || ""); break; case 'view_invite': showRoomInviteDialog(payload.roomId); @@ -710,14 +708,6 @@ export default class MatrixChat extends React.PureComponent { this.state.resizeNotifier.notifyLeftHandleResized(); }); break; - case 'panel_disable': { - this.setState({ - leftDisabled: payload.leftDisabled || payload.sideDisabled || false, - middleDisabled: payload.middleDisabled || false, - // We don't track the right panel being disabled here - it's tracked in the store. - }); - break; - } case 'on_logged_in': if ( !Lifecycle.isSoftLogout() && @@ -763,7 +753,12 @@ export default class MatrixChat extends React.PureComponent { SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, true); SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false); hideAnalyticsToast(); - Analytics.enable(); + if (Analytics.canEnable()) { + Analytics.enable(); + } + if (CountlyAnalytics.instance.canEnable()) { + CountlyAnalytics.instance.enable(/* anonymous = */ false); + } break; case 'reject_cookies': SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, false); @@ -955,10 +950,11 @@ export default class MatrixChat extends React.PureComponent { this.themeWatcher.recheck(); } - private viewHome() { + private viewHome(justRegistered = false) { // The home page requires the "logged in" view, so we'll set that. this.setStateForNewView({ view: Views.LOGGED_IN, + justRegistered, }); this.setPage(PageTypes.HomePage); this.notifyNewScreen('home'); @@ -1192,7 +1188,7 @@ export default class MatrixChat extends React.PureComponent { if (welcomeUserRoom === null) { // We didn't redirect to the welcome user room, so show // the homepage. - dis.dispatch({action: 'view_home_page'}); + dis.dispatch({action: 'view_home_page', justRegistered: true}); } } else if (ThreepidInviteStore.instance.pickBestInvite()) { // The user has a 3pid invite pending - show them that @@ -1205,7 +1201,7 @@ export default class MatrixChat extends React.PureComponent { } else { // The user has just logged in after registering, // so show the homepage. - dis.dispatch({action: 'view_home_page'}); + dis.dispatch({action: 'view_home_page', justRegistered: true}); } } else { this.showScreenAfterLogin(); @@ -1213,7 +1209,11 @@ export default class MatrixChat extends React.PureComponent { StorageManager.tryPersistStorage(); - if (SettingsStore.getValue("showCookieBar") && Analytics.canEnable()) { + // defer the following actions by 30 seconds to not throw them at the user immediately + await sleep(30); + if (SettingsStore.getValue("showCookieBar") && + (Analytics.canEnable() || CountlyAnalytics.instance.canEnable()) + ) { showAnalyticsToast(this.props.config.piwik?.policyUrl); } } @@ -1343,8 +1343,8 @@ export default class MatrixChat extends React.PureComponent { this.firstSyncComplete = true; this.firstSyncPromise.resolve(); - if (Notifier.shouldShowPrompt()) { - showNotificationsToast(); + if (Notifier.shouldShowPrompt() && !MatrixClientPeg.userRegisteredWithinLastHours(24)) { + showNotificationsToast(false); } dis.fire(Action.FocusComposer); @@ -1407,6 +1407,7 @@ export default class MatrixChat extends React.PureComponent { const dft = new DecryptionFailureTracker((total, errorCode) => { Analytics.trackEvent('E2E', 'Decryption failure', errorCode, total); + CountlyAnalytics.instance.track("decryption_failure", { errorCode }, null, { sum: total }); }, (errorCode) => { // Map JS-SDK error codes to tracker codes for aggregation switch (errorCode) { @@ -1548,6 +1549,14 @@ export default class MatrixChat extends React.PureComponent { } showScreen(screen: string, params?: {[key: string]: any}) { + const cli = MatrixClientPeg.get(); + const isLoggedOutOrGuest = !cli || cli.isGuest(); + if (!isLoggedOutOrGuest && AUTH_SCREENS.includes(screen)) { + // user is logged in and landing on an auth page which will uproot their session, redirect them home instead + dis.dispatch({ action: "view_home_page" }); + return; + } + if (screen === 'register') { dis.dispatch({ action: 'start_registration', @@ -1564,7 +1573,7 @@ export default class MatrixChat extends React.PureComponent { params: params, }); } else if (screen === 'soft_logout') { - if (MatrixClientPeg.get() && MatrixClientPeg.get().getUserId() && !Lifecycle.isSoftLogout()) { + if (cli.getUserId() && !Lifecycle.isSoftLogout()) { // Logged in - visit a room this.viewLastRoom(); } else { @@ -1594,6 +1603,9 @@ export default class MatrixChat extends React.PureComponent { action: 'require_registration', }); } else if (screen === 'directory') { + if (this.state.view === Views.WELCOME) { + CountlyAnalytics.instance.track("onboarding_room_directory"); + } dis.fire(Action.ViewRoomDirectory); } else if (screen === "start_sso" || screen === "start_cas") { // TODO if logged in, skip SSO @@ -1612,14 +1624,6 @@ export default class MatrixChat extends React.PureComponent { dis.dispatch({ action: 'view_my_groups', }); - } else if (screen === 'complete_security') { - dis.dispatch({ - action: 'start_complete_security', - }); - } else if (screen === 'post_registration') { - dis.dispatch({ - action: 'start_post_registration', - }); } else if (screen.indexOf('room/') === 0) { // Rooms can have the following formats: // #room_alias:domain or !opaque_id:domain @@ -1790,14 +1794,6 @@ export default class MatrixChat extends React.PureComponent { return Lifecycle.setLoggedIn(credentials); } - onFinishPostRegistration = () => { - // Don't confuse this with "PageType" which is the middle window to show - this.setState({ - view: Views.LOGGED_IN, - }); - this.showScreen("settings"); - }; - onSendEvent(roomId: string, event: MatrixEvent) { const cli = MatrixClientPeg.get(); if (!cli) { @@ -1962,13 +1958,6 @@ export default class MatrixChat extends React.PureComponent { accountPassword={this.accountPassword} /> ); - } else if (this.state.view === Views.POST_REGISTRATION) { - // needs to be before normal PageTypes as you are logged in technically - const PostRegistration = sdk.getComponent('structures.auth.PostRegistration'); - view = ( - - ); } else if (this.state.view === Views.LOGGED_IN) { // store errors stop the client syncing and require user intervention, so we'll // be showing a dialog. Don't show anything else. diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index e2e3592536..375545f819 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -30,6 +30,8 @@ import {_t} from "../../languageHandler"; import {haveTileForEvent} from "../views/rooms/EventTile"; import {textForEvent} from "../../TextForEvent"; import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer"; +import DMRoomMap from "../../utils/DMRoomMap"; +import NewRoomIntro from "../views/rooms/NewRoomIntro"; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = ['m.sticker', 'm.room.message']; @@ -952,15 +954,25 @@ class CreationGrouper { }).reduce((a, b) => a.concat(b), []); // Get sender profile from the latest event in the summary as the m.room.create doesn't contain one const ev = this.events[this.events.length - 1]; + + let summaryText; + const roomId = ev.getRoomId(); + const creator = ev.sender ? ev.sender.name : ev.getSender(); + if (DMRoomMap.shared().getUserIdForRoomId(roomId)) { + summaryText = _t("%(creator)s created this DM.", { creator }); + } else { + summaryText = _t("%(creator)s created and configured the room.", { creator }); + } + + ret.push(); + ret.push( { eventTiles } , diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index 021cdb438d..41f4d83743 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -17,7 +17,6 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import classNames from 'classnames'; import {Room} from "matrix-js-sdk/src/models/room"; import * as sdk from '../../index'; @@ -304,14 +303,8 @@ export default class RightPanel extends React.Component { break; } - const classes = classNames("mx_RightPanel", "mx_fadable", { - "collapsed": this.props.collapsed, - "mx_fadable_faded": this.props.disabled, - "dark-panel": true, - }); - return ( -
; @@ -693,7 +710,7 @@ export default class RoomDirectory extends React.Component {
diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 526aecddd7..a64e40bc65 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -148,7 +148,7 @@ export default class RoomSearch extends React.PureComponent { onBlur={this.onBlur} onChange={this.onChange} onKeyDown={this.onKeyDown} - placeholder={_t("Search")} + placeholder={_t("Filter")} autoComplete="off" /> ); @@ -164,7 +164,7 @@ export default class RoomSearch extends React.PureComponent { if (this.props.isMinimized) { icon = ( diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index e390be6979..e6d2985073 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -41,9 +41,6 @@ export default class RoomStatusBar extends React.Component { static propTypes = { // the room this statusbar is representing. room: PropTypes.object.isRequired, - // This is true when the user is alone in the room, but has also sent a message. - // Used to suggest to the user to invite someone - sentMessageAndIsAlone: PropTypes.bool, // The active call in the room, if any (means we show the call bar // along with the status of the call) @@ -68,10 +65,6 @@ export default class RoomStatusBar extends React.Component { // 'you are alone' bar onInviteClick: PropTypes.func, - // callback for when the user clicks on the 'stop warning me' button in the - // 'you are alone' bar - onStopWarningClick: PropTypes.func, - // callback for when we do something that changes the size of the // status bar. This is used to trigger a re-layout in the parent // component. @@ -159,10 +152,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() || - this.props.sentMessageAndIsAlone - ) { + if (this._shouldShowConnectionError() || this._showCallBar()) { return STATUS_BAR_EXPANDED; } else if (this.state.unsentMessages.length > 0) { return STATUS_BAR_EXPANDED_LARGE; @@ -325,24 +315,6 @@ export default class RoomStatusBar extends React.Component { ); } - // If you're alone in the room, and have sent a message, suggest to invite someone - if (this.props.sentMessageAndIsAlone && !this.props.isPeeking) { - return ( -
- { _t("There's no one else here! Would you like to invite others " + - "or stop warning about the empty room?", - {}, - { - 'inviteText': (sub) => - { sub }, - 'nowarnText': (sub) => - { sub }, - }, - ) } -
- ); - } - return null; } diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index f03b39ff52..de7ae347dd 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -71,7 +71,11 @@ 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/lib/webrtc/call"; +import { CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; +import WidgetStore from "../../stores/WidgetStore"; +import {UPDATE_EVENT} from "../../stores/AsyncStore"; +import Notifier from "../../Notifier"; +import {showToast as showNotificationsToast} from "../../toasts/DesktopNotificationsToast"; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -105,7 +109,6 @@ interface IProps { viaServers?: string[]; autoJoin?: boolean; - disabled?: boolean; resizeNotifier: ResizeNotifier; // Called with the credentials of a registered user (if they were a ROU that transitioned to PWLU) @@ -128,6 +131,7 @@ export interface IState { initialEventPixelOffset?: number; // Whether to highlight the event scrolled to isInitialEventHighlighted?: boolean; + replyToEvent?: MatrixEvent; forwardingEvent?: MatrixEvent; numUnreadMessages: number; draggingFile: boolean; @@ -146,7 +150,6 @@ export interface IState { guestsCanJoin: boolean; canPeek: boolean; showApps: boolean; - isAlone: boolean; isPeeking: boolean; showingPinned: boolean; showReadReceipts: boolean; @@ -181,6 +184,7 @@ export interface IState { e2eStatus?: E2EStatus; rejecting?: boolean; rejectError?: Error; + hasPinnedWidgets?: boolean; } export default class RoomView extends React.Component { @@ -218,7 +222,6 @@ export default class RoomView extends React.Component { guestsCanJoin: false, canPeek: false, showApps: false, - isAlone: false, isPeeking: false, showingPinned: false, showReadReceipts: true, @@ -251,7 +254,9 @@ export default class RoomView extends React.Component { this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate); - WidgetEchoStore.on('update', this.onWidgetEchoStoreUpdate); + WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); + WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate); + this.showReadReceiptsWatchRef = SettingsStore.watchSetting("showReadReceipts", null, this.onReadReceiptsChange); this.layoutWatcherRef = SettingsStore.watchSetting("useIRCLayout", null, this.onLayoutChange); @@ -263,6 +268,18 @@ export default class RoomView extends React.Component { this.onRoomViewStoreUpdate(true); } + private onWidgetStoreUpdate = () => { + if (this.state.room) { + this.checkWidgets(this.state.room); + } + } + + private checkWidgets = (room) => { + this.setState({ + hasPinnedWidgets: WidgetStore.instance.getPinnedApps(room.roomId).length > 0, + }) + }; + private onReadReceiptsChange = () => { this.setState({ showReadReceipts: SettingsStore.getValue("showReadReceipts", this.state.roomId), @@ -299,6 +316,7 @@ export default class RoomView extends React.Component { joining: RoomViewStore.isJoining(), initialEventId: RoomViewStore.getInitialEventId(), isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(), + replyToEvent: RoomViewStore.getQuotingEvent(), forwardingEvent: RoomViewStore.getForwardingEvent(), // we should only peek once we have a ready client shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(), @@ -585,7 +603,8 @@ export default class RoomView extends React.Component { this.rightPanelStoreToken.remove(); } - WidgetEchoStore.removeListener('update', this.onWidgetEchoStoreUpdate); + WidgetEchoStore.removeListener(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); + WidgetStore.instance.removeListener(UPDATE_EVENT, this.onWidgetStoreUpdate); if (this.showReadReceiptsWatchRef) { SettingsStore.unwatchSetting(this.showReadReceiptsWatchRef); @@ -684,9 +703,8 @@ export default class RoomView extends React.Component { private onAction = payload => { switch (payload.action) { - case 'message_send_failed': case 'message_sent': - this.checkIfAlone(this.state.room); + this.checkDesktopNotifications(); break; case 'post_sticker_message': this.injectSticker( @@ -824,6 +842,7 @@ export default class RoomView extends React.Component { this.calculateRecommendedVersion(room); this.updateE2EStatus(room); this.updatePermissions(room); + this.checkWidgets(room); }; private async calculateRecommendedVersion(room: Room) { @@ -1003,33 +1022,17 @@ export default class RoomView extends React.Component { } // rate limited because a power level change will emit an event for every member in the room. - private updateRoomMembers = rateLimitedFunc((dueToMember) => { + private updateRoomMembers = rateLimitedFunc(() => { this.updateDMState(); - - let memberCountInfluence = 0; - if (dueToMember && dueToMember.membership === "invite" && this.state.room.getInvitedMemberCount() === 0) { - // A member got invited, but the room hasn't detected that change yet. Influence the member - // count by 1 to counteract this. - memberCountInfluence = 1; - } - this.checkIfAlone(this.state.room, memberCountInfluence); - this.updateE2EStatus(this.state.room); }, 500); - private checkIfAlone(room: Room, countInfluence?: number) { - let warnedAboutLonelyRoom = false; - if (localStorage) { - warnedAboutLonelyRoom = Boolean(localStorage.getItem('mx_user_alone_warned_' + this.state.room.roomId)); + private checkDesktopNotifications() { + const memberCount = this.state.room.getJoinedMemberCount() + this.state.room.getInvitedMemberCount(); + // if they are not alone prompt the user about notifications so they don't miss replies + if (memberCount > 1 && Notifier.shouldShowPrompt()) { + showNotificationsToast(true); } - if (warnedAboutLonelyRoom) { - if (this.state.isAlone) this.setState({isAlone: false}); - return; - } - - let joinedOrInvitedMemberCount = room.getJoinedMemberCount() + room.getInvitedMemberCount(); - if (countInfluence) joinedOrInvitedMemberCount += countInfluence; - this.setState({isAlone: joinedOrInvitedMemberCount === 1}); } private updateDMState() { @@ -1064,14 +1067,6 @@ export default class RoomView extends React.Component { action: 'view_invite', roomId: this.state.room.roomId, }); - this.setState({isAlone: false}); // there's a good chance they'll invite someone - }; - - private onStopAloneWarningClick = () => { - if (localStorage) { - localStorage.setItem('mx_user_alone_warned_' + this.state.room.roomId, String(true)); - } - this.setState({isAlone: false}); }; private onJoinButtonClicked = () => { @@ -1093,6 +1088,7 @@ export default class RoomView extends React.Component { dis.dispatch({ action: 'join_room', opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers }, + _type: "unknown", // TODO: instrumentation }); return Promise.resolve(); }); @@ -1119,16 +1115,9 @@ export default class RoomView extends React.Component { ev.dataTransfer.dropEffect = 'none'; - const items = [...ev.dataTransfer.items]; - if (items.length >= 1) { - const isDraggingFiles = items.every(function(item) { - return item.kind == 'file'; - }); - - if (isDraggingFiles) { - this.setState({ draggingFile: true }); - ev.dataTransfer.dropEffect = 'copy'; - } + if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) { + this.setState({ draggingFile: true }); + ev.dataTransfer.dropEffect = 'copy'; } }; @@ -1259,7 +1248,7 @@ export default class RoomView extends React.Component { } if (!this.state.searchResults.next_batch) { - if (this.state.searchResults.results.length == 0) { + if (!this.state.searchResults?.results?.length) { ret.push(
  • { _t("No results") }

  • , @@ -1283,7 +1272,7 @@ export default class RoomView extends React.Component { let lastRoomId; - for (let i = this.state.searchResults.results.length - 1; i >= 0; i--) { + for (let i = (this.state.searchResults?.results?.length || 0) - 1; i >= 0; i--) { const result = this.state.searchResults.results[i]; const mxEv = result.context.getEvent(); @@ -1353,6 +1342,13 @@ export default class RoomView extends React.Component { dis.fire(Action.FocusComposer); }; + private onAppsClick = () => { + dis.dispatch({ + action: "appsDrawer", + show: !this.state.showApps, + }); + }; + private onLeaveClick = () => { dis.dispatch({ action: 'leave_room', @@ -1762,12 +1758,10 @@ export default class RoomView extends React.Component { isStatusAreaExpanded = this.state.statusBarVisible; statusBar = ; @@ -1853,7 +1847,6 @@ export default class RoomView extends React.Component { draggingFile={this.state.draggingFile} maxHeight={this.state.auxPanelMaxHeight} showApps={this.state.showApps} - hideAppsDrawer={false} onResize={this.onResize} resizeNotifier={this.props.resizeNotifier} > @@ -1872,10 +1865,10 @@ export default class RoomView extends React.Component { ; } @@ -1947,7 +1940,7 @@ export default class RoomView extends React.Component { if (this.state.searchResults) { // show searching spinner - if (this.state.searchResults.results === undefined) { + if (this.state.searchResults.count === undefined) { searchResultsPanel = (
    ); @@ -2028,10 +2021,6 @@ export default class RoomView extends React.Component { "mx_RoomView_statusArea_expanded": isStatusAreaExpanded, }); - const fadableSectionClasses = classNames("mx_RoomView_body", "mx_fadable", { - "mx_fadable_faded": this.props.disabled, - }); - const showRightPanel = this.state.room && this.state.showRightPanel; const rightPanel = showRightPanel ? @@ -2061,9 +2050,11 @@ export default class RoomView extends React.Component { onForgetClick={(myMembership === "leave") ? this.onForgetClick : null} onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null} e2eStatus={this.state.e2eStatus} + onAppsClick={this.state.hasPinnedWidgets ? this.onAppsClick : null} + appsShown={this.state.showApps} /> -
    +
    {auxPanel}
    {topUnreadMessagesBar} diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 99a3da2565..744400df3c 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -704,7 +704,7 @@ export default class ScrollPanel extends React.Component { if (itemlist.style.height !== newHeight) { itemlist.style.height = newHeight; } - if (sn.scrollTop !== sn.scrollHeight){ + if (sn.scrollTop !== sn.scrollHeight) { sn.scrollTop = sn.scrollHeight; } debuglog("updateHeight to", newHeight); diff --git a/src/components/structures/ToastContainer.tsx b/src/components/structures/ToastContainer.tsx index 84473031fa..513cca82c3 100644 --- a/src/components/structures/ToastContainer.tsx +++ b/src/components/structures/ToastContainer.tsx @@ -55,11 +55,11 @@ export default class ToastContainer extends React.Component<{}, IState> { let toast; if (totalCount !== 0) { const topToast = this.state.toasts[0]; - const {title, icon, key, component, props} = topToast; + const {title, icon, key, component, className, props} = topToast; const toastClasses = classNames("mx_Toast_toast", { "mx_Toast_hasIcon": icon, [`mx_Toast_icon_${icon}`]: icon, - }); + }, className); let countIndicator; if (isStacked || this.state.countSeen > 0) { diff --git a/src/components/structures/UploadBar.js b/src/components/structures/UploadBar.js index 0865764c5a..16cc4cb987 100644 --- a/src/components/structures/UploadBar.js +++ b/src/components/structures/UploadBar.js @@ -86,7 +86,9 @@ export default class UploadBar extends React.Component { } // MUST use var name 'count' for pluralization to kick in - const uploadText = _t("Uploading %(filename)s and %(count)s others", {filename: upload.fileName, count: (uploads.length - 1)}); + const uploadText = _t( + "Uploading %(filename)s and %(count)s others", {filename: upload.fileName, count: (uploads.length - 1)}, + ); return (
    diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 17523290b9..75208b8cfe 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -23,7 +23,7 @@ import { _t } from "../../languageHandler"; import { ContextMenuButton } from "./ContextMenu"; import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog"; import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; -import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog"; +import FeedbackDialog from "../views/dialogs/FeedbackDialog"; import Modal from "../../Modal"; import LogoutDialog from "../views/dialogs/LogoutDialog"; import SettingsStore from "../../settings/SettingsStore"; @@ -44,7 +44,7 @@ import IconizedContextMenu, { } from "../views/context_menus/IconizedContextMenu"; import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore"; import * as fbEmitter from "fbemitter"; -import TagOrderStore from "../../stores/TagOrderStore"; +import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore"; import { showCommunityInviteDialog } from "../../RoomInvite"; import dis from "../../dispatcher/dispatcher"; import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; @@ -87,7 +87,7 @@ export default class UserMenu extends React.Component { public componentDidMount() { this.dispatcherRef = defaultDispatcher.register(this.onAction); this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged); - this.tagStoreRef = TagOrderStore.addListener(this.onTagStoreUpdate); + this.tagStoreRef = GroupFilterOrderStore.addListener(this.onTagStoreUpdate); } public componentWillUnmount() { @@ -186,15 +186,22 @@ export default class UserMenu extends React.Component { ev.preventDefault(); ev.stopPropagation(); - Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog); + Modal.createTrackedDialog('Feedback Dialog', '', FeedbackDialog); this.setState({contextMenuPosition: null}); // also close the menu }; - private onSignOutClick = (ev: ButtonEvent) => { + private onSignOutClick = async (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); - Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog); + const cli = MatrixClientPeg.get(); + if (!cli || !cli.isCryptoEnabled() || !(await cli.exportRoomKeys())?.length) { + // log out without user prompt if they have no local megolm sessions + dis.dispatch({action: 'logout'}); + } else { + Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog); + } + this.setState({contextMenuPosition: null}); // also close the menu }; @@ -203,6 +210,7 @@ export default class UserMenu extends React.Component { ev.stopPropagation(); defaultDispatcher.dispatch({action: 'view_home_page'}); + this.setState({contextMenuPosition: null}); // also close the menu }; private onCommunitySettingsClick = (ev: ButtonEvent) => { @@ -257,7 +265,7 @@ export default class UserMenu extends React.Component { const signupLink = getHostingLink("user-context-menu"); if (signupLink) { hostingLink = ( -
    +
    {_t( "Upgrade to your own domain", {}, { @@ -452,7 +460,8 @@ export default class UserMenu extends React.Component { public render() { const avatarSize = 32; // should match border-radius of the avatar - const displayName = OwnProfileStore.instance.displayName || MatrixClientPeg.get().getUserId(); + const userId = MatrixClientPeg.get().getUserId(); + const displayName = OwnProfileStore.instance.displayName || userId; const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize); const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName(); @@ -507,7 +516,7 @@ export default class UserMenu extends React.Component {
    CountlyAnalytics.instance.track("onboarding_forgot_password_email_focus")} + onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_blur")} />
    CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_focus")} + onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_blur")} /> CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_focus")} + onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_blur")} />
    {_t( diff --git a/src/components/structures/auth/Login.js b/src/components/structures/auth/Login.js index 118eed59e3..c3cbac0442 100644 --- a/src/components/structures/auth/Login.js +++ b/src/components/structures/auth/Login.js @@ -30,6 +30,7 @@ import SSOButton from "../../views/elements/SSOButton"; 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]*$/; @@ -126,6 +127,8 @@ export default class LoginComponent extends React.Component { 'm.login.cas': () => this._renderSsoStep("cas"), 'm.login.sso': () => this._renderSsoStep("sso"), }; + + CountlyAnalytics.instance.track("onboarding_login_begin"); } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event diff --git a/src/components/structures/auth/PostRegistration.js b/src/components/structures/auth/PostRegistration.js deleted file mode 100644 index aa36de6596..0000000000 --- a/src/components/structures/auth/PostRegistration.js +++ /dev/null @@ -1,77 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket 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 * as sdk from '../../../index'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import { _t } from '../../../languageHandler'; -import AuthPage from "../../views/auth/AuthPage"; - -export default class PostRegistration extends React.Component { - static propTypes = { - onComplete: PropTypes.func.isRequired, - }; - - state = { - avatarUrl: null, - errorString: null, - busy: false, - }; - - componentDidMount() { - // There is some assymetry between ChangeDisplayName and ChangeAvatar, - // as ChangeDisplayName will auto-get the name but ChangeAvatar expects - // the URL to be passed to you (because it's also used for room avatars). - const cli = MatrixClientPeg.get(); - this.setState({busy: true}); - const self = this; - cli.getProfileInfo(cli.credentials.userId).then(function(result) { - self.setState({ - avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(result.avatar_url), - busy: false, - }); - }, function(error) { - self.setState({ - errorString: _t("Failed to fetch avatar URL"), - busy: false, - }); - }); - } - - render() { - const ChangeDisplayName = sdk.getComponent('settings.ChangeDisplayName'); - const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar'); - const AuthHeader = sdk.getComponent('auth.AuthHeader'); - const AuthBody = sdk.getComponent("auth.AuthBody"); - return ( - - - -
    - { _t('Set a display name:') } - - { _t('Upload an avatar:') } - - - { this.state.errorString } -
    -
    -
    - ); - } -} diff --git a/src/components/structures/auth/Registration.js b/src/components/structures/auth/Registration.js index 630e04da9c..80bf3b72cd 100644 --- a/src/components/structures/auth/Registration.js +++ b/src/components/structures/auth/Registration.js @@ -502,6 +502,11 @@ export default class Registration extends React.Component { return null; } + // 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) { + return null; + } + // If we're on a different phase, we only show the server type selector, // 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 @@ -582,17 +587,6 @@ export default class Registration extends React.Component {
    ; } else if (this.state.flows.length) { - let onEditServerDetailsClick = null; - // If custom URLs are allowed and we haven't selected the Free server type, wire - // up the server details edit link. - if ( - PHASES_ENABLED && - !SdkConfig.get()['disable_custom_urls'] && - this.state.serverType !== ServerType.FREE - ) { - onEditServerDetailsClick = this.onEditServerDetailsClick; - } - return ; } else { + let yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', { + serverName: this.props.serverConfig.hsName, + }); + if (this.props.serverConfig.hsNameIsDifferent) { + const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip"); + + yourMatrixAccountText = _t('Create your Matrix account on ', {}, { + 'underlinedServerName': () => { + return ; + }, + }); + } + + // If custom URLs are allowed, user is not doing UIA flows and they haven't selected the Free server type, + // wire up the server details edit link. + let editLink = null; + if (PHASES_ENABLED && + !SdkConfig.get()['disable_custom_urls'] && + this.state.serverType !== ServerType.FREE && + !this.state.doingUIAuth + ) { + editLink = ( + + {_t('Change')} + + ); + } + body =

    { _t('Create your account') }

    { errorText } { serverDeadSection } { this.renderServerComponent() } + { this.state.phase !== PHASE_SERVER_DETAILS &&

    + {yourMatrixAccountText} + {editLink} +

    } { this.renderRegisterComponent() } { goBack } { signIn } diff --git a/src/components/views/auth/CaptchaForm.js b/src/components/views/auth/CaptchaForm.js index 783d519621..e2d7d594fa 100644 --- a/src/components/views/auth/CaptchaForm.js +++ b/src/components/views/auth/CaptchaForm.js @@ -17,6 +17,7 @@ limitations under the License. import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; +import CountlyAnalytics from "../../../CountlyAnalytics"; const DIV_ID = 'mx_recaptcha'; @@ -45,6 +46,8 @@ export default class CaptchaForm extends React.Component { this._captchaWidgetId = null; this._recaptchaContainer = createRef(); + + CountlyAnalytics.instance.track("onboarding_grecaptcha_begin"); } componentDidMount() { @@ -99,10 +102,16 @@ export default class CaptchaForm extends React.Component { console.log("Loaded recaptcha script."); try { this._renderRecaptcha(DIV_ID); + // clear error if re-rendered + this.setState({ + errorText: null, + }); + CountlyAnalytics.instance.track("onboarding_grecaptcha_loaded"); } catch (e) { this.setState({ errorText: e.toString(), }); + CountlyAnalytics.instance.track("onboarding_grecaptcha_error", { error: e.toString() }); } } diff --git a/src/components/views/auth/CountryDropdown.js b/src/components/views/auth/CountryDropdown.js index 37b1967c48..3296b574a4 100644 --- a/src/components/views/auth/CountryDropdown.js +++ b/src/components/views/auth/CountryDropdown.js @@ -123,7 +123,7 @@ export default class CountryDropdown extends React.Component { const options = displayedCountries.map((country) => { return
    { this._flagImgForIso2(country.iso2) } - { country.name } (+{ country.prefix }) + { _t(country.name) } (+{ country.prefix })
    ; }); diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.js b/src/components/views/auth/InteractiveAuthEntryComponents.js index 47263c1e21..6628ca7120 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.js +++ b/src/components/views/auth/InteractiveAuthEntryComponents.js @@ -26,6 +26,7 @@ import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; import AccessibleButton from "../elements/AccessibleButton"; import Spinner from "../elements/Spinner"; +import CountlyAnalytics from "../../../CountlyAnalytics"; /* This file contains a collection of components which are used by the * InteractiveAuth to prompt the user to enter the information needed @@ -189,6 +190,7 @@ export class RecaptchaAuthEntry extends React.Component { } _onCaptchaResponse = response => { + CountlyAnalytics.instance.track("onboarding_grecaptcha_submit"); this.props.submitAuthDict({ type: RecaptchaAuthEntry.LOGIN_TYPE, response: response, @@ -297,6 +299,8 @@ export class TermsAuthEntry extends React.Component { toggledPolicies: initToggles, policies: pickedPolicies, }; + + CountlyAnalytics.instance.track("onboarding_terms_begin"); } @@ -326,8 +330,12 @@ export class TermsAuthEntry extends React.Component { allChecked = allChecked && checked; } - if (allChecked) this.props.submitAuthDict({type: TermsAuthEntry.LOGIN_TYPE}); - else this.setState({errorText: _t("Please review and accept all of the homeserver's policies")}); + if (allChecked) { + this.props.submitAuthDict({type: TermsAuthEntry.LOGIN_TYPE}); + CountlyAnalytics.instance.track("onboarding_terms_complete"); + } else { + this.setState({errorText: _t("Please review and accept all of the homeserver's policies")}); + } }; render() { @@ -413,12 +421,12 @@ export class EmailIdentityAuthEntry extends React.Component { return ; } else { return ( -
    -

    { _t("An email has been sent to %(emailAddress)s", - { emailAddress: (sub) => { this.props.inputs.emailAddress } }, +

    +

    { _t("A confirmation email has been sent to %(emailAddress)s", + { emailAddress: (sub) => { this.props.inputs.emailAddress } }, ) }

    -

    { _t("Please check your email to continue registration.") }

    +

    { _t("Open the link in the email to continue registration.") }

    ); } diff --git a/src/components/views/auth/PasswordLogin.js b/src/components/views/auth/PasswordLogin.js index 3bd9b557bc..405f9051b9 100644 --- a/src/components/views/auth/PasswordLogin.js +++ b/src/components/views/auth/PasswordLogin.js @@ -24,6 +24,7 @@ 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. @@ -150,7 +151,20 @@ export default class PasswordLogin extends React.Component { 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); } @@ -161,6 +175,7 @@ export default class PasswordLogin extends React.Component { loginType: loginType, username: "", // Reset because email and username use the same state }); + CountlyAnalytics.instance.track("onboarding_login_type_changed", { loginType }); } onPhoneCountryChanged(country) { @@ -176,8 +191,13 @@ export default class PasswordLogin extends React.Component { 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) { @@ -202,6 +222,7 @@ export default class PasswordLogin extends React.Component { placeholder="joe@example.com" value={this.state.username} onChange={this.onUsernameChanged} + onFocus={this.onUsernameFocus} onBlur={this.onUsernameBlur} disabled={this.props.disableSubmit} autoFocus={autoFocus} @@ -216,6 +237,7 @@ export default class PasswordLogin extends React.Component { label={_t("Username")} value={this.state.username} onChange={this.onUsernameChanged} + onFocus={this.onUsernameFocus} onBlur={this.onUsernameBlur} disabled={this.props.disableSubmit} autoFocus={autoFocus} @@ -240,6 +262,7 @@ export default class PasswordLogin extends React.Component { value={this.state.phoneNumber} prefixComponent={phoneCountry} onChange={this.onPhoneNumberChanged} + onFocus={this.onPhoneNumberFocus} onBlur={this.onPhoneNumberBlur} disabled={this.props.disableSubmit} autoFocus={autoFocus} diff --git a/src/components/views/auth/RegistrationForm.js b/src/components/views/auth/RegistrationForm.js index c07486d3bd..70c1017427 100644 --- a/src/components/views/auth/RegistrationForm.js +++ b/src/components/views/auth/RegistrationForm.js @@ -29,6 +29,7 @@ import { SAFE_LOCALPART_REGEX } from '../../../Registration'; import withValidation from '../elements/Validation'; 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'; @@ -50,7 +51,6 @@ export default class RegistrationForm extends React.Component { defaultUsername: PropTypes.string, defaultPassword: PropTypes.string, onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise - onEditServerDetailsClick: PropTypes.func, flows: PropTypes.arrayOf(PropTypes.object).isRequired, serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, canSubmit: PropTypes.bool, @@ -77,6 +77,8 @@ export default class RegistrationForm extends React.Component { passwordConfirm: this.props.defaultPassword || "", passwordComplexity: null, }; + + CountlyAnalytics.instance.track("onboarding_registration_begin"); } onSubmit = async ev => { @@ -86,6 +88,7 @@ export default class RegistrationForm extends React.Component { const allFieldsValid = await this.verifyFieldsBeforeSubmit(); if (!allFieldsValid) { + CountlyAnalytics.instance.track("onboarding_registration_submit_failed"); return; } @@ -110,6 +113,8 @@ export default class RegistrationForm extends React.Component { return; } + CountlyAnalytics.instance.track("onboarding_registration_submit_warn"); + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, { title: _t("Warning!"), @@ -128,6 +133,11 @@ export default class RegistrationForm extends React.Component { _doSubmit(ev) { const email = this.state.email.trim(); + + CountlyAnalytics.instance.track("onboarding_registration_submit_ok", { + email: !!email, + }); + const promise = this.props.onRegisterClick({ username: this.state.username.trim(), password: this.state.password.trim(), @@ -239,6 +249,7 @@ export default class RegistrationForm extends React.Component { validateEmailRules = withValidation({ description: () => _t("Use an email address to recover your account"), + hideDescriptionIfValid: true, rules: [ { key: "required", @@ -315,6 +326,7 @@ export default class RegistrationForm extends React.Component { validatePhoneNumberRules = withValidation({ description: () => _t("Other users can invite you to rooms using your contact details"), + hideDescriptionIfValid: true, rules: [ { key: "required", @@ -345,6 +357,7 @@ export default class RegistrationForm extends React.Component { validateUsernameRules = withValidation({ description: () => _t("Use lowercase letters, numbers, dashes and underscores only"), + hideDescriptionIfValid: true, rules: [ { key: "required", @@ -422,6 +435,8 @@ export default class RegistrationForm extends React.Component { value={this.state.email} onChange={this.onEmailChange} onValidate={this.onEmailValidate} + onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_email_focus")} + onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_email_blur")} />; } @@ -433,6 +448,8 @@ export default class RegistrationForm extends React.Component { value={this.state.password} onChange={this.onPasswordChange} onValidate={this.onPasswordValidate} + onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_password_focus")} + onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_password_blur")} />; } @@ -443,10 +460,12 @@ export default class RegistrationForm extends React.Component { ref={field => this[FIELD_PASSWORD_CONFIRM] = field} type="password" autoComplete="new-password" - label={_t("Confirm")} + label={_t("Confirm password")} value={this.state.passwordConfirm} onChange={this.onPasswordConfirmChange} onValidate={this.onPasswordConfirmValidate} + onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_passwordConfirm_focus")} + onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_passwordConfirm_blur")} />; } @@ -487,37 +506,12 @@ export default class RegistrationForm extends React.Component { value={this.state.username} onChange={this.onUsernameChange} onValidate={this.onUsernameValidate} + onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_username_focus")} + onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_username_blur")} />; } render() { - let yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', { - serverName: this.props.serverConfig.hsName, - }); - if (this.props.serverConfig.hsNameIsDifferent) { - const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip"); - - yourMatrixAccountText = _t('Create your Matrix account on ', {}, { - 'underlinedServerName': () => { - return ; - }, - }); - } - - let editLink = null; - if (this.props.onEditServerDetailsClick) { - editLink = - {_t('Change')} - ; - } - const registerButton = ( ); @@ -553,10 +547,6 @@ export default class RegistrationForm extends React.Component { return (
    -

    - {yourMatrixAccountText} - {editLink} -

    {this.renderUsername()} diff --git a/src/components/views/auth/ServerConfig.js b/src/components/views/auth/ServerConfig.js index ee6f57a521..e04bf9e25a 100644 --- a/src/components/views/auth/ServerConfig.js +++ b/src/components/views/auth/ServerConfig.js @@ -26,6 +26,7 @@ import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; import SdkConfig from "../../../SdkConfig"; import { createClient } from 'matrix-js-sdk/src/matrix'; import classNames from 'classnames'; +import CountlyAnalytics from "../../../CountlyAnalytics"; /* * A pure UI component which displays the HS and IS to use. @@ -70,6 +71,8 @@ export default class ServerConfig extends React.PureComponent { isUrl: props.serverConfig.isUrl, showIdentityServer: false, }; + + CountlyAnalytics.instance.track("onboarding_custom_server"); } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event diff --git a/src/components/views/auth/Welcome.js b/src/components/views/auth/Welcome.js index 21032f4f1a..0205f4e0b9 100644 --- a/src/components/views/auth/Welcome.js +++ b/src/components/views/auth/Welcome.js @@ -23,11 +23,18 @@ import AuthPage from "./AuthPage"; import {_td} from "../../../languageHandler"; import SettingsStore from "../../../settings/SettingsStore"; import {UIFeature} from "../../../settings/UIFeature"; +import CountlyAnalytics from "../../../CountlyAnalytics"; // translatable strings for Welcome pages _td("Sign in with SSO"); export default class Welcome extends React.PureComponent { + constructor(props) { + super(props); + + CountlyAnalytics.instance.track("onboarding_welcome"); + } + render() { const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage'); const LanguageSelector = sdk.getComponent('auth.LanguageSelector'); diff --git a/src/components/views/avatars/BaseAvatar.tsx b/src/components/views/avatars/BaseAvatar.tsx index 245c50576a..799a559263 100644 --- a/src/components/views/avatars/BaseAvatar.tsx +++ b/src/components/views/avatars/BaseAvatar.tsx @@ -51,7 +51,8 @@ const calculateUrls = (url, urls) => { _urls = urls || []; if (url) { - _urls.unshift(url); // put in urls[0] + // copy urls and put url first + _urls = [url, ..._urls]; } } diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index e37dff4bfe..98d69a63e7 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -22,6 +22,7 @@ import ImageView from '../elements/ImageView'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import Modal from '../../../Modal'; import * as Avatar from '../../../Avatar'; +import {ResizeMethod} from "../../../Avatar"; interface IProps { // Room may be left unset here, but if it is, @@ -32,8 +33,9 @@ interface IProps { oobData?: any; width?: number; height?: number; - resizeMethod?: string; + resizeMethod?: ResizeMethod; viewAvatarOnClick?: boolean; + onClick?(): void; } interface IState { @@ -129,7 +131,7 @@ export default class RoomAvatar extends React.Component { }; public render() { - const {room, oobData, viewAvatarOnClick, ...otherProps} = this.props; + const {room, oobData, viewAvatarOnClick, onClick, ...otherProps} = this.props; const roomName = room ? room.name : oobData.name; @@ -138,7 +140,7 @@ export default class RoomAvatar extends React.Component { name={roomName} idName={room ? room.roomId : null} urls={this.state.urls} - onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : null} + onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : onClick} /> ); } diff --git a/src/components/views/avatars/WidgetAvatar.tsx b/src/components/views/avatars/WidgetAvatar.tsx new file mode 100644 index 0000000000..04cfce7670 --- /dev/null +++ b/src/components/views/avatars/WidgetAvatar.tsx @@ -0,0 +1,58 @@ +/* +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, {ComponentProps, useContext} from 'react'; +import classNames from 'classnames'; +import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; + +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import {IApp} from "../../../stores/WidgetStore"; +import BaseAvatar, {BaseAvatarType} from "./BaseAvatar"; + +interface IProps extends Omit, "name" | "url" | "urls"> { + app: IApp; +} + +const WidgetAvatar: React.FC = ({ app, className, width = 20, height = 20, ...props }) => { + const cli = useContext(MatrixClientContext); + + let iconUrls = [require("../../../../res/img/element-icons/room/default_app.svg")]; + // heuristics for some better icons until Widgets support their own icons + if (app.type.includes("jitsi")) { + iconUrls = [require("../../../../res/img/element-icons/room/default_video.svg")]; + } else if (app.type.includes("meeting") || app.type.includes("calendar")) { + iconUrls = [require("../../../../res/img/element-icons/room/default_cal.svg")]; + } else if (app.type.includes("pad") || app.type.includes("doc") || app.type.includes("calc")) { + iconUrls = [require("../../../../res/img/element-icons/room/default_doc.svg")]; + } else if (app.type.includes("clock")) { + iconUrls = [require("../../../../res/img/element-icons/room/default_clock.svg")]; + } + + return ( + + ) +}; + +export default WidgetAvatar; diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index d760c8defa..bc4514f8a6 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -31,6 +31,7 @@ import SettingsStore from '../../../settings/SettingsStore'; import { isUrlPermitted } from '../../../HtmlUtils'; import { isContentActionable } from '../../../utils/EventUtils'; import {MenuItem} from "../../structures/ContextMenu"; +import {EventType} from "matrix-js-sdk/src/@types/event"; function canCancel(eventStatus) { return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; @@ -72,7 +73,10 @@ export default class MessageContextMenu extends React.Component { const cli = MatrixClientPeg.get(); const room = cli.getRoom(this.props.mxEvent.getRoomId()); - const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId); + // We explicitly decline to show the redact option on ACL events as it has a potential + // to obliterate the room - https://github.com/matrix-org/synapse/issues/4042 + const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId) + && this.props.mxEvent.getType() !== EventType.RoomServerAcl; let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli); // HACK: Intentionally say we can't pin if the user doesn't want to use the functionality diff --git a/src/components/views/context_menus/WidgetContextMenu.js b/src/components/views/context_menus/WidgetContextMenu.js deleted file mode 100644 index 6ed32daa5c..0000000000 --- a/src/components/views/context_menus/WidgetContextMenu.js +++ /dev/null @@ -1,142 +0,0 @@ -/* -Copyright 2019 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'; -import PropTypes from 'prop-types'; -import {_t} from '../../../languageHandler'; -import {MenuItem} from "../../structures/ContextMenu"; - -export default class WidgetContextMenu extends React.Component { - static propTypes = { - onFinished: PropTypes.func, - - // Callback for when the revoke button is clicked. Required. - onRevokeClicked: PropTypes.func.isRequired, - - // Callback for when the unpin button is clicked. If absent, unpin will be hidden. - onUnpinClicked: PropTypes.func, - - // Callback for when the snapshot button is clicked. Button not shown - // without a callback. - onSnapshotClicked: PropTypes.func, - - // Callback for when the reload button is clicked. Button not shown - // without a callback. - onReloadClicked: PropTypes.func, - - // Callback for when the edit button is clicked. Button not shown - // without a callback. - onEditClicked: PropTypes.func, - - // Callback for when the delete button is clicked. Button not shown - // without a callback. - onDeleteClicked: PropTypes.func, - }; - - proxyClick(fn) { - fn(); - if (this.props.onFinished) this.props.onFinished(); - } - - // XXX: It's annoying that our context menus require us to hit onFinished() to close :( - - onEditClicked = () => { - this.proxyClick(this.props.onEditClicked); - }; - - onReloadClicked = () => { - this.proxyClick(this.props.onReloadClicked); - }; - - onSnapshotClicked = () => { - this.proxyClick(this.props.onSnapshotClicked); - }; - - onDeleteClicked = () => { - this.proxyClick(this.props.onDeleteClicked); - }; - - onRevokeClicked = () => { - this.proxyClick(this.props.onRevokeClicked); - }; - - onUnpinClicked = () => this.proxyClick(this.props.onUnpinClicked); - - render() { - const options = []; - - if (this.props.onEditClicked) { - options.push( - - {_t("Edit")} - , - ); - } - - if (this.props.onUnpinClicked) { - options.push( - - {_t("Unpin")} - , - ); - } - - if (this.props.onReloadClicked) { - options.push( - - {_t("Reload")} - , - ); - } - - if (this.props.onSnapshotClicked) { - options.push( - - {_t("Take picture")} - , - ); - } - - if (this.props.onDeleteClicked) { - options.push( - - {_t("Remove for everyone")} - , - ); - } - - // Push this last so it appears last. It's always present. - options.push( - - {_t("Remove for me")} - , - ); - - // Put separators between the options - if (options.length > 1) { - const length = options.length; - for (let i = 0; i < length - 1; i++) { - const sep =
    ; - - // Insert backwards so the insertions don't affect our math on where to place them. - // We also use our cached length to avoid worrying about options.length changing - options.splice(length - 1 - i, 0, sep); - } - } - - return
    {options}
    ; - } -} diff --git a/src/components/views/context_menus/WidgetContextMenu.tsx b/src/components/views/context_menus/WidgetContextMenu.tsx new file mode 100644 index 0000000000..7656e70341 --- /dev/null +++ b/src/components/views/context_menus/WidgetContextMenu.tsx @@ -0,0 +1,177 @@ +/* +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, {useContext} from "react"; +import {MatrixCapabilities} from "matrix-widget-api"; + +import IconizedContextMenu, {IconizedContextMenuOption, IconizedContextMenuOptionList} from "./IconizedContextMenu"; +import {ChevronFace} from "../../structures/ContextMenu"; +import {_t} from "../../../languageHandler"; +import WidgetStore, {IApp} from "../../../stores/WidgetStore"; +import WidgetUtils from "../../../utils/WidgetUtils"; +import {WidgetMessagingStore} from "../../../stores/widgets/WidgetMessagingStore"; +import RoomContext from "../../../contexts/RoomContext"; +import dis from "../../../dispatcher/dispatcher"; +import SettingsStore from "../../../settings/SettingsStore"; +import {SettingLevel} from "../../../settings/SettingLevel"; +import Modal from "../../../Modal"; +import QuestionDialog from "../dialogs/QuestionDialog"; +import {WidgetType} from "../../../widgets/WidgetType"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; + +interface IProps extends React.ComponentProps { + app: IApp; + userWidget?: boolean; + showUnpin?: boolean; + // override delete handler + onDeleteClick?(): void; +} + +const WidgetContextMenu: React.FC = ({ + onFinished, + app, + userWidget, + onDeleteClick, + showUnpin, + ...props +}) => { + const cli = useContext(MatrixClientContext); + const {room, roomId} = useContext(RoomContext); + + const widgetMessaging = WidgetMessagingStore.instance.getMessagingForId(app.id); + const canModify = userWidget || WidgetUtils.canUserModifyWidgets(roomId); + + let unpinButton; + if (showUnpin) { + const onUnpinClick = () => { + WidgetStore.instance.unpinWidget(app.id); + onFinished(); + }; + + unpinButton = ; + } + + let editButton; + if (canModify && WidgetUtils.isManagedByManager(app)) { + const onEditClick = () => { + WidgetUtils.editWidget(room, app); + onFinished(); + }; + + editButton = ; + } + + let snapshotButton; + if (widgetMessaging?.hasCapability(MatrixCapabilities.Screenshots)) { + const onSnapshotClick = () => { + widgetMessaging?.takeScreenshot().then(data => { + dis.dispatch({ + action: 'picture_snapshot', + file: data.screenshot, + }); + }).catch(err => { + console.error("Failed to take screenshot: ", err); + }); + onFinished(); + }; + + snapshotButton = ; + } + + let deleteButton; + if (onDeleteClick || canModify) { + const onDeleteClickDefault = () => { + // Show delete confirmation dialog + Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, { + title: _t("Delete Widget"), + description: _t( + "Deleting a widget removes it for all users in this room." + + " Are you sure you want to delete this widget?"), + button: _t("Delete widget"), + onFinished: (confirmed) => { + if (!confirmed) return; + WidgetUtils.setRoomWidget(roomId, app.id); + }, + }); + onFinished(); + }; + + deleteButton = ; + } + + let isAllowedWidget = SettingsStore.getValue("allowedWidgets", roomId)[app.eventId]; + if (isAllowedWidget === undefined) { + isAllowedWidget = app.creatorUserId === cli.getUserId(); + } + + const isLocalWidget = WidgetType.JITSI.matches(app.type); + let revokeButton; + if (!userWidget && !isLocalWidget && isAllowedWidget) { + const onRevokeClick = () => { + console.info("Revoking permission for widget to load: " + app.eventId); + const current = SettingsStore.getValue("allowedWidgets", roomId); + current[app.eventId] = false; + SettingsStore.setValue("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, current).catch(err => { + console.error(err); + // We don't really need to do anything about this - the user will just hit the button again. + }); + onFinished(); + }; + + revokeButton = ; + } + + const pinnedWidgets = WidgetStore.instance.getPinnedApps(roomId); + const widgetIndex = pinnedWidgets.findIndex(widget => widget.id === app.id); + + let moveLeftButton; + if (showUnpin && widgetIndex > 0) { + const onClick = () => { + WidgetStore.instance.movePinnedWidget(app.id, -1); + onFinished(); + }; + + moveLeftButton = ; + } + + let moveRightButton; + if (showUnpin && widgetIndex < pinnedWidgets.length - 1) { + const onClick = () => { + WidgetStore.instance.movePinnedWidget(app.id, 1); + onFinished(); + }; + + moveRightButton = ; + } + + return + + { editButton } + { revokeButton } + { deleteButton } + { snapshotButton } + { moveLeftButton } + { moveRightButton } + { unpinButton } + + ; +}; + +export default WidgetContextMenu; + diff --git a/src/components/views/dialogs/FeedbackDialog.js b/src/components/views/dialogs/FeedbackDialog.js new file mode 100644 index 0000000000..cbe26af6cc --- /dev/null +++ b/src/components/views/dialogs/FeedbackDialog.js @@ -0,0 +1,138 @@ +/* +Copyright 2018 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, {useState} from 'react'; +import QuestionDialog from './QuestionDialog'; +import { _t } from '../../../languageHandler'; +import Field from "../elements/Field"; +import AccessibleButton from "../elements/AccessibleButton"; +import CountlyAnalytics from "../../../CountlyAnalytics"; +import SdkConfig from "../../../SdkConfig"; +import Modal from "../../../Modal"; +import BugReportDialog from "./BugReportDialog"; +import InfoDialog from "./InfoDialog"; +import StyledRadioGroup from "../elements/StyledRadioGroup"; + +const existingIssuesUrl = "https://github.com/vector-im/element-web/issues" + + "?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc"; +const newIssueUrl = "https://github.com/vector-im/element-web/issues/new"; + + +export default (props) => { + const [rating, setRating] = useState(""); + const [comment, setComment] = useState(""); + + const onDebugLogsLinkClick = () => { + props.onFinished(); + Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {}); + }; + + const hasFeedback = CountlyAnalytics.instance.canEnable(); + const onFinished = (sendFeedback) => { + if (hasFeedback && sendFeedback) { + CountlyAnalytics.instance.reportFeedback(parseInt(rating, 10), comment); + Modal.createTrackedDialog('Feedback sent', '', InfoDialog, { + title: _t('Feedback sent'), + description: _t('Thank you!'), + }); + } + props.onFinished(); + }; + + const brand = SdkConfig.get().brand; + + let countlyFeedbackSection; + if (hasFeedback) { + countlyFeedbackSection = +
    +
    +

    {_t("Rate %(brand)s", { brand })}

    + +

    {_t("Tell us below how you feel about %(brand)s so far.", { brand })}

    +

    {_t("Please go into as much detail as you like, so we can track down the problem.")}

    + + + + { + setComment(ev.target.value); + }} + /> +
    +
    ; + } + + let subheading; + if (hasFeedback) { + subheading = ( +

    {_t("There are two ways you can provide feedback and help us improve %(brand)s.", { brand })}

    + ); + } + + return ( + { subheading } + +
    +

    {_t("Report a bug")}

    +

    { + _t("Please view existing bugs on Github first. " + + "No match? Start a new one.", {}, { + existingIssuesLink: (sub) => { + return { sub }; + }, + newIssueLink: (sub) => { + return { sub }; + }, + }) + }

    +

    { + _t("PRO TIP: If you start a bug, please submit debug logs " + + "to help us track down the problem.", {}, { + debugLogsLink: sub => ( + {sub} + ), + }) + }

    +
    + { countlyFeedbackSection } + } + button={hasFeedback ? _t("Send feedback") : _t("Go back")} + buttonDisabled={hasFeedback && rating === ""} + onFinished={onFinished} + />); +}; diff --git a/src/components/views/dialogs/InviteDialog.js b/src/components/views/dialogs/InviteDialog.js index 73101056f3..c039c191c5 100644 --- a/src/components/views/dialogs/InviteDialog.js +++ b/src/components/views/dialogs/InviteDialog.js @@ -31,7 +31,7 @@ import dis from "../../../dispatcher/dispatcher"; import IdentityAuthClient from "../../../IdentityAuthClient"; import Modal from "../../../Modal"; import {humanizeTime} from "../../../utils/humanize"; -import createRoom, {canEncryptToAllUsers, privateShouldBeEncrypted} from "../../../createRoom"; +import createRoom, {canEncryptToAllUsers, findDMForUser, privateShouldBeEncrypted} from "../../../createRoom"; import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite"; import {Key} from "../../../Keyboard"; import {Action} from "../../../dispatcher/actions"; @@ -40,6 +40,8 @@ import RoomListStore from "../../../stores/room-list/RoomListStore"; import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore"; import SettingsStore from "../../../settings/SettingsStore"; import {UIFeature} from "../../../settings/UIFeature"; +import CountlyAnalytics from "../../../CountlyAnalytics"; +import {Room} from "matrix-js-sdk/src/models/room"; // we have a number of types defined from the Matrix spec which can't reasonably be altered here. /* eslint-disable camelcase */ @@ -279,11 +281,17 @@ class DMRoomTile extends React.PureComponent { ); + const caption = this.props.member.isEmail + ? _t("Invite by email") + : this._highlightName(this.props.member.userId); + return (
    {stackedAvatar} - {this._highlightName(this.props.member.name)} - {this._highlightName(this.props.member.userId)} + +
    {this._highlightName(this.props.member.name)}
    +
    {caption}
    +
    {timestamp}
    ); @@ -301,10 +309,14 @@ export default class InviteDialog extends React.PureComponent { // The room ID this dialog is for. Only required for KIND_INVITE. roomId: PropTypes.string, + + // Initial value to populate the filter with + initialText: PropTypes.string, }; static defaultProps = { kind: KIND_DM, + initialText: "", }; _debounceTimer: number = null; @@ -325,11 +337,13 @@ export default class InviteDialog extends React.PureComponent { room.getMembersWithMembership('join').forEach(m => alreadyInvited.add(m.userId)); // add banned users, so we don't try to invite them room.getMembersWithMembership('ban').forEach(m => alreadyInvited.add(m.userId)); + + CountlyAnalytics.instance.trackBeginInvite(props.roomId); } this.state = { targets: [], // array of Member objects (see interface above) - filterText: "", + filterText: this.props.initialText, recents: InviteDialog.buildRecents(alreadyInvited), numRecentsShown: INITIAL_ROOMS_SHOWN, suggestions: this._buildSuggestions(alreadyInvited), @@ -347,6 +361,12 @@ export default class InviteDialog extends React.PureComponent { this._editorRef = createRef(); } + componentDidMount() { + if (this.props.initialText) { + this._updateSuggestions(this.props.initialText); + } + } + static buildRecents(excludedTargetIds: Set): {userId: string, user: RoomMember, lastActive: number} { const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room @@ -566,7 +586,12 @@ export default class InviteDialog extends React.PureComponent { const targetIds = targets.map(t => t.userId); // Check if there is already a DM with these people and reuse it if possible. - const existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds); + let existingRoom: Room; + if (targetIds.length === 1) { + existingRoom = findDMForUser(MatrixClientPeg.get(), targetIds[0]); + } else { + existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds); + } if (existingRoom) { dis.dispatch({ action: 'view_room', @@ -627,6 +652,7 @@ export default class InviteDialog extends React.PureComponent { }; _inviteUsers = () => { + const startTime = CountlyAnalytics.getTimestamp(); this.setState({busy: true}); this._convertFilter(); const targets = this._convertFilter(); @@ -643,6 +669,7 @@ export default class InviteDialog extends React.PureComponent { } inviteMultipleToRoom(this.props.roomId, targetIds).then(result => { + CountlyAnalytics.instance.trackSendInvite(startTime, this.props.roomId, targetIds.length); if (!this._shouldAbortAfterInviteError(result)) { // handles setting error message too this.props.onFinished(); } @@ -658,12 +685,130 @@ export default class InviteDialog extends React.PureComponent { }; _onKeyDown = (e) => { - // when the field is empty and the user hits backspace remove the right-most target - if (!e.target.value && !this.state.busy && this.state.targets.length > 0 && e.key === Key.BACKSPACE && - !e.ctrlKey && !e.shiftKey && !e.metaKey - ) { + if (this.state.busy) return; + const value = e.target.value.trim(); + const hasModifiers = e.ctrlKey || e.shiftKey || e.metaKey; + if (!value && this.state.targets.length > 0 && e.key === Key.BACKSPACE && !hasModifiers) { + // when the field is empty and the user hits backspace remove the right-most target e.preventDefault(); this._removeMember(this.state.targets[this.state.targets.length - 1]); + } else if (value && e.key === Key.ENTER && !hasModifiers) { + // when the user hits enter with something in their field try to convert it + e.preventDefault(); + this._convertFilter(); + } else if (value && e.key === Key.SPACE && !hasModifiers && value.includes("@") && !value.includes(" ")) { + // when the user hits space and their input looks like an e-mail/MXID then try to convert it + e.preventDefault(); + this._convertFilter(); + } + }; + + _updateSuggestions = async (term) => { + MatrixClientPeg.get().searchUserDirectory({term}).then(async r => { + if (term !== this.state.filterText) { + // Discard the results - we were probably too slow on the server-side to make + // these results useful. This is a race we want to avoid because we could overwrite + // more accurate results. + return; + } + + if (!r.results) r.results = []; + + // While we're here, try and autocomplete a search result for the mxid itself + // if there's no matches (and the input looks like a mxid). + if (term[0] === '@' && term.indexOf(':') > 1) { + try { + const profile = await MatrixClientPeg.get().getProfileInfo(term); + if (profile) { + // If we have a profile, we have enough information to assume that + // the mxid can be invited - add it to the list. We stick it at the + // top so it is most obviously presented to the user. + r.results.splice(0, 0, { + user_id: term, + display_name: profile['displayname'], + avatar_url: profile['avatar_url'], + }); + } + } catch (e) { + console.warn("Non-fatal error trying to make an invite for a user ID"); + console.warn(e); + + // Add a result anyways, just without a profile. We stick it at the + // top so it is most obviously presented to the user. + r.results.splice(0, 0, { + user_id: term, + display_name: term, + avatar_url: null, + }); + } + } + + this.setState({ + serverResultsMixin: r.results.map(u => ({ + userId: u.user_id, + user: new DirectoryMember(u), + })), + }); + }).catch(e => { + console.error("Error searching user directory:"); + console.error(e); + this.setState({serverResultsMixin: []}); // clear results because it's moderately fatal + }); + + // Whenever we search the directory, also try to search the identity server. It's + // all debounced the same anyways. + if (!this.state.canUseIdentityServer) { + // The user doesn't have an identity server set - warn them of that. + this.setState({tryingIdentityServer: true}); + return; + } + if (term.indexOf('@') > 0 && Email.looksValid(term) && SettingsStore.getValue(UIFeature.IdentityServer)) { + // Start off by suggesting the plain email while we try and resolve it + // to a real account. + this.setState({ + // per above: the userId is a lie here - it's just a regular identifier + threepidResultsMixin: [{user: new ThreepidMember(term), userId: term}], + }); + try { + const authClient = new IdentityAuthClient(); + const token = await authClient.getAccessToken(); + if (term !== this.state.filterText) return; // abandon hope + + const lookup = await MatrixClientPeg.get().lookupThreePid( + 'email', + term, + undefined, // callback + token, + ); + if (term !== this.state.filterText) return; // abandon hope + + if (!lookup || !lookup.mxid) { + // We weren't able to find anyone - we're already suggesting the plain email + // as an alternative, so do nothing. + return; + } + + // We append the user suggestion to give the user an option to click + // the email anyways, and so we don't cause things to jump around. In + // theory, the user would see the user pop up and think "ah yes, that + // person!" + const profile = await MatrixClientPeg.get().getProfileInfo(lookup.mxid); + if (term !== this.state.filterText || !profile) return; // abandon hope + this.setState({ + threepidResultsMixin: [...this.state.threepidResultsMixin, { + user: new DirectoryMember({ + user_id: lookup.mxid, + display_name: profile.displayname, + avatar_url: profile.avatar_url, + }), + userId: lookup.mxid, + }], + }); + } catch (e) { + console.error("Error searching identity server:"); + console.error(e); + this.setState({threepidResultsMixin: []}); // clear results because it's moderately fatal + } } }; @@ -677,113 +822,8 @@ export default class InviteDialog extends React.PureComponent { if (this._debounceTimer) { clearTimeout(this._debounceTimer); } - this._debounceTimer = setTimeout(async () => { - MatrixClientPeg.get().searchUserDirectory({term}).then(async r => { - if (term !== this.state.filterText) { - // Discard the results - we were probably too slow on the server-side to make - // these results useful. This is a race we want to avoid because we could overwrite - // more accurate results. - return; - } - - if (!r.results) r.results = []; - - // While we're here, try and autocomplete a search result for the mxid itself - // if there's no matches (and the input looks like a mxid). - if (term[0] === '@' && term.indexOf(':') > 1) { - try { - const profile = await MatrixClientPeg.get().getProfileInfo(term); - if (profile) { - // If we have a profile, we have enough information to assume that - // the mxid can be invited - add it to the list. We stick it at the - // top so it is most obviously presented to the user. - r.results.splice(0, 0, { - user_id: term, - display_name: profile['displayname'], - avatar_url: profile['avatar_url'], - }); - } - } catch (e) { - console.warn("Non-fatal error trying to make an invite for a user ID"); - console.warn(e); - - // Add a result anyways, just without a profile. We stick it at the - // top so it is most obviously presented to the user. - r.results.splice(0, 0, { - user_id: term, - display_name: term, - avatar_url: null, - }); - } - } - - this.setState({ - serverResultsMixin: r.results.map(u => ({ - userId: u.user_id, - user: new DirectoryMember(u), - })), - }); - }).catch(e => { - console.error("Error searching user directory:"); - console.error(e); - this.setState({serverResultsMixin: []}); // clear results because it's moderately fatal - }); - - // Whenever we search the directory, also try to search the identity server. It's - // all debounced the same anyways. - if (!this.state.canUseIdentityServer) { - // The user doesn't have an identity server set - warn them of that. - this.setState({tryingIdentityServer: true}); - return; - } - if (term.indexOf('@') > 0 && Email.looksValid(term) && SettingsStore.getValue(UIFeature.IdentityServer)) { - // Start off by suggesting the plain email while we try and resolve it - // to a real account. - this.setState({ - // per above: the userId is a lie here - it's just a regular identifier - threepidResultsMixin: [{user: new ThreepidMember(term), userId: term}], - }); - try { - const authClient = new IdentityAuthClient(); - const token = await authClient.getAccessToken(); - if (term !== this.state.filterText) return; // abandon hope - - const lookup = await MatrixClientPeg.get().lookupThreePid( - 'email', - term, - undefined, // callback - token, - ); - if (term !== this.state.filterText) return; // abandon hope - - if (!lookup || !lookup.mxid) { - // We weren't able to find anyone - we're already suggesting the plain email - // as an alternative, so do nothing. - return; - } - - // We append the user suggestion to give the user an option to click - // the email anyways, and so we don't cause things to jump around. In - // theory, the user would see the user pop up and think "ah yes, that - // person!" - const profile = await MatrixClientPeg.get().getProfileInfo(lookup.mxid); - if (term !== this.state.filterText || !profile) return; // abandon hope - this.setState({ - threepidResultsMixin: [...this.state.threepidResultsMixin, { - user: new DirectoryMember({ - user_id: lookup.mxid, - display_name: profile.displayname, - avatar_url: profile.avatar_url, - }), - userId: lookup.mxid, - }], - }); - } catch (e) { - console.error("Error searching identity server:"); - console.error(e); - this.setState({threepidResultsMixin: []}); // clear results because it's moderately fatal - } - } + this._debounceTimer = setTimeout(() => { + this._updateSuggestions(term); }, 150); // 150ms debounce (human reaction time + some) }; @@ -806,6 +846,10 @@ export default class InviteDialog extends React.PureComponent { filterText = ""; // clear the filter when the user accepts a suggestion } this.setState({targets, filterText}); + + if (this._editorRef && this._editorRef.current) { + this._editorRef.current.focus(); + } }; _removeMember = (member: Member) => { @@ -815,6 +859,10 @@ export default class InviteDialog extends React.PureComponent { targets.splice(idx, 1); this.setState({targets}); } + + if (this._editorRef && this._editorRef.current) { + this._editorRef.current.focus(); + } }; _onPaste = async (e) => { @@ -824,7 +872,7 @@ export default class InviteDialog extends React.PureComponent { return; } - // Prevent the text being pasted into the textarea + // Prevent the text being pasted into the input e.preventDefault(); // Process it as a list of addresses to add instead @@ -1019,8 +1067,8 @@ export default class InviteDialog extends React.PureComponent { )); const input = ( -