Initial Countly work

This commit is contained in:
Michael Telatynski 2020-10-29 15:53:14 +00:00
parent 557d9ad90c
commit c3a355097d
33 changed files with 1416 additions and 72 deletions

View file

@ -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";

View file

@ -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');
}
}
}

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.283 21.4392C17.649 21.4392 21.9991 17.0875 21.9991 11.7196C21.9991 6.3516 17.649 2 12.283 2C6.91698 2 2.56696 6.3516 2.56696 11.7196C2.56696 13.2233 2.90831 14.6472 3.51772 15.9181L2.04565 20.7041C1.80906 21.4733 2.53172 22.1926 3.29983 21.9525L8.04605 20.4688C9.32655 21.0905 10.7641 21.4392 12.283 21.4392Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 481 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C9.23858 2 7 4.23858 7 7L17 7C17 4.23858 14.7614 2 12 2ZM2.29289 7.70711C1.90237 7.31658 1.90237 6.68342 2.29289 6.29289C2.68342 5.90237 3.31658 5.90237 3.70711 6.29289L6.41421 9H17.5858L20.2929 6.29289C20.6834 5.90237 21.3166 5.90237 21.7071 6.29289C22.0976 6.68342 22.0976 7.31658 21.7071 7.70711L19 10.4142V13H22C22.5523 13 23 13.4477 23 14C23 14.5523 22.5523 15 22 15H19C19 15.7795 18.8726 16.5292 18.6375 17.2295C18.6614 17.2493 18.6847 17.2705 18.7071 17.2929L21.7071 20.2929C22.0976 20.6834 22.0976 21.3166 21.7071 21.7071C21.3166 22.0976 20.6834 22.0976 20.2929 21.7071L17.6791 19.0933C16.5924 20.5983 14.9222 21.6542 13 21.9291L13 12C13 11.4477 12.5523 11 12 11C11.4477 11 11 11.4477 11 12V21.9291C9.07785 21.6542 7.40759 20.5983 6.32091 19.0933L3.70711 21.7071C3.31658 22.0976 2.68342 22.0976 2.29289 21.7071C1.90237 21.3166 1.90237 20.6834 2.29289 20.2929L5.29289 17.2929C5.31533 17.2705 5.33857 17.2493 5.36252 17.2295C5.1274 16.5292 5 15.7795 5 15H2C1.44772 15 1 14.5523 1 14C1 13.4477 1.44772 13 2 13H5V10.4142L2.29289 7.70711Z" fill="#FF4B55"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -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;
}
}

View file

@ -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;
}
}
}

View file

@ -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<any>) {
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) {

951
src/CountlyAnalytics.ts Normal file
View file

@ -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<string, unknown>;
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<string, Value | Operation>; // `.` 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<string, string>;
}
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<string, Value> {
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<string> => {
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<string, string>;
}
// Apply fn to all hash path parts after the 1st one
async function getViewData(anonymous = true): Promise<IViewData> {
const { origin, hash } = window.location;
let { pathname } = window.location;
// Redact paths which could contain unexpected PII
if (origin.startsWith('file://')) {
pathname = "/<redacted>/";
}
let [_, screen, ...parts] = hash.split("/");
if (!knownScreens.has(screen)) {
screen = "<redacted>";
}
for (let i = 0; i < parts.length; i++) {
parts[i] = anonymous ? "<redacted>" : 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<IStarRatingEvent>("[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<IViewEvent>("[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<IParams, "session_duration"> = {};
// 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<IParams, "app_key" | "device_id" | "timestamp" | "hour" | "dow">
& Partial<Pick<IParams, "device_id">> = {},
) {
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<IParams, "timestamp" | "hour" | "dow"> {
const date = new Date();
return {
timestamp: this.getMsTimestamp(),
hour: date.getHours(),
dow: date.getDay(),
};
}
private queue(args: Omit<IEvent, "timestamp" | "hour" | "dow" | "count"> & Partial<Pick<IEvent, "count">>) {
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<IOrientationEvent>("[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<IViewEvent>("[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<IBeginInviteEvent>("begin_invite", {}, roomId);
}
public trackSendInvite(startTime: number, roomId: string, qty: number) {
this.track<ISendInviteEvent>("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<ICreateRoomEvent>("create_room", {}, roomId, {
dur: endTime - startTime,
});
}
public trackRoomJoin(startTime: number, roomId: string, type: IJoinRoomEvent["segmentation"]["type"]) {
this.track<IJoinRoomEvent>("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<ISendMessageEvent>("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<IStartCallEvent>("start_call", {
is_video: isVideo,
is_jitsi: isJitsi,
}, roomId);
}
public trackJoinCall(roomId: string, isVideo = false, isJitsi = false) {
this.track<IJoinCallEvent>("join_call", {
is_video: isVideo,
is_jitsi: isJitsi,
}, roomId);
}
public trackRoomDirectory(startTime: number) {
this.track<IRoomDirectoryEvent>("room_directory", {}, null, {
dur: CountlyAnalytics.getTimestamp() - startTime,
});
}
public trackRoomDirectorySearch(numResults: number, query: string) {
this.track<IRoomDirectorySearchEvent>("room_directory_search", {
query_length: query.length,
query_num_words: query.split(" ").length,
}, null, {
sum: numResults,
});
}
public async track<E extends IEvent>(
key: E["key"],
segments?: Omit<E["segmentation"], "room_id" | "num_users" | "is_encrypted" | "is_public">,
roomId?: string,
args?: Partial<Pick<E, "dur" | "sum">>,
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;

View file

@ -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;

View file

@ -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<IProps, IState> {
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<IProps, IState> {
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<IProps, IState> {
} 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<IProps, IState> {
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<IProps, IState> {
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<IProps, IState> {
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

View file

@ -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 {
<BaseDialog
className={'mx_RoomDirectory_dialog'}
hasCancel={true}
onFinished={this.props.onFinished}
onFinished={this.onFinished}
title={title}
>
<div className="mx_RoomDirectory">

View file

@ -1111,6 +1111,7 @@ export default class RoomView extends React.Component<IProps, IState> {
dis.dispatch({
action: 'join_room',
opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers },
_type: "unknown", // TODO: instrumentation
});
return Promise.resolve();
});

View file

@ -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<IProps, IState> {
ev.preventDefault();
ev.stopPropagation();
Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog);
Modal.createTrackedDialog('Feedback Dialog', '', FeedbackDialog);
this.setState({contextMenuPosition: null}); // also close the menu
};

View file

@ -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")}
/>
</div>
<div className="mx_AuthBody_fieldRow">
@ -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")}
/>
<Field
name="reset_password_confirm"
@ -315,6 +326,8 @@ export default class ForgotPassword extends React.Component {
label={_t('Confirm')}
value={this.state.password2}
onChange={this.onInputChanged.bind(this, "password2")}
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_blur")}
/>
</div>
<span>{_t(

View file

@ -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

View file

@ -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() });
}
}

View file

@ -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() {

View file

@ -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}

View file

@ -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")}
/>;
}

View file

@ -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

View file

@ -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');

View file

@ -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 = <React.Fragment>
<hr />
<div className="mx_FeedbackDialog_section mx_FeedbackDialog_rateApp">
<h3>{_t("Rate %(brand)s", { brand })}</h3>
<p>{_t("Tell us below how you feel about %(brand)s so far.", { brand })}</p>
<p>{_t("Please go into as much detail as you like, so we can track down the problem.")}</p>
<StyledRadioGroup
name="feedbackRating"
value={rating}
onChange={setRating}
definitions={[
{ value: "1", label: "😠" },
{ value: "2", label: "😞" },
{ value: "3", label: "😑" },
{ value: "4", label: "😄" },
{ value: "5", label: "😍" },
]}
/>
<Field
id="feedbackComment"
label={_t("Add comment")}
placeholder={_t("Comment")}
type="text"
autoComplete="off"
value={comment}
element="textarea"
onChange={(ev) => {
setComment(ev.target.value);
}}
/>
</div>
</React.Fragment>;
}
let subheading;
if (hasFeedback) {
subheading = (
<h2>{_t("There are two ways you can provide feedback and help us improve %(brand)s.", { brand })}</h2>
);
}
return (<QuestionDialog
className="mx_FeedbackDialog"
hasCancelButton={!!hasFeedback}
title={_t("Feedback")}
description={<React.Fragment>
{ subheading }
<div className="mx_FeedbackDialog_section mx_FeedbackDialog_reportBug">
<h3>{_t("Report a bug")}</h3>
<p>{
_t("Please view <existingIssuesLink>existing bugs on Github</existingIssuesLink> first. " +
"No match? <newIssueLink>Start a new one</newIssueLink>.", {}, {
existingIssuesLink: (sub) => {
return <a target="_blank" rel="noreferrer noopener" href={existingIssuesUrl}>{ sub }</a>;
},
newIssueLink: (sub) => {
return <a target="_blank" rel="noreferrer noopener" href={newIssueUrl}>{ sub }</a>;
},
})
}</p>
<p>{
_t("PRO TIP: If you start a bug, please submit <debugLogsLink>debug logs</debugLogsLink> " +
"to help us track down the problem.", {}, {
debugLogsLink: sub => (
<AccessibleButton kind="link" onClick={onDebugLogsLinkClick}>{sub}</AccessibleButton>
),
})
}</p>
</div>
{ countlyFeedbackSection }
</React.Fragment>}
button={hasFeedback ? _t("Send feedback") : _t("Go back")}
buttonDisabled={hasFeedback && rating === ""}
onFinished={onFinished}
/>);
};

View file

@ -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();
}

View file

@ -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 (
<BaseDialog
className="mx_QuestionDialog"
className={classNames("mx_QuestionDialog", this.props.className)}
onFinished={this.props.onFinished}
title={this.props.title}
contentId='mx_Dialog_content'
@ -74,6 +78,7 @@ export default class QuestionDialog extends React.Component {
</div>
<DialogButtons primaryButton={this.props.button || _t('OK')}
primaryButtonClass={primaryButtonClass}
primaryDisabled={this.props.buttonDisabled}
cancelButton={this.props.cancelButton}
hasCancel={this.props.hasCancelButton && !this.props.quitOnly}
onPrimaryButtonClick={this.onOk}

View file

@ -1,49 +0,0 @@
/*
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 from 'react';
import QuestionDialog from './QuestionDialog';
import { _t } from '../../../languageHandler';
export default (props) => {
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 <existingIssuesLink>view existing issues</existingIssuesLink> " +
"first (and add a +1) or <newIssueLink>create a new issue</newIssueLink> " +
"if you can't find it.", {},
{
existingIssuesLink: (sub) => {
return <a target="_blank" rel="noreferrer noopener" href={existingIssuesUrl}>{ sub }</a>;
},
newIssueLink: (sub) => {
return <a target="_blank" rel="noreferrer noopener" href={newIssueUrl}>{ sub }</a>;
},
});
return (<QuestionDialog
hasCancelButton={false}
title={_t("Report bugs & give feedback")}
description={<div><p>{description1}</p><p>{description2}</p></div>}
button={_t("Go back")}
onFinished={props.onFinished}
/>);
};

View file

@ -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

View file

@ -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").

View file

@ -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);

View file

@ -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 = <React.Fragment>
<div className="mx_SettingsTab_heading">{_t("Privacy")}</div>
<div className="mx_SettingsTab_section">

View file

@ -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<string | null> {
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<string | null> {
joining: true,
});
}
CountlyAnalytics.instance.trackRoomCreate(startTime, roomId);
return roomId;
}, function(err) {
// Raise the error if the caller requested that we do so.

View file

@ -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 <existingIssuesLink>existing bugs on Github</existingIssuesLink> first. No match? <newIssueLink>Start a new one</newIssueLink>.": "Please view <existingIssuesLink>existing bugs on Github</existingIssuesLink> first. No match? <newIssueLink>Start a new one</newIssueLink>.",
"PRO TIP: If you start a bug, please submit <debugLogsLink>debug logs</debugLogsLink> to help us track down the problem.": "PRO TIP: If you start a bug, please submit <debugLogsLink>debug logs</debugLogsLink> 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 didnt sign in to this session, your account may be compromised.": "If you didnt 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 <existingIssuesLink>view existing issues</existingIssuesLink> first (and add a +1) or <newIssueLink>create a new issue</newIssueLink> if you can't find it.": "To help avoid duplicate issues, please <existingIssuesLink>view existing issues</existingIssuesLink> first (and add a +1) or <newIssueLink>create a new issue</newIssueLink> 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",

View file

@ -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<ActionPayload> {
}
private async joinRoom(payload: ActionPayload) {
const startTime = CountlyAnalytics.getTimestamp();
this.setState({
joining: true,
});
@ -275,6 +277,7 @@ class RoomViewStore extends Store<ActionPayload> {
// 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

View file

@ -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<IStickyActionRequest>) => {
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, <IWidgetApiRequestEmptyData>{}); // ack