diff --git a/CHANGELOG.md b/CHANGELOG.md index 34a3498f9e..5f5372ae5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,113 @@ +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) diff --git a/package.json b/package.json index bb47fc6401..9689892e24 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.6.1", + "version": "3.7.1", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { diff --git a/res/css/_components.scss b/res/css/_components.scss index 4a9301d085..0b46df9bd8 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -70,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"; diff --git a/res/css/structures/_CustomRoomTagPanel.scss b/res/css/structures/_CustomRoomTagPanel.scss index 96813cccea..be1138cf5b 100644 --- a/res/css/structures/_CustomRoomTagPanel.scss +++ b/res/css/structures/_CustomRoomTagPanel.scss @@ -16,11 +16,6 @@ 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: $groupFilterPanel-bg-color; max-height: 40vh; diff --git a/res/css/structures/_LeftPanel.scss b/res/css/structures/_LeftPanel.scss index 885dd77a84..1424d9cda0 100644 --- a/res/css/structures/_LeftPanel.scss +++ b/res/css/structures/_LeftPanel.scss @@ -32,6 +32,7 @@ $groupFilterPanelWidth: 56px; // only applies in this file, used for calculation // Create another flexbox so the GroupFilterPanel fills the container display: flex; + flex-direction: column; // GroupFilterPanel handles its own CSS } 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/_ModalWidgetDialog.scss b/res/css/views/dialogs/_ModalWidgetDialog.scss new file mode 100644 index 0000000000..aa2dd0d395 --- /dev/null +++ b/res/css/views/dialogs/_ModalWidgetDialog.scss @@ -0,0 +1,42 @@ +/* +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_ModalWidgetDialog { + .mx_ModalWidgetDialog_warning { + margin-bottom: 24px; + + > img { + vertical-align: middle; + margin-right: 8px; + } + } + + .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/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/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/src/@types/global.d.ts b/src/@types/global.d.ts index acb2c40031..741798761f 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -33,7 +33,9 @@ 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 { @@ -59,7 +61,9 @@ declare global { mxWidgetStore: WidgetStore; mxCallHandler: CallHandler; mxAnalytics: Analytics; + mxCountlyAnalytics: typeof CountlyAnalytics; mxUserActivity: UserActivity; + mxModalWidgetStore: ModalWidgetStore; } interface Document { @@ -108,4 +112,13 @@ declare global { 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/CallHandler.tsx b/src/CallHandler.tsx index 17867536ed..5fb7197283 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -76,8 +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/src/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', @@ -357,6 +358,7 @@ export default class CallHandler { localElement: HTMLVideoElement, remoteElement: HTMLVideoElement, ) { Analytics.trackEvent('voip', 'placeCall', 'type', type); + CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false); const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), roomId); this.calls.set(roomId, call); this.setCallListeners(call); @@ -437,6 +439,7 @@ export default class CallHandler { 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': @@ -481,16 +484,19 @@ export default class CallHandler { } 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 cba8671143..5409a606de 100644 --- a/src/ContentMessages.tsx +++ b/src/ContentMessages.tsx @@ -31,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; @@ -368,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() { @@ -479,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: { @@ -563,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..600a9be1bf --- /dev/null +++ b/src/CountlyAnalytics.ts @@ -0,0 +1,948 @@ +/* +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"; + 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", + } +} + +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 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 + ""; + } + + 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 = { + ...rest, + ...this.getTimeParams(), + 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 trackRoomDirectory(startTime: number) { + this.track("room_directory", {}, 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/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/Lifecycle.ts b/src/Lifecycle.ts index 6293de063d..7469624f5c 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -47,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"; @@ -580,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/Modal.tsx b/src/Modal.tsx index b0f6ef988e..2f761e7393 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/SlashCommands.tsx b/src/SlashCommands.tsx index 85e0776453..8cda290d07 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -518,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] === '!') { @@ -532,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) { @@ -556,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; diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js index 6819742388..b1a14062f4 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js @@ -470,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 >
@@ -488,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 >
@@ -509,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/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index f7665fea8a..f37da03e47 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"; @@ -109,7 +110,8 @@ export enum Views { // flow to setup SSSS / cross-signing on this account E2E_SETUP = 7, - // we are logged in with an active matrix client. + // 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 = 8, // We are logged out (invalid token) but have our local state again. The user @@ -349,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 @@ -363,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); @@ -415,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 @@ -750,7 +756,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); @@ -1200,7 +1211,9 @@ export default class MatrixChat extends React.PureComponent { StorageManager.tryPersistStorage(); - if (SettingsStore.getValue("showCookieBar") && Analytics.canEnable()) { + if (SettingsStore.getValue("showCookieBar") && + (Analytics.canEnable() || CountlyAnalytics.instance.canEnable()) + ) { showAnalyticsToast(this.props.config.piwik?.policyUrl); } } @@ -1581,6 +1594,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 diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.js index 97e1f82a77..e5c828b442 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.js @@ -33,6 +33,7 @@ import SettingsStore from "../../settings/SettingsStore"; import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore"; import GroupStore from "../../stores/GroupStore"; import FlairStore from "../../stores/FlairStore"; +import CountlyAnalytics from "../../CountlyAnalytics"; const MAX_NAME_LENGTH = 80; const MAX_TOPIC_LENGTH = 800; @@ -49,6 +50,8 @@ export default class RoomDirectory extends React.Component { constructor(props) { super(props); + this.startTime = CountlyAnalytics.getTimestamp(); + const selectedCommunityId = GroupFilterOrderStore.getSelectedTags()[0]; this.state = { publicRooms: [], @@ -198,6 +201,11 @@ export default class RoomDirectory extends React.Component { return; } + if (this.state.filterString) { + const count = data.total_room_count_estimate || data.chunk.length; + CountlyAnalytics.instance.trackRoomDirectorySearch(count, this.state.filterString); + } + this.nextBatch = data.next_batch; this.setState((s) => { s.publicRooms.push(...(data.chunk || [])); @@ -407,7 +415,7 @@ export default class RoomDirectory extends React.Component { }; onCreateRoomClick = room => { - this.props.onFinished(); + this.onFinished(); dis.dispatch({ action: 'view_create_room', public: true, @@ -419,11 +427,12 @@ export default class RoomDirectory extends React.Component { } showRoom(room, room_alias, autoJoin = false, shouldPeek = false) { - this.props.onFinished(); + this.onFinished(); const payload = { action: 'view_room', auto_join: autoJoin, should_peek: shouldPeek, + _type: "room_directory", // instrumentation }; if (room) { // Don't let the user view a room they won't be able to either @@ -575,6 +584,11 @@ export default class RoomDirectory extends React.Component { } }; + onFinished = () => { + CountlyAnalytics.instance.trackRoomDirectory(this.startTime); + this.props.onFinished(); + }; + render() { const Loader = sdk.getComponent("elements.Spinner"); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); @@ -693,7 +707,7 @@ export default class RoomDirectory extends React.Component {
diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 372186ff38..e29daadd8e 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -129,6 +129,7 @@ export interface IState { initialEventPixelOffset?: number; // Whether to highlight the event scrolled to isInitialEventHighlighted?: boolean; + replyToEvent?: MatrixEvent; forwardingEvent?: MatrixEvent; numUnreadMessages: number; draggingFile: boolean; @@ -315,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(), @@ -1111,6 +1113,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(); }); @@ -1899,6 +1902,7 @@ export default class RoomView extends React.Component { showApps={this.state.showApps} e2eStatus={this.state.e2eStatus} resizeNotifier={this.props.resizeNotifier} + replyToEvent={this.state.replyToEvent} permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)} />; } diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 64ee94628e..4847d41fa8 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"; @@ -186,7 +186,7 @@ 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 }; diff --git a/src/components/structures/auth/ForgotPassword.js b/src/components/structures/auth/ForgotPassword.js index 3fa2713a35..54d4b5de83 100644 --- a/src/components/structures/auth/ForgotPassword.js +++ b/src/components/structures/auth/ForgotPassword.js @@ -26,6 +26,7 @@ import PasswordReset from "../../../PasswordReset"; import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; import classNames from 'classnames'; import AuthPage from "../../views/auth/AuthPage"; +import CountlyAnalytics from "../../../CountlyAnalytics"; // Phases // Show controls to configure server details @@ -64,6 +65,12 @@ export default class ForgotPassword extends React.Component { serverRequiresIdServer: null, }; + constructor(props) { + super(props); + + CountlyAnalytics.instance.track("onboarding_forgot_password_begin"); + } + componentDidMount() { this.reset = null; this._checkServerLiveliness(this.props.serverConfig); @@ -299,6 +306,8 @@ export default class ForgotPassword extends React.Component { value={this.state.email} onChange={this.onInputChanged.bind(this, "email")} autoFocus + onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_focus")} + onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_blur")} />
@@ -308,6 +317,8 @@ export default class ForgotPassword extends React.Component { label={_t('Password')} value={this.state.password} onChange={this.onInputChanged.bind(this, "password")} + onFocus={() => 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/views/auth/CaptchaForm.js b/src/components/views/auth/CaptchaForm.js index 783d519621..5cce93f0b8 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,12 @@ export default class CaptchaForm extends React.Component { console.log("Loaded recaptcha script."); try { this._renderRecaptcha(DIV_ID); + 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/InteractiveAuthEntryComponents.js b/src/components/views/auth/InteractiveAuthEntryComponents.js index 47263c1e21..f49e6959fb 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() { 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..db7d1df994 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'; @@ -77,6 +78,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 +89,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 +114,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 +134,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(), @@ -422,6 +433,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 +446,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")} />; } @@ -447,6 +462,8 @@ export default class RegistrationForm extends React.Component { 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,6 +504,8 @@ 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")} />; } 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/dialogs/FeedbackDialog.js b/src/components/views/dialogs/FeedbackDialog.js new file mode 100644 index 0000000000..2515377709 --- /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..fc3245aa18 100644 --- a/src/components/views/dialogs/InviteDialog.js +++ b/src/components/views/dialogs/InviteDialog.js @@ -40,6 +40,7 @@ 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"; // we have a number of types defined from the Matrix spec which can't reasonably be altered here. /* eslint-disable camelcase */ @@ -325,6 +326,8 @@ 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 = { @@ -627,6 +630,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 +647,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(); } diff --git a/src/components/views/dialogs/ModalWidgetDialog.tsx b/src/components/views/dialogs/ModalWidgetDialog.tsx new file mode 100644 index 0000000000..6ce3230a7a --- /dev/null +++ b/src/components/views/dialogs/ModalWidgetDialog.tsx @@ -0,0 +1,165 @@ +/* +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 * as React from 'react'; +import BaseDialog from './BaseDialog'; +import { _t } from '../../../languageHandler'; +import AccessibleButton from "../elements/AccessibleButton"; +import { + ClientWidgetApi, + IModalWidgetCloseRequest, + IModalWidgetOpenRequestData, + IModalWidgetReturnData, + ModalButtonKind, + Widget, + WidgetApiFromWidgetAction, +} from "matrix-widget-api"; +import {StopGapWidgetDriver} from "../../../stores/widgets/StopGapWidgetDriver"; +import {MatrixClientPeg} from "../../../MatrixClientPeg"; +import RoomViewStore from "../../../stores/RoomViewStore"; +import {OwnProfileStore} from "../../../stores/OwnProfileStore"; + +interface IProps { + widgetDefinition: IModalWidgetOpenRequestData; + sourceWidgetId: string; + onFinished(success: boolean, data?: IModalWidgetReturnData): void; +} + +interface IState { + messaging?: ClientWidgetApi; +} + +const MAX_BUTTONS = 3; + +export default class ModalWidgetDialog extends React.PureComponent { + private readonly widget: Widget; + private appFrame: React.RefObject = React.createRef(); + + state: IState = {}; + + constructor(props) { + super(props); + + this.widget = new Widget({ + ...this.props.widgetDefinition, + creatorUserId: MatrixClientPeg.get().getUserId(), + id: `modal_${this.props.sourceWidgetId}`, + }); + } + + public componentDidMount() { + const driver = new StopGapWidgetDriver( []); + const messaging = new ClientWidgetApi(this.widget, this.appFrame.current, driver); + this.setState({messaging}); + } + + public componentWillUnmount() { + this.state.messaging.off("ready", this.onReady); + this.state.messaging.off(`action:${WidgetApiFromWidgetAction.CloseModalWidget}`, this.onWidgetClose); + this.state.messaging.stop(); + } + + private onReady = () => { + this.state.messaging.sendWidgetConfig(this.props.widgetDefinition); + }; + + private onLoad = () => { + this.state.messaging.once("ready", this.onReady); + this.state.messaging.on(`action:${WidgetApiFromWidgetAction.CloseModalWidget}`, this.onWidgetClose); + }; + + private onWidgetClose = (ev: CustomEvent) => { + this.props.onFinished(true, ev.detail.data); + } + + public render() { + const templated = this.widget.getCompleteUrl({ + currentRoomId: RoomViewStore.getRoomId(), + currentUserId: MatrixClientPeg.get().getUserId(), + userDisplayName: OwnProfileStore.instance.displayName, + userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(), + }); + + const parsed = new URL(templated); + + // Add in some legacy support sprinkles (for non-popout widgets) + // TODO: Replace these with proper widget params + // See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833 + parsed.searchParams.set('widgetId', this.widget.id); + parsed.searchParams.set('parentUrl', window.location.href.split('#', 2)[0]); + + // Replace the encoded dollar signs back to dollar signs. They have no special meaning + // in HTTP, but URL parsers encode them anyways. + const widgetUrl = parsed.toString().replace(/%24/g, '$'); + + let buttons; + if (this.props.widgetDefinition.buttons) { + // show first button rightmost for a more natural specification + buttons = this.props.widgetDefinition.buttons.slice(0, MAX_BUTTONS).reverse().map(def => { + let kind = "secondary"; + switch (def.kind) { + case ModalButtonKind.Primary: + kind = "primary"; + break; + case ModalButtonKind.Secondary: + kind = "primary_outline"; + break + case ModalButtonKind.Danger: + kind = "danger"; + break; + } + + const onClick = () => { + this.state.messaging.notifyModalWidgetButtonClicked(def.id); + }; + + return + { def.label } + ; + }); + } + + return +
+ + {_t("Data on this screen is shared with %(widgetDomain)s", { + widgetDomain: parsed.hostname, + })} +
+
+