diff --git a/res/css/_components.scss b/res/css/_components.scss index 657d77974f..37d0e0d286 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -9,6 +9,7 @@ @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"; @@ -27,7 +28,6 @@ @import "./structures/_ScrollPanel.scss"; @import "./structures/_SearchBox.scss"; @import "./structures/_TabbedView.scss"; -@import "./structures/_GroupFilterPanel.scss"; @import "./structures/_ToastContainer.scss"; @import "./structures/_UploadBar.scss"; @import "./structures/_UserMenu.scss"; @@ -70,6 +70,7 @@ @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"; 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/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/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 161aa3797f..4d77fc42c4 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -33,6 +33,7 @@ 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"; @@ -60,6 +61,7 @@ declare global { mxWidgetStore: WidgetStore; mxCallHandler: CallHandler; mxAnalytics: Analytics; + mxCountlyAnalytics: typeof CountlyAnalytics; mxUserActivity: UserActivity; mxModalWidgetStore: ModalWidgetStore; } @@ -96,4 +98,13 @@ declare global { interface HTMLAudioElement { type?: string; } + + 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 e303dd3819..f3ce4ac679 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -77,8 +77,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/lib/webrtc/call"; import Analytics from './Analytics'; +import CountlyAnalytics from "./CountlyAnalytics"; enum AudioID { Ring = 'ringAudio', @@ -341,6 +342,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); @@ -419,6 +421,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': @@ -462,16 +465,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..2b6ac08cbd --- /dev/null +++ b/src/CountlyAnalytics.ts @@ -0,0 +1,951 @@ +/* +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; +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 { origin, hash } = window.location; + let { pathname } = window.location; + + // Redact paths which could contain unexpected PII + if (origin.startsWith('file://')) { + pathname = "//"; + } + + 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 class CountlyAnalytics { + private baseUrl: URL = null; + private appKey: string = null; + private userKey: string = null; + private firstPage = true; + private heartbeatIntervalID: number = null; + + private anonymous = true; + private pendingEvents: IEvent[] = []; + + private appPlatform: string; + private appVersion = "unknown"; + private initTime = CountlyAnalytics.getTimestamp(); + + 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 (merge) { + 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 && this.anonymous !== anonymous) { + this.anonymous = anonymous; + if (anonymous) { + await this.changeUserKey(randomString(64)) + } else { + await this.changeUserKey(await hashHex(MatrixClientPeg.get().getUserId()), true); + } + return; + } + + const config = SdkConfig.get(); + + this.baseUrl = new URL("/i", config.countly.url); + this.appKey = config.countly.appKey; + + this.anonymous = anonymous; + if (this.anonymous) { + this.userKey = randomString(64); + } else { + this.userKey = await hashHex(MatrixClientPeg.get().getUserId()); + } + + 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 = window.setInterval(this.heartbeat.bind(this), HEARTBEAT_INTERVAL); + this.trackSessions(); // TODO clear on disable + this.trackErrors(); // TODO clear on disable + } + + /** + * Disable Analytics, stop the heartbeat and clear identifiers from localStorage + */ + public disable() { + if (this.disabled) return; + this.queue({ key: "Opt-Out" }); + window.clearInterval(this.heartbeatIntervalID); + this.baseUrl = null; + } + + 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; + + if (typeof generationTimeMs !== 'number') { + console.warn('Analytics.trackPageChange: expected generationTimeMs to be a number'); + // But continue anyway because we still want to track the change + } + + // 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) 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: metrics._app_version, + _run: CountlyAnalytics.getTimestamp() - this.initTime, + _nonfatal: !fatal, + _view: this.lastView, + }; + + if (typeof navigator.onLine !== "undefined") { + ob._online = navigator.onLine; + } + + ob._background = document.hasFocus(); + + // if (crashLogs.length > 0) { + // ob._logs = crashLogs.join("\n"); + // } + // crashLogs = []; + + 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 trackTime = true; + private lastBeat: number; + private storedDuration = 0; + private lastView: string; + private lastViewTime = 0; + private lastViewStoredDuration = 0; + + 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 { + 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 sessionStarted = false; + private heartbeatEnabled = false; + + 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, + }, + }; + + this.request({ + begin_session: 1, + metrics: JSON.stringify(this.getMetrics()), + user_details: JSON.stringify(userDetails), + }); + } + } + + 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) + + const sec = CountlyAnalytics.getTimestamp() - this.lastBeat; + this.reportViewDuration(); + this.request({ end_session: 1, session_duration: sec }); + } + 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 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); + + 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) { + this.request({ device_id: randomString(64) }); + } + } +} + +window.mxCountlyAnalytics = CountlyAnalytics; +export default CountlyAnalytics; diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index a6481d5b95..e94cf7a37c 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -517,6 +517,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 +532,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 +557,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/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 21c7674f6e..a738a95fb5 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"; @@ -349,6 +350,9 @@ export default class MatrixChat extends React.PureComponent { if (SettingsStore.getValue("analyticsOptIn")) { Analytics.enable(); + CountlyAnalytics.instance.enable(false); + } else { + CountlyAnalytics.instance.enable(true); } } @@ -364,6 +368,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); @@ -416,6 +421,8 @@ export default class MatrixChat extends React.PureComponent { } else { dis.dispatch({action: "view_welcome_page"}); } + } else if (SettingsStore.getValue("analyticsOptIn")) { + CountlyAnalytics.instance.enable(false); } }); // Note we don't catch errors from this: we catch everything within @@ -751,7 +758,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(false); + } break; case 'reject_cookies': SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, false); @@ -1201,7 +1213,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); } } @@ -1582,6 +1596,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 4b2fa67c1c..3ed3b0097f 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -1111,6 +1111,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(); }); 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/QuestionDialog.js b/src/components/views/dialogs/QuestionDialog.js index d6de60195f..3d90236b08 100644 --- a/src/components/views/dialogs/QuestionDialog.js +++ b/src/components/views/dialogs/QuestionDialog.js @@ -17,6 +17,8 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; +import classNames from "classnames"; + import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; @@ -26,12 +28,14 @@ export default class QuestionDialog extends React.Component { description: PropTypes.node, extraButtons: PropTypes.node, button: PropTypes.string, + buttonDisabled: PropTypes.bool, danger: PropTypes.bool, focus: PropTypes.bool, onFinished: PropTypes.func.isRequired, headerImage: PropTypes.string, quitOnly: PropTypes.bool, // quitOnly doesn't show the cancel button just the quit [x]. fixedWidth: PropTypes.bool, + className: PropTypes.string, }; static defaultProps = { @@ -61,7 +65,7 @@ export default class QuestionDialog extends React.Component { } return ( { - 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"; - - const description1 = - _t("If you run into any bugs or have feedback you'd like to share, " + - "please let us know on GitHub."); - const description2 = _t("To help avoid duplicate issues, " + - "please view existing issues " + - "first (and add a +1) or create a new issue " + - "if you can't find it.", {}, - { - existingIssuesLink: (sub) => { - return { sub }; - }, - newIssueLink: (sub) => { - return { sub }; - }, - }); - - return (

{description1}

{description2}

} - button={_t("Go back")} - onFinished={props.onFinished} - />); -}; diff --git a/src/components/views/rooms/EditMessageComposer.js b/src/components/views/rooms/EditMessageComposer.js index 78c7de887d..6fc1d3e1ad 100644 --- a/src/components/views/rooms/EditMessageComposer.js +++ b/src/components/views/rooms/EditMessageComposer.js @@ -32,6 +32,7 @@ import BasicMessageComposer from "./BasicMessageComposer"; import {Key} from "../../../Keyboard"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {Action} from "../../../dispatcher/actions"; +import CountlyAnalytics from "../../../CountlyAnalytics"; function _isReply(mxEvent) { const relatesTo = mxEvent.getContent()["m.relates_to"]; @@ -182,6 +183,7 @@ export default class EditMessageComposer extends React.Component { } _sendEdit = () => { + const startTime = CountlyAnalytics.getTimestamp(); const editedEvent = this.props.editState.getEvent(); const editContent = createEditContent(this.model, editedEvent); const newContent = editContent["m.new_content"]; @@ -190,8 +192,9 @@ export default class EditMessageComposer extends React.Component { if (this._isContentModified(newContent)) { const roomId = editedEvent.getRoomId(); this._cancelPreviousPendingEdit(); - this.context.sendMessage(roomId, editContent); + const prom = this.context.sendMessage(roomId, editContent); dis.dispatch({action: "message_sent"}); + CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent); } // close the event editing and focus composer diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 40eaf77272..3e2c15ab35 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -371,6 +371,7 @@ export default class MessageComposer extends React.Component { event_id: createEventId, room_id: replacementRoomId, auto_join: true, + _type: "tombstone", // instrumentation // Try to join via the server that sent the event. This converts @something:example.org // into a server domain by splitting on colons and ignoring the first entry ("@something"). diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 4828277d8a..9438cceef5 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -42,6 +42,7 @@ import {Key} from "../../../Keyboard"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import RateLimitedFunc from '../../../ratelimitedfunc'; import {Action} from "../../../dispatcher/actions"; +import CountlyAnalytics from "../../../CountlyAnalytics"; function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) { const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent); @@ -304,9 +305,10 @@ export default class SendMessageComposer extends React.Component { const replyToEvent = this.props.replyToEvent; if (shouldSend) { + const startTime = CountlyAnalytics.getTimestamp(); const {roomId} = this.props.room; const content = createMessageContent(this.model, this.props.permalinkCreator, replyToEvent); - this.context.sendMessage(roomId, content); + const prom = this.context.sendMessage(roomId, content); if (replyToEvent) { // Clear reply_to_event as we put the message into the queue // if the send fails, retry will handle resending. @@ -316,6 +318,7 @@ export default class SendMessageComposer extends React.Component { }); } dis.dispatch({action: "message_sent"}); + CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content); } this.sendHistoryManager.save(this.model, replyToEvent); diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js index 61402e8881..a0c7a6f3d0 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js @@ -33,6 +33,7 @@ import SecureBackupPanel from "../../SecureBackupPanel"; import SettingsStore from "../../../../../settings/SettingsStore"; import {UIFeature} from "../../../../../settings/UIFeature"; import {isE2eAdvancedPanelPossible} from "../../E2eAdvancedPanel"; +import CountlyAnalytics from "../../../../../CountlyAnalytics"; export class IgnoredUser extends React.Component { static propTypes = { @@ -102,6 +103,7 @@ export default class SecurityUserSettingsTab extends React.Component { _updateAnalytics = (checked) => { checked ? Analytics.enable() : Analytics.disable(); + checked ? CountlyAnalytics.enable() : CountlyAnalytics.disable(); }; _onExportE2eKeysClicked = () => { @@ -339,7 +341,7 @@ export default class SecurityUserSettingsTab extends React.Component { } let privacySection; - if (Analytics.canEnable()) { + if (Analytics.canEnable() || CountlyAnalytics.canEnable()) { privacySection =
{_t("Privacy")}
diff --git a/src/createRoom.ts b/src/createRoom.ts index 34eb65df4e..a42fcc5e7b 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -28,6 +28,7 @@ import DMRoomMap from "./utils/DMRoomMap"; import {getAddressType} from "./UserAddress"; import { getE2EEWellKnown } from "./utils/WellKnownUtils"; import GroupStore from "./stores/GroupStore"; +import CountlyAnalytics from "./CountlyAnalytics"; // we define a number of interfaces which take their names from the js-sdk /* eslint-disable camelcase */ @@ -108,6 +109,8 @@ export default function createRoom(opts: IOpts): Promise { if (opts.guestAccess === undefined) opts.guestAccess = true; if (opts.encryption === undefined) opts.encryption = false; + const startTime = CountlyAnalytics.getTimestamp(); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const Loader = sdk.getComponent("elements.Spinner"); @@ -203,6 +206,7 @@ export default function createRoom(opts: IOpts): Promise { joining: true, }); } + CountlyAnalytics.instance.trackRoomCreate(startTime, roomId); return roomId; }, function(err) { // Raise the error if the caller requested that we do so. diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 1548dd5c13..01a6731562 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1699,6 +1699,18 @@ "There was an error updating your community. The server is unable to process your request.": "There was an error updating your community. The server is unable to process your request.", "Update community": "Update community", "An error has occurred.": "An error has occurred.", + "Feedback sent": "Feedback sent", + "Rate %(brand)s": "Rate %(brand)s", + "Tell us below how you feel about %(brand)s so far.": "Tell us below how you feel about %(brand)s so far.", + "Please go into as much detail as you like, so we can track down the problem.": "Please go into as much detail as you like, so we can track down the problem.", + "Add comment": "Add comment", + "Comment": "Comment", + "Feedback": "Feedback", + "There are two ways you can provide feedback and help us improve %(brand)s.": "There are two ways you can provide feedback and help us improve %(brand)s.", + "Report a bug": "Report a bug", + "Please view existing bugs on Github first. No match? Start a new one.": "Please view existing bugs on Github first. No match? Start a new one.", + "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.": "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.", + "Send feedback": "Send feedback", "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.": "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.", "Verifying this user will mark their session as trusted, and also mark your session as trusted to them.": "Verifying this user will mark their session as trusted, and also mark your session as trusted to them.", "Verify this device to mark it as trusted. Trusting this device gives you and other users extra peace of mind when using end-to-end encrypted messages.": "Verify this device to mark it as trusted. Trusting this device gives you and other users extra peace of mind when using end-to-end encrypted messages.", @@ -1771,9 +1783,6 @@ "Use this session to verify your new one, granting it access to encrypted messages:": "Use this session to verify your new one, granting it access to encrypted messages:", "If you didn’t sign in to this session, your account may be compromised.": "If you didn’t sign in to this session, your account may be compromised.", "This wasn't me": "This wasn't me", - "If you run into any bugs or have feedback you'd like to share, please let us know on GitHub.": "If you run into any bugs or have feedback you'd like to share, please let us know on GitHub.", - "To help avoid duplicate issues, please view existing issues first (and add a +1) or create a new issue if you can't find it.": "To help avoid duplicate issues, please view existing issues first (and add a +1) or create a new issue if you can't find it.", - "Report bugs & give feedback": "Report bugs & give feedback", "Please fill why you're reporting.": "Please fill why you're reporting.", "Report Content to Your Homeserver Administrator": "Report Content to Your Homeserver Administrator", "Reporting this message will send its unique 'event ID' to the administrator of your homeserver. If messages in this room are encrypted, your homeserver administrator will not be able to read the message text or view any files or images.": "Reporting this message will send its unique 'event ID' to the administrator of your homeserver. If messages in this room are encrypted, your homeserver administrator will not be able to read the message text or view any files or images.", @@ -2132,7 +2141,6 @@ "Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s", "Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other", "Failed to find the general chat for this community": "Failed to find the general chat for this community", - "Feedback": "Feedback", "Notification settings": "Notification settings", "Security & privacy": "Security & privacy", "All settings": "All settings", diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index be1141fa1e..f4c0c1b15c 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -28,6 +28,7 @@ import { _t } from '../languageHandler'; import { getCachedRoomIDForAlias, storeRoomAliasInCache } from '../RoomAliasCache'; import {ActionPayload} from "../dispatcher/payloads"; import {retry} from "../utils/promise"; +import CountlyAnalytics from "../CountlyAnalytics"; const NUM_JOIN_RETRY = 5; @@ -264,6 +265,7 @@ class RoomViewStore extends Store { } private async joinRoom(payload: ActionPayload) { + const startTime = CountlyAnalytics.getTimestamp(); this.setState({ joining: true, }); @@ -275,6 +277,7 @@ class RoomViewStore extends Store { // if we received a Gateway timeout then retry return err.httpStatus === 504; }); + CountlyAnalytics.instance.trackRoomJoin(startTime, this.state.roomId, payload._type); // We do *not* clear the 'joining' flag because the Room object and/or our 'joined' member event may not // have come down the sync stream yet, and that's the point at which we'd consider the user joined to the diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index e5953d739d..4e67416d92 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -53,6 +53,7 @@ import WidgetOpenIDPermissionsDialog from "../../components/views/dialogs/Widget import {ModalWidgetStore} from "../ModalWidgetStore"; import ThemeWatcher from "../../settings/watchers/ThemeWatcher"; import {getCustomTheme} from "../../theme"; +import CountlyAnalytics from "../../CountlyAnalytics"; // TODO: Destroy all of this code @@ -301,6 +302,7 @@ export class StopGapWidget extends EventEmitter { this.messaging.on("action:set_always_on_screen", (ev: CustomEvent) => { if (this.messaging.hasCapability(MatrixCapabilities.AlwaysOnScreen)) { + CountlyAnalytics.instance.trackJoinCall(this.appTileProps.room.roomId, true, true); ActiveWidgetStore.setWidgetPersistence(this.mockWidget.id, ev.detail.data.value); ev.preventDefault(); this.messaging.transport.reply(ev.detail, {}); // ack