Merge remote-tracking branch 'origin/develop' into dbkr/call_hold

This commit is contained in:
David Baker 2020-10-30 16:49:42 +00:00
commit 7796621e8d
52 changed files with 1995 additions and 144 deletions

View file

@ -1,3 +1,113 @@
Changes in [3.7.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.7.1) (2020-10-28)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.7.0...v3.7.1)
* Upgrade JS SDK to 9.0.1
* [Release] Fix theme variable passed to Jitsi
[\#5358](https://github.com/matrix-org/matrix-react-sdk/pull/5358)
* [Release] Widget fixes
[\#5351](https://github.com/matrix-org/matrix-react-sdk/pull/5351)
Changes in [3.7.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.7.0) (2020-10-26)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.7.0-rc.2...v3.7.0)
* Upgrade JS SDK to 9.0.0
Changes in [3.7.0-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.7.0-rc.2) (2020-10-21)
=============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.7.0-rc.1...v3.7.0-rc.2)
* Fix JS SDK dependency to use 9.0.0-rc.1 as intended
Changes in [3.7.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.7.0-rc.1) (2020-10-21)
=============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.6.1...v3.7.0-rc.1)
* Upgrade JS SDK to 9.0.0-rc.1
* Update Weblate URL
[\#5346](https://github.com/matrix-org/matrix-react-sdk/pull/5346)
* Translations update from Weblate
[\#5347](https://github.com/matrix-org/matrix-react-sdk/pull/5347)
* Left Panel Widget support
[\#5247](https://github.com/matrix-org/matrix-react-sdk/pull/5247)
* Pinned widgets work
[\#5266](https://github.com/matrix-org/matrix-react-sdk/pull/5266)
* Convert resizer to Typescript
[\#5343](https://github.com/matrix-org/matrix-react-sdk/pull/5343)
* Hide filtering microcopy when left panel is minimized
[\#5338](https://github.com/matrix-org/matrix-react-sdk/pull/5338)
* Skip editor confirmation of upgrades
[\#5344](https://github.com/matrix-org/matrix-react-sdk/pull/5344)
* Spec compliance, /search doesn't have to return results
[\#5337](https://github.com/matrix-org/matrix-react-sdk/pull/5337)
* Fix excessive hosting link padding
[\#5336](https://github.com/matrix-org/matrix-react-sdk/pull/5336)
* Adjust for new widget messaging APIs
[\#5341](https://github.com/matrix-org/matrix-react-sdk/pull/5341)
* Fix case where sublist context menu missed an update
[\#5339](https://github.com/matrix-org/matrix-react-sdk/pull/5339)
* Add analytics to VoIP
[\#5340](https://github.com/matrix-org/matrix-react-sdk/pull/5340)
* Fix Jitsi OpenIDC auth
[\#5334](https://github.com/matrix-org/matrix-react-sdk/pull/5334)
* Support rejecting calls
[\#5324](https://github.com/matrix-org/matrix-react-sdk/pull/5324)
* Don't show admin tooling if we're not in the room
[\#5330](https://github.com/matrix-org/matrix-react-sdk/pull/5330)
* Show Integrations error if iframe failed to load too
[\#5328](https://github.com/matrix-org/matrix-react-sdk/pull/5328)
* Add security customisation points
[\#5327](https://github.com/matrix-org/matrix-react-sdk/pull/5327)
* Discard all mx_fadable legacy cruft which is totally useless
[\#5326](https://github.com/matrix-org/matrix-react-sdk/pull/5326)
* Fix background-image: url(null) for backdrop filter
[\#5319](https://github.com/matrix-org/matrix-react-sdk/pull/5319)
* Make the ACL update message less noisy
[\#5316](https://github.com/matrix-org/matrix-react-sdk/pull/5316)
* Fix aspect ratio of avatar before clicking Save
[\#5318](https://github.com/matrix-org/matrix-react-sdk/pull/5318)
* Don't supply popout widgets with widget parameters
[\#5323](https://github.com/matrix-org/matrix-react-sdk/pull/5323)
* Changed rainbow algorithm
[\#5301](https://github.com/matrix-org/matrix-react-sdk/pull/5301)
* Renamed TagPanel and TagOrderStore
[\#5309](https://github.com/matrix-org/matrix-react-sdk/pull/5309)
* Fix/clarify boolean logic for reaction previews
[\#5321](https://github.com/matrix-org/matrix-react-sdk/pull/5321)
* Support glare for VoIP calls
[\#5311](https://github.com/matrix-org/matrix-react-sdk/pull/5311)
* Round of Typescript conversions
[\#5314](https://github.com/matrix-org/matrix-react-sdk/pull/5314)
* Fix broken rendering of Room Create when showHiddenEvents enabled
[\#5317](https://github.com/matrix-org/matrix-react-sdk/pull/5317)
* Improve LHS resize performance and tidy stale props&classes
[\#5313](https://github.com/matrix-org/matrix-react-sdk/pull/5313)
* event-index: Pass the user/device id pair when initializing the event index.
[\#5312](https://github.com/matrix-org/matrix-react-sdk/pull/5312)
* Fix various aspects of (jitsi) widgets
[\#5315](https://github.com/matrix-org/matrix-react-sdk/pull/5315)
* Fix rogue (partial) call bar
[\#5310](https://github.com/matrix-org/matrix-react-sdk/pull/5310)
* Rewrite call state machine
[\#5308](https://github.com/matrix-org/matrix-react-sdk/pull/5308)
* Convert `src/SecurityManager.js` to TypeScript
[\#5307](https://github.com/matrix-org/matrix-react-sdk/pull/5307)
* Fix templating for v1 jitsi widgets
[\#5305](https://github.com/matrix-org/matrix-react-sdk/pull/5305)
* Use new preparing event for widget communications
[\#5303](https://github.com/matrix-org/matrix-react-sdk/pull/5303)
* Fix parsing issue in event tile preview for appearance tab
[\#5302](https://github.com/matrix-org/matrix-react-sdk/pull/5302)
* Track replyToEvent along with Cider state & history
[\#5284](https://github.com/matrix-org/matrix-react-sdk/pull/5284)
* Roving Tab Index should not interfere with inputs
[\#5299](https://github.com/matrix-org/matrix-react-sdk/pull/5299)
* Visual tweaks from 2020-10-06 polishing
[\#5298](https://github.com/matrix-org/matrix-react-sdk/pull/5298)
* Convert auth lifecycle to TS, remove dead ILAG code
[\#5296](https://github.com/matrix-org/matrix-react-sdk/pull/5296)
Changes in [3.6.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.6.1) (2020-10-20) Changes in [3.6.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.6.1) (2020-10-20)
=================================================================================================== ===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.6.0...v3.6.1) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.6.0...v3.6.1)

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "3.6.1", "version": "3.7.1",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {

View file

@ -70,11 +70,13 @@
@import "./views/dialogs/_DeactivateAccountDialog.scss"; @import "./views/dialogs/_DeactivateAccountDialog.scss";
@import "./views/dialogs/_DevtoolsDialog.scss"; @import "./views/dialogs/_DevtoolsDialog.scss";
@import "./views/dialogs/_EditCommunityPrototypeDialog.scss"; @import "./views/dialogs/_EditCommunityPrototypeDialog.scss";
@import "./views/dialogs/_FeedbackDialog.scss";
@import "./views/dialogs/_GroupAddressPicker.scss"; @import "./views/dialogs/_GroupAddressPicker.scss";
@import "./views/dialogs/_IncomingSasDialog.scss"; @import "./views/dialogs/_IncomingSasDialog.scss";
@import "./views/dialogs/_InviteDialog.scss"; @import "./views/dialogs/_InviteDialog.scss";
@import "./views/dialogs/_KeyboardShortcutsDialog.scss"; @import "./views/dialogs/_KeyboardShortcutsDialog.scss";
@import "./views/dialogs/_MessageEditHistoryDialog.scss"; @import "./views/dialogs/_MessageEditHistoryDialog.scss";
@import "./views/dialogs/_ModalWidgetDialog.scss";
@import "./views/dialogs/_NewSessionReviewDialog.scss"; @import "./views/dialogs/_NewSessionReviewDialog.scss";
@import "./views/dialogs/_RoomSettingsDialog.scss"; @import "./views/dialogs/_RoomSettingsDialog.scss";
@import "./views/dialogs/_RoomSettingsDialogBridges.scss"; @import "./views/dialogs/_RoomSettingsDialogBridges.scss";

View file

@ -16,11 +16,6 @@ limitations under the License.
// TODO: Update design for custom tags to match new designs // TODO: Update design for custom tags to match new designs
.mx_LeftPanel_tagPanelContainer {
display: flex;
flex-direction: column;
}
.mx_CustomRoomTagPanel { .mx_CustomRoomTagPanel {
background-color: $groupFilterPanel-bg-color; background-color: $groupFilterPanel-bg-color;
max-height: 40vh; max-height: 40vh;

View file

@ -32,6 +32,7 @@ $groupFilterPanelWidth: 56px; // only applies in this file, used for calculation
// Create another flexbox so the GroupFilterPanel fills the container // Create another flexbox so the GroupFilterPanel fills the container
display: flex; display: flex;
flex-direction: column;
// GroupFilterPanel handles its own CSS // GroupFilterPanel handles its own CSS
} }

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,42 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_ModalWidgetDialog {
.mx_ModalWidgetDialog_warning {
margin-bottom: 24px;
> img {
vertical-align: middle;
margin-right: 8px;
}
}
.mx_ModalWidgetDialog_buttons {
float: right;
margin-top: 24px;
.mx_AccessibleButton + .mx_AccessibleButton {
margin-left: 8px;
}
}
iframe {
width: 100%;
height: 450px;
border: 0;
border-radius: 8px;
}
}

View file

@ -25,7 +25,7 @@ limitations under the License.
.mx_AccessibleButton_hasKind { .mx_AccessibleButton_hasKind {
padding: 7px 18px; padding: 7px 18px;
text-align: center; text-align: center;
border-radius: 4px; border-radius: 8px;
display: inline-block; display: inline-block;
font-size: $font-14px; font-size: $font-14px;
} }

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,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="8" fill="#737D8C" style="mix-blend-mode:multiply"/>
<rect x="7" y="3" width="2" height="6" rx="1" fill="white"/>
<rect x="7" y="11" width="2" height="2" rx="1" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 303 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,7 +33,9 @@ import RightPanelStore from "../stores/RightPanelStore";
import WidgetStore from "../stores/WidgetStore"; import WidgetStore from "../stores/WidgetStore";
import CallHandler from "../CallHandler"; import CallHandler from "../CallHandler";
import {Analytics} from "../Analytics"; import {Analytics} from "../Analytics";
import CountlyAnalytics from "../CountlyAnalytics";
import UserActivity from "../UserActivity"; import UserActivity from "../UserActivity";
import {ModalWidgetStore} from "../stores/ModalWidgetStore";
declare global { declare global {
interface Window { interface Window {
@ -59,7 +61,9 @@ declare global {
mxWidgetStore: WidgetStore; mxWidgetStore: WidgetStore;
mxCallHandler: CallHandler; mxCallHandler: CallHandler;
mxAnalytics: Analytics; mxAnalytics: Analytics;
mxCountlyAnalytics: typeof CountlyAnalytics;
mxUserActivity: UserActivity; mxUserActivity: UserActivity;
mxModalWidgetStore: ModalWidgetStore;
} }
interface Document { interface Document {
@ -108,4 +112,13 @@ declare global {
webkitRequestFullScreen(options?: FullscreenOptions): Promise<void>; webkitRequestFullScreen(options?: FullscreenOptions): Promise<void>;
msRequestFullscreen(options?: FullscreenOptions): Promise<void>; msRequestFullscreen(options?: FullscreenOptions): Promise<void>;
} }
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

@ -76,8 +76,9 @@ import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import WidgetStore from "./stores/WidgetStore"; import WidgetStore from "./stores/WidgetStore";
import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore"; import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore";
import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions"; import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions";
import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty } from "matrix-js-sdk/src/webrtc/call"; import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty, CallType } from "matrix-js-sdk/src/webrtc/call";
import Analytics from './Analytics'; import Analytics from './Analytics';
import CountlyAnalytics from "./CountlyAnalytics";
enum AudioID { enum AudioID {
Ring = 'ringAudio', Ring = 'ringAudio',
@ -357,6 +358,7 @@ export default class CallHandler {
localElement: HTMLVideoElement, remoteElement: HTMLVideoElement, localElement: HTMLVideoElement, remoteElement: HTMLVideoElement,
) { ) {
Analytics.trackEvent('voip', 'placeCall', 'type', type); Analytics.trackEvent('voip', 'placeCall', 'type', type);
CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false);
const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), roomId); const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), roomId);
this.calls.set(roomId, call); this.calls.set(roomId, call);
this.setCallListeners(call); this.setCallListeners(call);
@ -437,6 +439,7 @@ export default class CallHandler {
case 'place_conference_call': case 'place_conference_call':
console.info("Place conference call in %s", payload.room_id); console.info("Place conference call in %s", payload.room_id);
Analytics.trackEvent('voip', 'placeConferenceCall'); Analytics.trackEvent('voip', 'placeConferenceCall');
CountlyAnalytics.instance.trackStartCall(payload.room_id, payload.type === PlaceCallType.Video, true);
this.startCallApp(payload.room_id, payload.type); this.startCallApp(payload.room_id, payload.type);
break; break;
case 'end_conference': case 'end_conference':
@ -481,11 +484,13 @@ export default class CallHandler {
} }
this.removeCallForRoom(payload.room_id); this.removeCallForRoom(payload.room_id);
break; break;
case 'answer': case 'answer': {
if (!this.calls.has(payload.room_id)) { if (!this.calls.has(payload.room_id)) {
return; // no call to answer 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({ dis.dispatch({
action: "view_room", action: "view_room",
room_id: payload.room_id, room_id: payload.room_id,
@ -493,6 +498,7 @@ export default class CallHandler {
break; break;
} }
} }
}
private async startCallApp(roomId: string, type: string) { private async startCallApp(roomId: string, type: string) {
dis.dispatch({ dis.dispatch({

View file

@ -31,6 +31,7 @@ import Spinner from "./components/views/elements/Spinner";
// Polyfill for Canvas.toBlob API using Canvas.toDataURL // Polyfill for Canvas.toBlob API using Canvas.toDataURL
import "blueimp-canvas-to-blob"; import "blueimp-canvas-to-blob";
import { Action } from "./dispatcher/actions"; import { Action } from "./dispatcher/actions";
import CountlyAnalytics from "./CountlyAnalytics";
const MAX_WIDTH = 800; const MAX_WIDTH = 800;
const MAX_HEIGHT = 600; const MAX_HEIGHT = 600;
@ -368,10 +369,13 @@ export default class ContentMessages {
private mediaConfig: IMediaConfig = null; private mediaConfig: IMediaConfig = null;
sendStickerContentToRoom(url: string, roomId: string, info: string, text: string, matrixClient: MatrixClient) { 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); console.warn(`Failed to send content with URL ${url} to room ${roomId}`, e);
throw e; throw e;
}); });
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, {msgtype: "m.sticker"});
return prom;
} }
getUploadLimit() { getUploadLimit() {
@ -479,6 +483,7 @@ export default class ContentMessages {
} }
private sendContentToRoom(file: File, roomId: string, matrixClient: MatrixClient, promBefore: Promise<any>) { private sendContentToRoom(file: File, roomId: string, matrixClient: MatrixClient, promBefore: Promise<any>) {
const startTime = CountlyAnalytics.getTimestamp();
const content: IContent = { const content: IContent = {
body: file.name || 'Attachment', body: file.name || 'Attachment',
info: { info: {
@ -563,7 +568,9 @@ export default class ContentMessages {
return promBefore; return promBefore;
}).then(function() { }).then(function() {
if (upload.canceled) throw new UploadCanceledError(); 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) { }, function(err) {
error = err; error = err;
if (!upload.canceled) { if (!upload.canceled) {

948
src/CountlyAnalytics.ts Normal file
View file

@ -0,0 +1,948 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {randomString} from "matrix-js-sdk/src/randomstring";
import {getCurrentLanguage} from './languageHandler';
import PlatformPeg from './PlatformPeg';
import SdkConfig from './SdkConfig';
import {MatrixClientPeg} from "./MatrixClientPeg";
import {sleep} from "./utils/promise";
import RoomViewStore from "./stores/RoomViewStore";
// polyfill textencoder if necessary
import * as TextEncodingUtf8 from 'text-encoding-utf-8';
let TextEncoder = window.TextEncoder;
if (!TextEncoder) {
TextEncoder = TextEncodingUtf8.TextEncoder;
}
const INACTIVITY_TIME = 20; // seconds
const HEARTBEAT_INTERVAL = 5_000; // ms
const SESSION_UPDATE_INTERVAL = 60; // seconds
const MAX_PENDING_EVENTS = 1000;
enum Orientation {
Landscape = "landscape",
Portrait = "portrait",
}
/* eslint-disable camelcase */
interface IMetrics {
_resolution?: string;
_app_version?: string;
_density?: number;
_ua?: string;
_locale?: string;
}
interface IEvent {
key: string;
count: number;
sum?: number;
dur?: number;
segmentation?: Record<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 rand = randomString(8);
const { origin, hash } = window.location;
let { pathname } = window.location;
// Redact paths which could contain unexpected PII
if (origin.startsWith('file://')) {
pathname = `/<redacted_${rand}>/`; // XXX: inject rand because Count.ly doesn't like X->X transitions
}
let [_, screen, ...parts] = hash.split("/");
if (!knownScreens.has(screen)) {
screen = `<redacted_${rand}>`;
}
for (let i = 0; i < parts.length; i++) {
parts[i] = anonymous ? `<redacted_${rand}>` : await hashHex(parts[i]);
}
const hashStr = `${_}/${screen}/${parts.join("/")}`;
const url = origin + pathname + hashStr;
const meta = {};
let name = "$/" + hash;
switch (screen) {
case "room": {
name = "view_room";
const roomId = RoomViewStore.getRoomId();
name += " " + parts[0]; // XXX: workaround Count.ly missing X->X transitions
meta["room_id"] = parts[0];
Object.assign(meta, getRoomStats(roomId));
break;
}
}
return { name, url, meta };
}
const getRoomStats = (roomId: string) => {
const cli = MatrixClientPeg.get();
const room = cli?.getRoom(roomId);
return {
"num_users": room?.getJoinedMemberCount(),
"is_encrypted": cli?.isRoomEncrypted(roomId),
// eslint-disable-next-line camelcase
"is_public": room?.currentState.getStateEvents("m.room.join_rules", "")?.getContent()?.join_rule === "public",
}
}
export default class CountlyAnalytics {
private baseUrl: URL = null;
private appKey: string = null;
private userKey: string = null;
private anonymous: boolean;
private appPlatform: string;
private appVersion = "unknown";
private initTime = CountlyAnalytics.getTimestamp();
private firstPage = true;
private heartbeatIntervalId: NodeJS.Timeout;
private activityIntervalId: NodeJS.Timeout;
private trackTime = true;
private lastBeat: number;
private storedDuration = 0;
private lastView: string;
private lastViewTime = 0;
private lastViewStoredDuration = 0;
private sessionStarted = false;
private heartbeatEnabled = false;
private inactivityCounter = 0;
private pendingEvents: IEvent[] = [];
private static internalInstance = new CountlyAnalytics();
public static get instance(): CountlyAnalytics {
return CountlyAnalytics.internalInstance;
}
public get disabled() {
return !this.baseUrl;
}
public canEnable() {
const config = SdkConfig.get();
return Boolean(navigator.doNotTrack !== "1" && config?.countly?.url && config?.countly?.appKey);
}
private async changeUserKey(userKey: string, merge = false) {
const oldUserKey = this.userKey;
this.userKey = userKey;
if (oldUserKey && merge) {
await this.request({ old_device_id: oldUserKey });
}
}
public async enable(anonymous = true) {
if (!this.disabled && this.anonymous === anonymous) return;
if (!this.canEnable()) return;
if (!this.disabled) {
// flush request queue as our userKey is going to change, no need to await it
this.request();
}
const config = SdkConfig.get();
this.baseUrl = new URL("/i", config.countly.url);
this.appKey = config.countly.appKey;
this.anonymous = anonymous;
if (anonymous) {
await this.changeUserKey(randomString(64))
} else {
await this.changeUserKey(await hashHex(MatrixClientPeg.get().getUserId()), true);
}
const platform = PlatformPeg.get();
this.appPlatform = platform.getHumanReadableName();
try {
this.appVersion = await platform.getAppVersion();
} catch (e) {
console.warn("Failed to get app version, using 'unknown'");
}
// start heartbeat
this.heartbeatIntervalId = setInterval(this.heartbeat.bind(this), HEARTBEAT_INTERVAL);
this.trackSessions();
this.trackErrors();
}
public async disable() {
if (this.disabled) return;
await this.track("Opt-Out" );
this.endSession();
window.clearInterval(this.heartbeatIntervalId);
window.clearTimeout(this.activityIntervalId)
this.baseUrl = null;
// remove listeners bound in trackSessions()
window.removeEventListener("beforeunload", this.endSession);
window.removeEventListener("unload", this.endSession);
window.removeEventListener("visibilitychange", this.onVisibilityChange);
window.removeEventListener("mousemove", this.onUserActivity);
window.removeEventListener("click", this.onUserActivity);
window.removeEventListener("keydown", this.onUserActivity);
window.removeEventListener("scroll", this.onUserActivity);
}
public reportFeedback(rating: 1 | 2 | 3 | 4 | 5, comment: string) {
this.track<IStarRatingEvent>("[CLY]_star_rating", { rating, comment }, null, {}, true);
}
public trackPageChange(generationTimeMs?: number) {
if (this.disabled) return;
// TODO use generationTimeMs
this.trackPageView();
}
private async trackPageView() {
this.reportViewDuration();
await sleep(0); // XXX: we sleep here because otherwise we get the old hash and not the new one
const viewData = await getViewData(this.anonymous);
const page = viewData.name;
this.lastView = page;
this.lastViewTime = CountlyAnalytics.getTimestamp();
const segments = {
...viewData.meta,
name: page,
visit: 1,
domain: window.location.hostname,
view: viewData.url,
segment: this.appPlatform,
start: this.firstPage,
};
if (this.firstPage) {
this.firstPage = false;
}
this.track<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 || this.anonymous) return;
let error = "";
if (typeof err === "object") {
if (typeof err.stack !== "undefined") {
error = err.stack;
} else {
if (typeof err.name !== "undefined") {
error += err.name + ":";
}
if (typeof err.message !== "undefined") {
error += err.message + "\n";
}
if (typeof err.fileName !== "undefined") {
error += "in " + err.fileName + "\n";
}
if (typeof err.lineNumber !== "undefined") {
error += "on " + err.lineNumber;
}
if (typeof err.columnNumber !== "undefined") {
error += ":" + err.columnNumber;
}
}
} else {
error = err + "";
}
const metrics = this.getMetrics();
const ob: ICrash = {
_resolution: metrics?._resolution,
_error: error,
_app_version: this.appVersion,
_run: CountlyAnalytics.getTimestamp() - this.initTime,
_nonfatal: !fatal,
_view: this.lastView,
};
if (typeof navigator.onLine !== "undefined") {
ob._online = navigator.onLine;
}
ob._background = document.hasFocus();
this.request({ crash: JSON.stringify(ob) });
}
private trackErrors() {
//override global uncaught error handler
window.onerror = (msg, url, line, col, err) => {
if (typeof err !== "undefined") {
this.recordError(err, false);
} else {
let error = "";
if (typeof msg !== "undefined") {
error += msg + "\n";
}
if (typeof url !== "undefined") {
error += "at " + url;
}
if (typeof line !== "undefined") {
error += ":" + line;
}
if (typeof col !== "undefined") {
error += ":" + col;
}
error += "\n";
try {
const stack = [];
// eslint-disable-next-line no-caller
let f = arguments.callee.caller;
while (f) {
stack.push(f.name);
f = f.caller;
}
error += stack.join("\n");
} catch (ex) {
//silent error
}
this.recordError(error, false);
}
};
window.addEventListener('unhandledrejection', (event) => {
this.recordError(new Error(`Unhandled rejection (reason: ${event.reason?.stack || event.reason}).`), true);
});
}
private heartbeat() {
const args: Pick<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 startTime() {
if (!this.trackTime) {
this.trackTime = true;
this.lastBeat = CountlyAnalytics.getTimestamp() - this.storedDuration;
this.lastViewTime = CountlyAnalytics.getTimestamp() - this.lastViewStoredDuration;
this.lastViewStoredDuration = 0;
}
}
private stopTime() {
if (this.trackTime) {
this.trackTime = false;
this.storedDuration = CountlyAnalytics.getTimestamp() - this.lastBeat;
this.lastViewStoredDuration = CountlyAnalytics.getTimestamp() - this.lastViewTime;
}
}
private getMetrics(): IMetrics {
if (this.anonymous) return undefined;
const metrics: IMetrics = {};
// getting app version
metrics._app_version = this.appVersion;
metrics._ua = navigator.userAgent;
// getting resolution
if (screen.width && screen.height) {
metrics._resolution = `${screen.width}x${screen.height}`;
}
// getting density ratio
if (window.devicePixelRatio) {
metrics._density = window.devicePixelRatio;
}
// getting locale
metrics._locale = getCurrentLanguage();
return metrics;
}
private async beginSession(heartbeat = true) {
if (!this.sessionStarted) {
this.reportOrientation();
window.addEventListener("resize", this.reportOrientation);
this.lastBeat = CountlyAnalytics.getTimestamp();
this.sessionStarted = true;
this.heartbeatEnabled = heartbeat;
const userDetails: IUserDetails = {
custom: {
"home_server": MatrixClientPeg.get() && MatrixClientPeg.getHomeserverName(), // TODO hash?
"anonymous": this.anonymous,
},
};
const request: Parameters<typeof CountlyAnalytics.prototype.request>[0] = {
begin_session: 1,
user_details: JSON.stringify(userDetails),
}
const metrics = this.getMetrics();
if (metrics) {
request.metrics = JSON.stringify(metrics);
}
await this.request(request);
}
}
private reportViewDuration() {
if (this.lastView) {
this.track<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)
this.reportViewDuration();
this.request({
end_session: 1,
session_duration: CountlyAnalytics.getTimestamp() - this.lastBeat,
});
}
this.sessionStarted = false;
}
private onVisibilityChange = () => {
if (document.hidden) {
this.stopTime();
} else {
this.startTime();
}
};
private onUserActivity = () => {
if (this.inactivityCounter >= INACTIVITY_TIME) {
this.startTime();
}
this.inactivityCounter = 0;
};
private trackSessions() {
this.beginSession();
this.startTime();
window.addEventListener("beforeunload", this.endSession);
window.addEventListener("unload", this.endSession);
window.addEventListener("visibilitychange", this.onVisibilityChange);
window.addEventListener("mousemove", this.onUserActivity);
window.addEventListener("click", this.onUserActivity);
window.addEventListener("keydown", this.onUserActivity);
window.addEventListener("scroll", this.onUserActivity);
this.activityIntervalId = setInterval(() => {
this.inactivityCounter++;
if (this.inactivityCounter >= INACTIVITY_TIME) {
this.stopTime();
}
}, 60_000);
}
public trackBeginInvite(roomId: string) {
this.track<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) {
await this.request({ device_id: randomString(64) });
}
}
}
// expose on window for easy access from the console
window.mxCountlyAnalytics = CountlyAnalytics;

View file

@ -29,6 +29,7 @@ import EMOJIBASE_REGEX from 'emojibase-regex';
import url from 'url'; import url from 'url';
import {MatrixClientPeg} from './MatrixClientPeg'; import {MatrixClientPeg} from './MatrixClientPeg';
import SettingsStore from './settings/SettingsStore';
import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks"; import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks";
import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji"; import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji";
import ReplyThread from "./components/views/elements/ReplyThread"; import ReplyThread from "./components/views/elements/ReplyThread";
@ -171,7 +172,10 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
// because transformTags is used _before_ we filter by allowedSchemesByTag and // because transformTags is used _before_ we filter by allowedSchemesByTag and
// we don't want to allow images with `https?` `src`s. // we don't want to allow images with `https?` `src`s.
if (!attribs.src || !attribs.src.startsWith('mxc://')) { // We also drop inline images (as if they were not present at all) when the "show
// images" preference is disabled. Future work might expose some UI to reveal them
// like standalone image events have.
if (!attribs.src || !attribs.src.startsWith('mxc://') || !SettingsStore.getValue("showImages")) {
return { tagName, attribs: {}}; return { tagName, attribs: {}};
} }
attribs.src = MatrixClientPeg.get().mxcUrlToHttp( attribs.src = MatrixClientPeg.get().mxcUrlToHttp(

View file

@ -47,6 +47,7 @@ import DeviceListener from "./DeviceListener";
import {Jitsi} from "./widgets/Jitsi"; import {Jitsi} from "./widgets/Jitsi";
import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "./BasePlatform"; import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "./BasePlatform";
import ThreepidInviteStore from "./stores/ThreepidInviteStore"; import ThreepidInviteStore from "./stores/ThreepidInviteStore";
import CountlyAnalytics from "./CountlyAnalytics";
const HOMESERVER_URL_KEY = "mx_hs_url"; const HOMESERVER_URL_KEY = "mx_hs_url";
const ID_SERVER_URL_KEY = "mx_is_url"; const ID_SERVER_URL_KEY = "mx_is_url";
@ -580,6 +581,10 @@ let _isLoggingOut = false;
*/ */
export function logout(): void { export function logout(): void {
if (!MatrixClientPeg.get()) return; if (!MatrixClientPeg.get()) return;
if (!CountlyAnalytics.instance.disabled) {
// user has logged out, fall back to anonymous
CountlyAnalytics.instance.enable(/* anonymous = */ true);
}
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
// logout doesn't work for guest sessions // logout doesn't work for guest sessions

View file

@ -28,7 +28,7 @@ import AsyncWrapper from './AsyncWrapper';
const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer"; const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer";
interface IModal<T extends any[]> { export interface IModal<T extends any[]> {
elem: React.ReactNode; elem: React.ReactNode;
className?: string; className?: string;
beforeClosePromise?: Promise<boolean>; beforeClosePromise?: Promise<boolean>;
@ -38,7 +38,7 @@ interface IModal<T extends any[]> {
close(...args: T): void; close(...args: T): void;
} }
interface IHandle<T extends any[]> { export interface IHandle<T extends any[]> {
finished: Promise<T>; finished: Promise<T>;
close(...args: T): void; close(...args: T): void;
} }

View file

@ -518,6 +518,7 @@ export const Commands = [
action: 'view_room', action: 'view_room',
room_alias: roomAlias, room_alias: roomAlias,
auto_join: true, auto_join: true,
_type: "slash_command", // instrumentation
}); });
return success(); return success();
} else if (params[0][0] === '!') { } else if (params[0][0] === '!') {
@ -532,6 +533,7 @@ export const Commands = [
}, },
via_servers: viaServers, // for the rejoin button via_servers: viaServers, // for the rejoin button
auto_join: true, auto_join: true,
_type: "slash_command", // instrumentation
}); });
return success(); return success();
} else if (isPermalink) { } else if (isPermalink) {
@ -556,6 +558,7 @@ export const Commands = [
const dispatch = { const dispatch = {
action: 'view_room', action: 'view_room',
auto_join: true, auto_join: true,
_type: "slash_command", // instrumentation
}; };
if (entity[0] === '!') dispatch["room_id"] = entity; if (entity[0] === '!') dispatch["room_id"] = entity;

View file

@ -470,6 +470,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
value={CREATE_STORAGE_OPTION_KEY} value={CREATE_STORAGE_OPTION_KEY}
name="keyPassphrase" name="keyPassphrase"
checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_KEY} checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_KEY}
onChange={this._onKeyPassphraseChange}
outlined outlined
> >
<div className="mx_CreateSecretStorageDialog_optionTitle"> <div className="mx_CreateSecretStorageDialog_optionTitle">
@ -488,6 +489,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
value={CREATE_STORAGE_OPTION_PASSPHRASE} value={CREATE_STORAGE_OPTION_PASSPHRASE}
name="keyPassphrase" name="keyPassphrase"
checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_PASSPHRASE} checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_PASSPHRASE}
onChange={this._onKeyPassphraseChange}
outlined outlined
> >
<div className="mx_CreateSecretStorageDialog_optionTitle"> <div className="mx_CreateSecretStorageDialog_optionTitle">
@ -509,7 +511,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
"Safeguard against losing access to encrypted messages & data by " + "Safeguard against losing access to encrypted messages & data by " +
"backing up encryption keys on your server.", "backing up encryption keys on your server.",
)}</p> )}</p>
<div className="mx_CreateSecretStorageDialog_primaryContainer" role="radiogroup" onChange={this._onKeyPassphraseChange}> <div className="mx_CreateSecretStorageDialog_primaryContainer" role="radiogroup">
{optionKey} {optionKey}
{optionPassphrase} {optionPassphrase}
</div> </div>

View file

@ -29,6 +29,7 @@ import 'focus-visible';
import 'what-input'; import 'what-input';
import Analytics from "../../Analytics"; import Analytics from "../../Analytics";
import CountlyAnalytics from "../../CountlyAnalytics";
import { DecryptionFailureTracker } from "../../DecryptionFailureTracker"; import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
import { MatrixClientPeg, IMatrixClientCreds } from "../../MatrixClientPeg"; import { MatrixClientPeg, IMatrixClientCreds } from "../../MatrixClientPeg";
import PlatformPeg from "../../PlatformPeg"; import PlatformPeg from "../../PlatformPeg";
@ -109,7 +110,8 @@ export enum Views {
// flow to setup SSSS / cross-signing on this account // flow to setup SSSS / cross-signing on this account
E2E_SETUP = 7, E2E_SETUP = 7,
// we are logged in with an active matrix client. // we are logged in with an active matrix client. The logged_in state also
// includes guests users as they too are logged in at the client level.
LOGGED_IN = 8, LOGGED_IN = 8,
// We are logged out (invalid token) but have our local state again. The user // We are logged out (invalid token) but have our local state again. The user
@ -349,6 +351,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (SettingsStore.getValue("analyticsOptIn")) { if (SettingsStore.getValue("analyticsOptIn")) {
Analytics.enable(); Analytics.enable();
} }
CountlyAnalytics.instance.enable(/* anonymous = */ true);
} }
// TODO: [REACT-WARNING] Replace with appropriate lifecycle stage // TODO: [REACT-WARNING] Replace with appropriate lifecycle stage
@ -363,6 +366,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (this.shouldTrackPageChange(prevState, this.state)) { if (this.shouldTrackPageChange(prevState, this.state)) {
const durationMs = this.stopPageChangeTimer(); const durationMs = this.stopPageChangeTimer();
Analytics.trackPageChange(durationMs); Analytics.trackPageChange(durationMs);
CountlyAnalytics.instance.trackPageChange(durationMs);
} }
if (this.focusComposer) { if (this.focusComposer) {
dis.fire(Action.FocusComposer); dis.fire(Action.FocusComposer);
@ -415,6 +419,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} else { } else {
dis.dispatch({action: "view_welcome_page"}); dis.dispatch({action: "view_welcome_page"});
} }
} else if (SettingsStore.getValue("analyticsOptIn")) {
CountlyAnalytics.instance.enable(/* anonymous = */ false);
} }
}); });
// Note we don't catch errors from this: we catch everything within // Note we don't catch errors from this: we catch everything within
@ -750,7 +756,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, true); SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, true);
SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false); SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false);
hideAnalyticsToast(); hideAnalyticsToast();
if (Analytics.canEnable()) {
Analytics.enable(); Analytics.enable();
}
if (CountlyAnalytics.instance.canEnable()) {
CountlyAnalytics.instance.enable(/* anonymous = */ false);
}
break; break;
case 'reject_cookies': case 'reject_cookies':
SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, false); SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, false);
@ -1200,7 +1211,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
StorageManager.tryPersistStorage(); StorageManager.tryPersistStorage();
if (SettingsStore.getValue("showCookieBar") && Analytics.canEnable()) { if (SettingsStore.getValue("showCookieBar") &&
(Analytics.canEnable() || CountlyAnalytics.instance.canEnable())
) {
showAnalyticsToast(this.props.config.piwik?.policyUrl); showAnalyticsToast(this.props.config.piwik?.policyUrl);
} }
} }
@ -1581,6 +1594,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
action: 'require_registration', action: 'require_registration',
}); });
} else if (screen === 'directory') { } else if (screen === 'directory') {
if (this.state.view === Views.WELCOME) {
CountlyAnalytics.instance.track("onboarding_room_directory");
}
dis.fire(Action.ViewRoomDirectory); dis.fire(Action.ViewRoomDirectory);
} else if (screen === "start_sso" || screen === "start_cas") { } else if (screen === "start_sso" || screen === "start_cas") {
// TODO if logged in, skip SSO // TODO if logged in, skip SSO

View file

@ -33,6 +33,7 @@ import SettingsStore from "../../settings/SettingsStore";
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore"; import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
import GroupStore from "../../stores/GroupStore"; import GroupStore from "../../stores/GroupStore";
import FlairStore from "../../stores/FlairStore"; import FlairStore from "../../stores/FlairStore";
import CountlyAnalytics from "../../CountlyAnalytics";
const MAX_NAME_LENGTH = 80; const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 800; const MAX_TOPIC_LENGTH = 800;
@ -49,6 +50,8 @@ export default class RoomDirectory extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.startTime = CountlyAnalytics.getTimestamp();
const selectedCommunityId = GroupFilterOrderStore.getSelectedTags()[0]; const selectedCommunityId = GroupFilterOrderStore.getSelectedTags()[0];
this.state = { this.state = {
publicRooms: [], publicRooms: [],
@ -198,6 +201,11 @@ export default class RoomDirectory extends React.Component {
return; 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.nextBatch = data.next_batch;
this.setState((s) => { this.setState((s) => {
s.publicRooms.push(...(data.chunk || [])); s.publicRooms.push(...(data.chunk || []));
@ -407,7 +415,7 @@ export default class RoomDirectory extends React.Component {
}; };
onCreateRoomClick = room => { onCreateRoomClick = room => {
this.props.onFinished(); this.onFinished();
dis.dispatch({ dis.dispatch({
action: 'view_create_room', action: 'view_create_room',
public: true, public: true,
@ -419,11 +427,12 @@ export default class RoomDirectory extends React.Component {
} }
showRoom(room, room_alias, autoJoin = false, shouldPeek = false) { showRoom(room, room_alias, autoJoin = false, shouldPeek = false) {
this.props.onFinished(); this.onFinished();
const payload = { const payload = {
action: 'view_room', action: 'view_room',
auto_join: autoJoin, auto_join: autoJoin,
should_peek: shouldPeek, should_peek: shouldPeek,
_type: "room_directory", // instrumentation
}; };
if (room) { if (room) {
// Don't let the user view a room they won't be able to either // 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() { render() {
const Loader = sdk.getComponent("elements.Spinner"); const Loader = sdk.getComponent("elements.Spinner");
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
@ -693,7 +707,7 @@ export default class RoomDirectory extends React.Component {
<BaseDialog <BaseDialog
className={'mx_RoomDirectory_dialog'} className={'mx_RoomDirectory_dialog'}
hasCancel={true} hasCancel={true}
onFinished={this.props.onFinished} onFinished={this.onFinished}
title={title} title={title}
> >
<div className="mx_RoomDirectory"> <div className="mx_RoomDirectory">

View file

@ -129,6 +129,7 @@ export interface IState {
initialEventPixelOffset?: number; initialEventPixelOffset?: number;
// Whether to highlight the event scrolled to // Whether to highlight the event scrolled to
isInitialEventHighlighted?: boolean; isInitialEventHighlighted?: boolean;
replyToEvent?: MatrixEvent;
forwardingEvent?: MatrixEvent; forwardingEvent?: MatrixEvent;
numUnreadMessages: number; numUnreadMessages: number;
draggingFile: boolean; draggingFile: boolean;
@ -315,6 +316,7 @@ export default class RoomView extends React.Component<IProps, IState> {
joining: RoomViewStore.isJoining(), joining: RoomViewStore.isJoining(),
initialEventId: RoomViewStore.getInitialEventId(), initialEventId: RoomViewStore.getInitialEventId(),
isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(), isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(),
replyToEvent: RoomViewStore.getQuotingEvent(),
forwardingEvent: RoomViewStore.getForwardingEvent(), forwardingEvent: RoomViewStore.getForwardingEvent(),
// we should only peek once we have a ready client // we should only peek once we have a ready client
shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(), shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(),
@ -1111,6 +1113,7 @@ export default class RoomView extends React.Component<IProps, IState> {
dis.dispatch({ dis.dispatch({
action: 'join_room', action: 'join_room',
opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers }, opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers },
_type: "unknown", // TODO: instrumentation
}); });
return Promise.resolve(); return Promise.resolve();
}); });
@ -1899,6 +1902,7 @@ export default class RoomView extends React.Component<IProps, IState> {
showApps={this.state.showApps} showApps={this.state.showApps}
e2eStatus={this.state.e2eStatus} e2eStatus={this.state.e2eStatus}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
replyToEvent={this.state.replyToEvent}
permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)} permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)}
/>; />;
} }

View file

@ -23,7 +23,7 @@ import { _t } from "../../languageHandler";
import { ContextMenuButton } from "./ContextMenu"; import { ContextMenuButton } from "./ContextMenu";
import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog"; import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog";
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog"; import FeedbackDialog from "../views/dialogs/FeedbackDialog";
import Modal from "../../Modal"; import Modal from "../../Modal";
import LogoutDialog from "../views/dialogs/LogoutDialog"; import LogoutDialog from "../views/dialogs/LogoutDialog";
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
@ -186,7 +186,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog); Modal.createTrackedDialog('Feedback Dialog', '', FeedbackDialog);
this.setState({contextMenuPosition: null}); // also close the menu 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 AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import classNames from 'classnames'; import classNames from 'classnames';
import AuthPage from "../../views/auth/AuthPage"; import AuthPage from "../../views/auth/AuthPage";
import CountlyAnalytics from "../../../CountlyAnalytics";
// Phases // Phases
// Show controls to configure server details // Show controls to configure server details
@ -64,6 +65,12 @@ export default class ForgotPassword extends React.Component {
serverRequiresIdServer: null, serverRequiresIdServer: null,
}; };
constructor(props) {
super(props);
CountlyAnalytics.instance.track("onboarding_forgot_password_begin");
}
componentDidMount() { componentDidMount() {
this.reset = null; this.reset = null;
this._checkServerLiveliness(this.props.serverConfig); this._checkServerLiveliness(this.props.serverConfig);
@ -299,6 +306,8 @@ export default class ForgotPassword extends React.Component {
value={this.state.email} value={this.state.email}
onChange={this.onInputChanged.bind(this, "email")} onChange={this.onInputChanged.bind(this, "email")}
autoFocus autoFocus
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_blur")}
/> />
</div> </div>
<div className="mx_AuthBody_fieldRow"> <div className="mx_AuthBody_fieldRow">
@ -308,6 +317,8 @@ export default class ForgotPassword extends React.Component {
label={_t('Password')} label={_t('Password')}
value={this.state.password} value={this.state.password}
onChange={this.onInputChanged.bind(this, "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 <Field
name="reset_password_confirm" name="reset_password_confirm"
@ -315,6 +326,8 @@ export default class ForgotPassword extends React.Component {
label={_t('Confirm')} label={_t('Confirm')}
value={this.state.password2} value={this.state.password2}
onChange={this.onInputChanged.bind(this, "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> </div>
<span>{_t( <span>{_t(

View file

@ -30,6 +30,7 @@ import SSOButton from "../../views/elements/SSOButton";
import PlatformPeg from '../../../PlatformPeg'; import PlatformPeg from '../../../PlatformPeg';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import {UIFeature} from "../../../settings/UIFeature"; import {UIFeature} from "../../../settings/UIFeature";
import CountlyAnalytics from "../../../CountlyAnalytics";
// For validating phone numbers without country codes // For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; 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.cas': () => this._renderSsoStep("cas"),
'm.login.sso': () => this._renderSsoStep("sso"), 'm.login.sso': () => this._renderSsoStep("sso"),
}; };
CountlyAnalytics.instance.track("onboarding_login_begin");
} }
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event // TODO: [REACT-WARNING] Replace with appropriate lifecycle event

View file

@ -17,6 +17,7 @@ limitations under the License.
import React, {createRef} from 'react'; import React, {createRef} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import CountlyAnalytics from "../../../CountlyAnalytics";
const DIV_ID = 'mx_recaptcha'; const DIV_ID = 'mx_recaptcha';
@ -45,6 +46,8 @@ export default class CaptchaForm extends React.Component {
this._captchaWidgetId = null; this._captchaWidgetId = null;
this._recaptchaContainer = createRef(); this._recaptchaContainer = createRef();
CountlyAnalytics.instance.track("onboarding_grecaptcha_begin");
} }
componentDidMount() { componentDidMount() {
@ -99,10 +102,12 @@ export default class CaptchaForm extends React.Component {
console.log("Loaded recaptcha script."); console.log("Loaded recaptcha script.");
try { try {
this._renderRecaptcha(DIV_ID); this._renderRecaptcha(DIV_ID);
CountlyAnalytics.instance.track("onboarding_grecaptcha_loaded");
} catch (e) { } catch (e) {
this.setState({ this.setState({
errorText: e.toString(), 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 SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import Spinner from "../elements/Spinner"; import Spinner from "../elements/Spinner";
import CountlyAnalytics from "../../../CountlyAnalytics";
/* This file contains a collection of components which are used by the /* This file contains a collection of components which are used by the
* InteractiveAuth to prompt the user to enter the information needed * InteractiveAuth to prompt the user to enter the information needed
@ -189,6 +190,7 @@ export class RecaptchaAuthEntry extends React.Component {
} }
_onCaptchaResponse = response => { _onCaptchaResponse = response => {
CountlyAnalytics.instance.track("onboarding_grecaptcha_submit");
this.props.submitAuthDict({ this.props.submitAuthDict({
type: RecaptchaAuthEntry.LOGIN_TYPE, type: RecaptchaAuthEntry.LOGIN_TYPE,
response: response, response: response,
@ -297,6 +299,8 @@ export class TermsAuthEntry extends React.Component {
toggledPolicies: initToggles, toggledPolicies: initToggles,
policies: pickedPolicies, policies: pickedPolicies,
}; };
CountlyAnalytics.instance.track("onboarding_terms_begin");
} }
@ -326,8 +330,12 @@ export class TermsAuthEntry extends React.Component {
allChecked = allChecked && checked; allChecked = allChecked && checked;
} }
if (allChecked) this.props.submitAuthDict({type: TermsAuthEntry.LOGIN_TYPE}); if (allChecked) {
else this.setState({errorText: _t("Please review and accept all of the homeserver's policies")}); 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() { render() {

View file

@ -24,6 +24,7 @@ import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import CountlyAnalytics from "../../../CountlyAnalytics";
/** /**
* A pure UI component which displays a username/password form. * 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); 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) { 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); this.props.onUsernameBlur(ev.target.value);
} }
@ -161,6 +175,7 @@ export default class PasswordLogin extends React.Component {
loginType: loginType, loginType: loginType,
username: "", // Reset because email and username use the same state username: "", // Reset because email and username use the same state
}); });
CountlyAnalytics.instance.track("onboarding_login_type_changed", { loginType });
} }
onPhoneCountryChanged(country) { onPhoneCountryChanged(country) {
@ -176,8 +191,13 @@ export default class PasswordLogin extends React.Component {
this.props.onPhoneNumberChanged(ev.target.value); this.props.onPhoneNumberChanged(ev.target.value);
} }
onPhoneNumberFocus() {
CountlyAnalytics.instance.track("onboarding_login_phone_number_focus");
}
onPhoneNumberBlur(ev) { onPhoneNumberBlur(ev) {
this.props.onPhoneNumberBlur(ev.target.value); this.props.onPhoneNumberBlur(ev.target.value);
CountlyAnalytics.instance.track("onboarding_login_phone_number_blur");
} }
onPasswordChanged(ev) { onPasswordChanged(ev) {
@ -202,6 +222,7 @@ export default class PasswordLogin extends React.Component {
placeholder="joe@example.com" placeholder="joe@example.com"
value={this.state.username} value={this.state.username}
onChange={this.onUsernameChanged} onChange={this.onUsernameChanged}
onFocus={this.onUsernameFocus}
onBlur={this.onUsernameBlur} onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit} disabled={this.props.disableSubmit}
autoFocus={autoFocus} autoFocus={autoFocus}
@ -216,6 +237,7 @@ export default class PasswordLogin extends React.Component {
label={_t("Username")} label={_t("Username")}
value={this.state.username} value={this.state.username}
onChange={this.onUsernameChanged} onChange={this.onUsernameChanged}
onFocus={this.onUsernameFocus}
onBlur={this.onUsernameBlur} onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit} disabled={this.props.disableSubmit}
autoFocus={autoFocus} autoFocus={autoFocus}
@ -240,6 +262,7 @@ export default class PasswordLogin extends React.Component {
value={this.state.phoneNumber} value={this.state.phoneNumber}
prefixComponent={phoneCountry} prefixComponent={phoneCountry}
onChange={this.onPhoneNumberChanged} onChange={this.onPhoneNumberChanged}
onFocus={this.onPhoneNumberFocus}
onBlur={this.onPhoneNumberBlur} onBlur={this.onPhoneNumberBlur}
disabled={this.props.disableSubmit} disabled={this.props.disableSubmit}
autoFocus={autoFocus} autoFocus={autoFocus}

View file

@ -29,6 +29,7 @@ import { SAFE_LOCALPART_REGEX } from '../../../Registration';
import withValidation from '../elements/Validation'; import withValidation from '../elements/Validation';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import PassphraseField from "./PassphraseField"; import PassphraseField from "./PassphraseField";
import CountlyAnalytics from "../../../CountlyAnalytics";
const FIELD_EMAIL = 'field_email'; const FIELD_EMAIL = 'field_email';
const FIELD_PHONE_NUMBER = 'field_phone_number'; const FIELD_PHONE_NUMBER = 'field_phone_number';
@ -77,6 +78,8 @@ export default class RegistrationForm extends React.Component {
passwordConfirm: this.props.defaultPassword || "", passwordConfirm: this.props.defaultPassword || "",
passwordComplexity: null, passwordComplexity: null,
}; };
CountlyAnalytics.instance.track("onboarding_registration_begin");
} }
onSubmit = async ev => { onSubmit = async ev => {
@ -86,6 +89,7 @@ export default class RegistrationForm extends React.Component {
const allFieldsValid = await this.verifyFieldsBeforeSubmit(); const allFieldsValid = await this.verifyFieldsBeforeSubmit();
if (!allFieldsValid) { if (!allFieldsValid) {
CountlyAnalytics.instance.track("onboarding_registration_submit_failed");
return; return;
} }
@ -110,6 +114,8 @@ export default class RegistrationForm extends React.Component {
return; return;
} }
CountlyAnalytics.instance.track("onboarding_registration_submit_warn");
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, { Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, {
title: _t("Warning!"), title: _t("Warning!"),
@ -128,6 +134,11 @@ export default class RegistrationForm extends React.Component {
_doSubmit(ev) { _doSubmit(ev) {
const email = this.state.email.trim(); const email = this.state.email.trim();
CountlyAnalytics.instance.track("onboarding_registration_submit_ok", {
email: !!email,
});
const promise = this.props.onRegisterClick({ const promise = this.props.onRegisterClick({
username: this.state.username.trim(), username: this.state.username.trim(),
password: this.state.password.trim(), password: this.state.password.trim(),
@ -422,6 +433,8 @@ export default class RegistrationForm extends React.Component {
value={this.state.email} value={this.state.email}
onChange={this.onEmailChange} onChange={this.onEmailChange}
onValidate={this.onEmailValidate} 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} value={this.state.password}
onChange={this.onPasswordChange} onChange={this.onPasswordChange}
onValidate={this.onPasswordValidate} 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} value={this.state.passwordConfirm}
onChange={this.onPasswordConfirmChange} onChange={this.onPasswordConfirmChange}
onValidate={this.onPasswordConfirmValidate} 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} value={this.state.username}
onChange={this.onUsernameChange} onChange={this.onUsernameChange}
onValidate={this.onUsernameValidate} 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 SdkConfig from "../../../SdkConfig";
import { createClient } from 'matrix-js-sdk/src/matrix'; import { createClient } from 'matrix-js-sdk/src/matrix';
import classNames from 'classnames'; import classNames from 'classnames';
import CountlyAnalytics from "../../../CountlyAnalytics";
/* /*
* A pure UI component which displays the HS and IS to use. * 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, isUrl: props.serverConfig.isUrl,
showIdentityServer: false, showIdentityServer: false,
}; };
CountlyAnalytics.instance.track("onboarding_custom_server");
} }
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event // TODO: [REACT-WARNING] Replace with appropriate lifecycle event

View file

@ -23,11 +23,18 @@ import AuthPage from "./AuthPage";
import {_td} from "../../../languageHandler"; import {_td} from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import {UIFeature} from "../../../settings/UIFeature"; import {UIFeature} from "../../../settings/UIFeature";
import CountlyAnalytics from "../../../CountlyAnalytics";
// translatable strings for Welcome pages // translatable strings for Welcome pages
_td("Sign in with SSO"); _td("Sign in with SSO");
export default class Welcome extends React.PureComponent { export default class Welcome extends React.PureComponent {
constructor(props) {
super(props);
CountlyAnalytics.instance.track("onboarding_welcome");
}
render() { render() {
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage'); const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
const LanguageSelector = sdk.getComponent('auth.LanguageSelector'); 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 {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import {UIFeature} from "../../../settings/UIFeature"; 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. // we have a number of types defined from the Matrix spec which can't reasonably be altered here.
/* eslint-disable camelcase */ /* eslint-disable camelcase */
@ -325,6 +326,8 @@ export default class InviteDialog extends React.PureComponent {
room.getMembersWithMembership('join').forEach(m => alreadyInvited.add(m.userId)); room.getMembersWithMembership('join').forEach(m => alreadyInvited.add(m.userId));
// add banned users, so we don't try to invite them // add banned users, so we don't try to invite them
room.getMembersWithMembership('ban').forEach(m => alreadyInvited.add(m.userId)); room.getMembersWithMembership('ban').forEach(m => alreadyInvited.add(m.userId));
CountlyAnalytics.instance.trackBeginInvite(props.roomId);
} }
this.state = { this.state = {
@ -627,6 +630,7 @@ export default class InviteDialog extends React.PureComponent {
}; };
_inviteUsers = () => { _inviteUsers = () => {
const startTime = CountlyAnalytics.getTimestamp();
this.setState({busy: true}); this.setState({busy: true});
this._convertFilter(); this._convertFilter();
const targets = this._convertFilter(); const targets = this._convertFilter();
@ -643,6 +647,7 @@ export default class InviteDialog extends React.PureComponent {
} }
inviteMultipleToRoom(this.props.roomId, targetIds).then(result => { 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 if (!this._shouldAbortAfterInviteError(result)) { // handles setting error message too
this.props.onFinished(); this.props.onFinished();
} }

View file

@ -0,0 +1,165 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from 'react';
import BaseDialog from './BaseDialog';
import { _t } from '../../../languageHandler';
import AccessibleButton from "../elements/AccessibleButton";
import {
ClientWidgetApi,
IModalWidgetCloseRequest,
IModalWidgetOpenRequestData,
IModalWidgetReturnData,
ModalButtonKind,
Widget,
WidgetApiFromWidgetAction,
} from "matrix-widget-api";
import {StopGapWidgetDriver} from "../../../stores/widgets/StopGapWidgetDriver";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import RoomViewStore from "../../../stores/RoomViewStore";
import {OwnProfileStore} from "../../../stores/OwnProfileStore";
interface IProps {
widgetDefinition: IModalWidgetOpenRequestData;
sourceWidgetId: string;
onFinished(success: boolean, data?: IModalWidgetReturnData): void;
}
interface IState {
messaging?: ClientWidgetApi;
}
const MAX_BUTTONS = 3;
export default class ModalWidgetDialog extends React.PureComponent<IProps, IState> {
private readonly widget: Widget;
private appFrame: React.RefObject<HTMLIFrameElement> = React.createRef();
state: IState = {};
constructor(props) {
super(props);
this.widget = new Widget({
...this.props.widgetDefinition,
creatorUserId: MatrixClientPeg.get().getUserId(),
id: `modal_${this.props.sourceWidgetId}`,
});
}
public componentDidMount() {
const driver = new StopGapWidgetDriver( []);
const messaging = new ClientWidgetApi(this.widget, this.appFrame.current, driver);
this.setState({messaging});
}
public componentWillUnmount() {
this.state.messaging.off("ready", this.onReady);
this.state.messaging.off(`action:${WidgetApiFromWidgetAction.CloseModalWidget}`, this.onWidgetClose);
this.state.messaging.stop();
}
private onReady = () => {
this.state.messaging.sendWidgetConfig(this.props.widgetDefinition);
};
private onLoad = () => {
this.state.messaging.once("ready", this.onReady);
this.state.messaging.on(`action:${WidgetApiFromWidgetAction.CloseModalWidget}`, this.onWidgetClose);
};
private onWidgetClose = (ev: CustomEvent<IModalWidgetCloseRequest>) => {
this.props.onFinished(true, ev.detail.data);
}
public render() {
const templated = this.widget.getCompleteUrl({
currentRoomId: RoomViewStore.getRoomId(),
currentUserId: MatrixClientPeg.get().getUserId(),
userDisplayName: OwnProfileStore.instance.displayName,
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(),
});
const parsed = new URL(templated);
// Add in some legacy support sprinkles (for non-popout widgets)
// TODO: Replace these with proper widget params
// See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833
parsed.searchParams.set('widgetId', this.widget.id);
parsed.searchParams.set('parentUrl', window.location.href.split('#', 2)[0]);
// Replace the encoded dollar signs back to dollar signs. They have no special meaning
// in HTTP, but URL parsers encode them anyways.
const widgetUrl = parsed.toString().replace(/%24/g, '$');
let buttons;
if (this.props.widgetDefinition.buttons) {
// show first button rightmost for a more natural specification
buttons = this.props.widgetDefinition.buttons.slice(0, MAX_BUTTONS).reverse().map(def => {
let kind = "secondary";
switch (def.kind) {
case ModalButtonKind.Primary:
kind = "primary";
break;
case ModalButtonKind.Secondary:
kind = "primary_outline";
break
case ModalButtonKind.Danger:
kind = "danger";
break;
}
const onClick = () => {
this.state.messaging.notifyModalWidgetButtonClicked(def.id);
};
return <AccessibleButton key={def.id} kind={kind} onClick={onClick}>
{ def.label }
</AccessibleButton>;
});
}
return <BaseDialog
title={this.props.widgetDefinition.name || _t("Modal Widget")}
className="mx_ModalWidgetDialog"
contentId="mx_Dialog_content"
onFinished={this.props.onFinished}
>
<div className="mx_ModalWidgetDialog_warning">
<img
src={require("../../../../res/img/element-icons/warning-badge.svg")}
height="16"
width="16"
alt=""
/>
{_t("Data on this screen is shared with %(widgetDomain)s", {
widgetDomain: parsed.hostname,
})}
</div>
<div>
<iframe
ref={this.appFrame}
sandbox="allow-forms allow-scripts allow-same-origin"
src={widgetUrl}
onLoad={this.onLoad}
/>
</div>
<div className="mx_ModalWidgetDialog_buttons">
{ buttons }
</div>
</BaseDialog>;
}
}

View file

@ -17,6 +17,8 @@ limitations under the License.
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from "classnames";
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -26,12 +28,14 @@ export default class QuestionDialog extends React.Component {
description: PropTypes.node, description: PropTypes.node,
extraButtons: PropTypes.node, extraButtons: PropTypes.node,
button: PropTypes.string, button: PropTypes.string,
buttonDisabled: PropTypes.bool,
danger: PropTypes.bool, danger: PropTypes.bool,
focus: PropTypes.bool, focus: PropTypes.bool,
onFinished: PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
headerImage: PropTypes.string, headerImage: PropTypes.string,
quitOnly: PropTypes.bool, // quitOnly doesn't show the cancel button just the quit [x]. quitOnly: PropTypes.bool, // quitOnly doesn't show the cancel button just the quit [x].
fixedWidth: PropTypes.bool, fixedWidth: PropTypes.bool,
className: PropTypes.string,
}; };
static defaultProps = { static defaultProps = {
@ -61,7 +65,7 @@ export default class QuestionDialog extends React.Component {
} }
return ( return (
<BaseDialog <BaseDialog
className="mx_QuestionDialog" className={classNames("mx_QuestionDialog", this.props.className)}
onFinished={this.props.onFinished} onFinished={this.props.onFinished}
title={this.props.title} title={this.props.title}
contentId='mx_Dialog_content' contentId='mx_Dialog_content'
@ -74,6 +78,7 @@ export default class QuestionDialog extends React.Component {
</div> </div>
<DialogButtons primaryButton={this.props.button || _t('OK')} <DialogButtons primaryButton={this.props.button || _t('OK')}
primaryButtonClass={primaryButtonClass} primaryButtonClass={primaryButtonClass}
primaryDisabled={this.props.buttonDisabled}
cancelButton={this.props.cancelButton} cancelButton={this.props.cancelButton}
hasCancel={this.props.hasCancelButton && !this.props.quitOnly} hasCancel={this.props.hasCancelButton && !this.props.quitOnly}
onPrimaryButtonClick={this.onOk} 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

@ -85,6 +85,7 @@ export default class MImageBody extends React.Component {
showImage() { showImage() {
localStorage.setItem("mx_ShowImage_" + this.props.mxEvent.getId(), "true"); localStorage.setItem("mx_ShowImage_" + this.props.mxEvent.getId(), "true");
this.setState({showImage: true}); this.setState({showImage: true});
this._downloadImage();
} }
onClick(ev) { onClick(ev) {
@ -253,10 +254,7 @@ export default class MImageBody extends React.Component {
} }
} }
componentDidMount() { _downloadImage() {
this.unmounted = false;
this.context.on('sync', this.onClientSync);
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) { if (content.file !== undefined && this.state.decryptedUrl === null) {
let thumbnailPromise = Promise.resolve(null); let thumbnailPromise = Promise.resolve(null);
@ -289,9 +287,18 @@ export default class MImageBody extends React.Component {
}); });
}); });
} }
}
// Remember that the user wanted to show this particular image componentDidMount() {
if (!this.state.showImage && localStorage.getItem("mx_ShowImage_" + this.props.mxEvent.getId()) === "true") { this.unmounted = false;
this.context.on('sync', this.onClientSync);
const showImage = this.state.showImage ||
localStorage.getItem("mx_ShowImage_" + this.props.mxEvent.getId()) === "true";
if (showImage) {
// Don't download anything becaue we don't want to display anything.
this._downloadImage();
this.setState({showImage: true}); this.setState({showImage: true});
} }

View file

@ -16,7 +16,6 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import MFileBody from './MFileBody'; import MFileBody from './MFileBody';
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { decryptFile } from '../../../utils/DecryptFile'; import { decryptFile } from '../../../utils/DecryptFile';
@ -24,23 +23,34 @@ import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import InlineSpinner from '../elements/InlineSpinner'; import InlineSpinner from '../elements/InlineSpinner';
export default class MVideoBody extends React.Component { interface IProps {
static propTypes = {
/* the MatrixEvent to show */ /* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired, mxEvent: any;
/* called when the video has loaded */ /* called when the video has loaded */
onHeightChanged: PropTypes.func.isRequired, onHeightChanged: () => void;
}; }
state = { interface IState {
decryptedUrl: string|null,
decryptedThumbnailUrl: string|null,
decryptedBlob: Blob|null,
error: any|null,
fetchingData: boolean,
}
export default class MVideoBody extends React.PureComponent<IProps, IState> {
constructor(props) {
super(props);
this.state = {
fetchingData: false,
decryptedUrl: null, decryptedUrl: null,
decryptedThumbnailUrl: null, decryptedThumbnailUrl: null,
decryptedBlob: null, decryptedBlob: null,
error: null, error: null,
}; }
}
thumbScale(fullWidth, fullHeight, thumbWidth, thumbHeight) { thumbScale(fullWidth: number, fullHeight: number, thumbWidth: number, thumbHeight: number) {
if (!fullWidth || !fullHeight) { if (!fullWidth || !fullHeight) {
// Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even // Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even
// log this because it's spammy // log this because it's spammy
@ -61,7 +71,7 @@ export default class MVideoBody extends React.Component {
} }
} }
_getContentUrl() { _getContentUrl(): string|null {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (content.file !== undefined) { if (content.file !== undefined) {
return this.state.decryptedUrl; return this.state.decryptedUrl;
@ -70,7 +80,7 @@ export default class MVideoBody extends React.Component {
} }
} }
_getThumbUrl() { _getThumbUrl(): string|null {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (content.file !== undefined) { if (content.file !== undefined) {
return this.state.decryptedThumbnailUrl; return this.state.decryptedThumbnailUrl;
@ -81,7 +91,8 @@ export default class MVideoBody extends React.Component {
} }
} }
componentDidMount() { async componentDidMount() {
const autoplay = SettingsStore.getValue("autoplayGifsAndVideos") as boolean;
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) { if (content.file !== undefined && this.state.decryptedUrl === null) {
let thumbnailPromise = Promise.resolve(null); let thumbnailPromise = Promise.resolve(null);
@ -92,26 +103,33 @@ export default class MVideoBody extends React.Component {
return URL.createObjectURL(blob); return URL.createObjectURL(blob);
}); });
} }
let decryptedBlob; try {
thumbnailPromise.then((thumbnailUrl) => { const thumbnailUrl = await thumbnailPromise;
return decryptFile(content.file).then(function(blob) { if (autoplay) {
decryptedBlob = blob; console.log("Preloading video");
return URL.createObjectURL(blob); const decryptedBlob = await decryptFile(content.file);
}).then((contentUrl) => { const contentUrl = URL.createObjectURL(decryptedBlob);
this.setState({ this.setState({
decryptedUrl: contentUrl, decryptedUrl: contentUrl,
decryptedThumbnailUrl: thumbnailUrl, decryptedThumbnailUrl: thumbnailUrl,
decryptedBlob: decryptedBlob, decryptedBlob: decryptedBlob,
}); });
this.props.onHeightChanged(); this.props.onHeightChanged();
} else {
console.log("NOT preloading video");
this.setState({
decryptedUrl: null,
decryptedThumbnailUrl: thumbnailUrl,
decryptedBlob: null,
}); });
}).catch((err) => { }
} catch (err) {
console.warn("Unable to decrypt attachment: ", err); console.warn("Unable to decrypt attachment: ", err);
// Set a placeholder image when we can't decrypt the image. // Set a placeholder image when we can't decrypt the image.
this.setState({ this.setState({
error: err, error: err,
}); });
}); }
} }
} }
@ -124,8 +142,35 @@ export default class MVideoBody extends React.Component {
} }
} }
async _videoOnPlay() {
if (this._getContentUrl() || this.state.fetchingData || this.state.error) {
// We have the file, we are fetching the file, or there is an error.
return;
}
this.setState({
// To stop subsequent download attempts
fetchingData: true,
});
const content = this.props.mxEvent.getContent();
if (!content.file) {
this.setState({
error: "No file given in content",
});
return;
}
const decryptedBlob = await decryptFile(content.file);
const contentUrl = URL.createObjectURL(decryptedBlob);
this.setState({
decryptedUrl: contentUrl,
decryptedBlob: decryptedBlob,
fetchingData: false,
});
this.props.onHeightChanged();
}
render() { render() {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
const autoplay = SettingsStore.getValue("autoplayGifsAndVideos");
if (this.state.error !== null) { if (this.state.error !== null) {
return ( return (
@ -136,7 +181,8 @@ export default class MVideoBody extends React.Component {
); );
} }
if (content.file !== undefined && this.state.decryptedUrl === null) { // Important: If we aren't autoplaying and we haven't decrypred it yet, show a video with a poster.
if (content.file !== undefined && this.state.decryptedUrl === null && autoplay) {
// Need to decrypt the attachment // Need to decrypt the attachment
// The attachment is decrypted in componentDidMount. // The attachment is decrypted in componentDidMount.
// For now add an img tag with a spinner. // For now add an img tag with a spinner.
@ -151,7 +197,6 @@ export default class MVideoBody extends React.Component {
const contentUrl = this._getContentUrl(); const contentUrl = this._getContentUrl();
const thumbUrl = this._getThumbUrl(); const thumbUrl = this._getThumbUrl();
const autoplay = SettingsStore.getValue("autoplayGifsAndVideos");
let height = null; let height = null;
let width = null; let width = null;
let poster = null; let poster = null;
@ -170,9 +215,9 @@ export default class MVideoBody extends React.Component {
} }
return ( return (
<span className="mx_MVideoBody"> <span className="mx_MVideoBody">
<video className="mx_MVideoBody" src={contentUrl} alt={content.body} <video className="mx_MVideoBody" src={contentUrl} title={content.body}
controls preload={preload} muted={autoplay} autoPlay={autoplay} controls preload={preload} muted={autoplay} autoPlay={autoplay}
height={height} width={width} poster={poster}> height={height} width={width} poster={poster} onPlay={this._videoOnPlay.bind(this)}>
</video> </video>
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} /> <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />
</span> </span>

View file

@ -32,6 +32,7 @@ import BasicMessageComposer from "./BasicMessageComposer";
import {Key} from "../../../Keyboard"; import {Key} from "../../../Keyboard";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {Action} from "../../../dispatcher/actions"; import {Action} from "../../../dispatcher/actions";
import CountlyAnalytics from "../../../CountlyAnalytics";
function _isReply(mxEvent) { function _isReply(mxEvent) {
const relatesTo = mxEvent.getContent()["m.relates_to"]; const relatesTo = mxEvent.getContent()["m.relates_to"];
@ -182,6 +183,7 @@ export default class EditMessageComposer extends React.Component {
} }
_sendEdit = () => { _sendEdit = () => {
const startTime = CountlyAnalytics.getTimestamp();
const editedEvent = this.props.editState.getEvent(); const editedEvent = this.props.editState.getEvent();
const editContent = createEditContent(this.model, editedEvent); const editContent = createEditContent(this.model, editedEvent);
const newContent = editContent["m.new_content"]; const newContent = editContent["m.new_content"];
@ -190,8 +192,9 @@ export default class EditMessageComposer extends React.Component {
if (this._isContentModified(newContent)) { if (this._isContentModified(newContent)) {
const roomId = editedEvent.getRoomId(); const roomId = editedEvent.getRoomId();
this._cancelPreviousPendingEdit(); this._cancelPreviousPendingEdit();
this.context.sendMessage(roomId, editContent); const prom = this.context.sendMessage(roomId, editContent);
dis.dispatch({action: "message_sent"}); dis.dispatch({action: "message_sent"});
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent);
} }
// close the event editing and focus composer // close the event editing and focus composer

View file

@ -23,7 +23,6 @@ import CallHandler from '../../../CallHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../MatrixClientPeg';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import RoomViewStore from '../../../stores/RoomViewStore';
import Stickerpicker from './Stickerpicker'; import Stickerpicker from './Stickerpicker';
import { makeRoomPermalink } from '../../../utils/permalinks/Permalinks'; import { makeRoomPermalink } from '../../../utils/permalinks/Permalinks';
import ContentMessages from '../../../ContentMessages'; import ContentMessages from '../../../ContentMessages';
@ -254,7 +253,6 @@ export default class MessageComposer extends React.Component {
super(props); super(props);
this.onInputStateChanged = this.onInputStateChanged.bind(this); this.onInputStateChanged = this.onInputStateChanged.bind(this);
this._onRoomStateEvents = this._onRoomStateEvents.bind(this); this._onRoomStateEvents = this._onRoomStateEvents.bind(this);
this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this);
this._onTombstoneClick = this._onTombstoneClick.bind(this); this._onTombstoneClick = this._onTombstoneClick.bind(this);
this.renderPlaceholderText = this.renderPlaceholderText.bind(this); this.renderPlaceholderText = this.renderPlaceholderText.bind(this);
WidgetStore.instance.on(UPDATE_EVENT, this._onWidgetUpdate); WidgetStore.instance.on(UPDATE_EVENT, this._onWidgetUpdate);
@ -262,7 +260,6 @@ export default class MessageComposer extends React.Component {
this._dispatcherRef = null; this._dispatcherRef = null;
this.state = { this.state = {
replyToEvent: RoomViewStore.getQuotingEvent(),
tombstone: this._getRoomTombstone(), tombstone: this._getRoomTombstone(),
canSendMessages: this.props.room.maySendMessage(), canSendMessages: this.props.room.maySendMessage(),
showCallButtons: SettingsStore.getValue("showCallButtonsInComposer"), showCallButtons: SettingsStore.getValue("showCallButtonsInComposer"),
@ -294,7 +291,6 @@ export default class MessageComposer extends React.Component {
componentDidMount() { componentDidMount() {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents); MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents);
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
this._waitForOwnMember(); this._waitForOwnMember();
} }
@ -318,9 +314,6 @@ export default class MessageComposer extends React.Component {
if (MatrixClientPeg.get()) { if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("RoomState.events", this._onRoomStateEvents); MatrixClientPeg.get().removeListener("RoomState.events", this._onRoomStateEvents);
} }
if (this._roomStoreToken) {
this._roomStoreToken.remove();
}
WidgetStore.instance.removeListener(UPDATE_EVENT, this._onWidgetUpdate); WidgetStore.instance.removeListener(UPDATE_EVENT, this._onWidgetUpdate);
ActiveWidgetStore.removeListener('update', this._onActiveWidgetUpdate); ActiveWidgetStore.removeListener('update', this._onActiveWidgetUpdate);
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
@ -341,12 +334,6 @@ export default class MessageComposer extends React.Component {
return this.props.room.currentState.getStateEvents('m.room.tombstone', ''); return this.props.room.currentState.getStateEvents('m.room.tombstone', '');
} }
_onRoomViewStoreUpdate() {
const replyToEvent = RoomViewStore.getQuotingEvent();
if (this.state.replyToEvent === replyToEvent) return;
this.setState({ replyToEvent });
}
onInputStateChanged(inputState) { onInputStateChanged(inputState) {
// Merge the new input state with old to support partial updates // Merge the new input state with old to support partial updates
inputState = Object.assign({}, this.state.inputState, inputState); inputState = Object.assign({}, this.state.inputState, inputState);
@ -371,6 +358,7 @@ export default class MessageComposer extends React.Component {
event_id: createEventId, event_id: createEventId,
room_id: replacementRoomId, room_id: replacementRoomId,
auto_join: true, auto_join: true,
_type: "tombstone", // instrumentation
// Try to join via the server that sent the event. This converts @something:example.org // 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"). // into a server domain by splitting on colons and ignoring the first entry ("@something").
@ -383,7 +371,7 @@ export default class MessageComposer extends React.Component {
} }
renderPlaceholderText() { renderPlaceholderText() {
if (this.state.replyToEvent) { if (this.props.replyToEvent) {
if (this.props.e2eStatus) { if (this.props.e2eStatus) {
return _t('Send an encrypted reply…'); return _t('Send an encrypted reply…');
} else { } else {
@ -429,7 +417,7 @@ export default class MessageComposer extends React.Component {
placeholder={this.renderPlaceholderText()} placeholder={this.renderPlaceholderText()}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
replyToEvent={this.state.replyToEvent} replyToEvent={this.props.replyToEvent}
/>, />,
<UploadButton key="controls_upload" roomId={this.props.room.roomId} />, <UploadButton key="controls_upload" roomId={this.props.room.roomId} />,
<EmojiButton key="emoji_button" addEmoji={this.addEmoji} />, <EmojiButton key="emoji_button" addEmoji={this.addEmoji} />,

View file

@ -42,6 +42,7 @@ import {Key} from "../../../Keyboard";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import RateLimitedFunc from '../../../ratelimitedfunc'; import RateLimitedFunc from '../../../ratelimitedfunc';
import {Action} from "../../../dispatcher/actions"; import {Action} from "../../../dispatcher/actions";
import CountlyAnalytics from "../../../CountlyAnalytics";
function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) { function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent); const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
@ -304,9 +305,10 @@ export default class SendMessageComposer extends React.Component {
const replyToEvent = this.props.replyToEvent; const replyToEvent = this.props.replyToEvent;
if (shouldSend) { if (shouldSend) {
const startTime = CountlyAnalytics.getTimestamp();
const {roomId} = this.props.room; const {roomId} = this.props.room;
const content = createMessageContent(this.model, this.props.permalinkCreator, replyToEvent); const content = createMessageContent(this.model, this.props.permalinkCreator, replyToEvent);
this.context.sendMessage(roomId, content); const prom = this.context.sendMessage(roomId, content);
if (replyToEvent) { if (replyToEvent) {
// Clear reply_to_event as we put the message into the queue // Clear reply_to_event as we put the message into the queue
// if the send fails, retry will handle resending. // if the send fails, retry will handle resending.
@ -316,6 +318,7 @@ export default class SendMessageComposer extends React.Component {
}); });
} }
dis.dispatch({action: "message_sent"}); dis.dispatch({action: "message_sent"});
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content);
} }
this.sendHistoryManager.save(this.model, replyToEvent); this.sendHistoryManager.save(this.model, replyToEvent);

View file

@ -25,6 +25,7 @@ import QuestionDialog from "../../../dialogs/QuestionDialog";
import StyledRadioGroup from '../../../elements/StyledRadioGroup'; import StyledRadioGroup from '../../../elements/StyledRadioGroup';
import {SettingLevel} from "../../../../../settings/SettingLevel"; import {SettingLevel} from "../../../../../settings/SettingLevel";
import SettingsStore from "../../../../../settings/SettingsStore"; import SettingsStore from "../../../../../settings/SettingsStore";
import {UIFeature} from "../../../../../settings/UIFeature";
export default class SecurityRoomSettingsTab extends React.Component { export default class SecurityRoomSettingsTab extends React.Component {
static propTypes = { static propTypes = {
@ -350,6 +351,16 @@ export default class SecurityRoomSettingsTab extends React.Component {
/>; />;
} }
let historySection = (<>
<span className='mx_SettingsTab_subheading'>{_t("Who can read history?")}</span>
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
{this._renderHistory()}
</div>
</>);
if (!SettingsStore.getValue(UIFeature.RoomHistorySettings)) {
historySection = null;
}
return ( return (
<div className="mx_SettingsTab mx_SecurityRoomSettingsTab"> <div className="mx_SettingsTab mx_SecurityRoomSettingsTab">
<div className="mx_SettingsTab_heading">{_t("Security & Privacy")}</div> <div className="mx_SettingsTab_heading">{_t("Security & Privacy")}</div>
@ -371,10 +382,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
{this._renderRoomAccess()} {this._renderRoomAccess()}
</div> </div>
<span className='mx_SettingsTab_subheading'>{_t("Who can read history?")}</span> {historySection}
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
{this._renderHistory()}
</div>
</div> </div>
); );
} }

View file

@ -33,6 +33,7 @@ import SecureBackupPanel from "../../SecureBackupPanel";
import SettingsStore from "../../../../../settings/SettingsStore"; import SettingsStore from "../../../../../settings/SettingsStore";
import {UIFeature} from "../../../../../settings/UIFeature"; import {UIFeature} from "../../../../../settings/UIFeature";
import {isE2eAdvancedPanelPossible} from "../../E2eAdvancedPanel"; import {isE2eAdvancedPanelPossible} from "../../E2eAdvancedPanel";
import CountlyAnalytics from "../../../../../CountlyAnalytics";
export class IgnoredUser extends React.Component { export class IgnoredUser extends React.Component {
static propTypes = { static propTypes = {
@ -102,6 +103,7 @@ export default class SecurityUserSettingsTab extends React.Component {
_updateAnalytics = (checked) => { _updateAnalytics = (checked) => {
checked ? Analytics.enable() : Analytics.disable(); checked ? Analytics.enable() : Analytics.disable();
CountlyAnalytics.instance.enable(/* anonymous = */ !checked);
}; };
_onExportE2eKeysClicked = () => { _onExportE2eKeysClicked = () => {
@ -339,7 +341,7 @@ export default class SecurityUserSettingsTab extends React.Component {
} }
let privacySection; let privacySection;
if (Analytics.canEnable()) { if (Analytics.canEnable() || CountlyAnalytics.instance.canEnable()) {
privacySection = <React.Fragment> privacySection = <React.Fragment>
<div className="mx_SettingsTab_heading">{_t("Privacy")}</div> <div className="mx_SettingsTab_heading">{_t("Privacy")}</div>
<div className="mx_SettingsTab_section"> <div className="mx_SettingsTab_section">

View file

@ -28,6 +28,7 @@ import DMRoomMap from "./utils/DMRoomMap";
import {getAddressType} from "./UserAddress"; import {getAddressType} from "./UserAddress";
import { getE2EEWellKnown } from "./utils/WellKnownUtils"; import { getE2EEWellKnown } from "./utils/WellKnownUtils";
import GroupStore from "./stores/GroupStore"; import GroupStore from "./stores/GroupStore";
import CountlyAnalytics from "./CountlyAnalytics";
// we define a number of interfaces which take their names from the js-sdk // we define a number of interfaces which take their names from the js-sdk
/* eslint-disable camelcase */ /* 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.guestAccess === undefined) opts.guestAccess = true;
if (opts.encryption === undefined) opts.encryption = false; if (opts.encryption === undefined) opts.encryption = false;
const startTime = CountlyAnalytics.getTimestamp();
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const Loader = sdk.getComponent("elements.Spinner"); const Loader = sdk.getComponent("elements.Spinner");
@ -203,6 +206,7 @@ export default function createRoom(opts: IOpts): Promise<string | null> {
joining: true, joining: true,
}); });
} }
CountlyAnalytics.instance.trackRoomCreate(startTime, roomId);
return roomId; return roomId;
}, function(err) { }, function(err) {
// Raise the error if the caller requested that we do so. // Raise the error if the caller requested that we do so.

View file

@ -1003,11 +1003,11 @@
"Members only (since the point in time of selecting this option)": "Members only (since the point in time of selecting this option)", "Members only (since the point in time of selecting this option)": "Members only (since the point in time of selecting this option)",
"Members only (since they were invited)": "Members only (since they were invited)", "Members only (since they were invited)": "Members only (since they were invited)",
"Members only (since they joined)": "Members only (since they joined)", "Members only (since they joined)": "Members only (since they joined)",
"Who can read history?": "Who can read history?",
"Security & Privacy": "Security & Privacy", "Security & Privacy": "Security & Privacy",
"Once enabled, encryption cannot be disabled.": "Once enabled, encryption cannot be disabled.", "Once enabled, encryption cannot be disabled.": "Once enabled, encryption cannot be disabled.",
"Encrypted": "Encrypted", "Encrypted": "Encrypted",
"Who can access this room?": "Who can access this room?", "Who can access this room?": "Who can access this room?",
"Who can read history?": "Who can read history?",
"Unable to revoke sharing for email address": "Unable to revoke sharing for email address", "Unable to revoke sharing for email address": "Unable to revoke sharing for email address",
"Unable to share email address": "Unable to share email address", "Unable to share email address": "Unable to share email address",
"Your email address hasn't been verified yet": "Your email address hasn't been verified yet", "Your email address hasn't been verified yet": "Your email address hasn't been verified yet",
@ -1702,6 +1702,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.", "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", "Update community": "Update community",
"An error has occurred.": "An error has occurred.", "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",
"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.",
"Feedback": "Feedback",
"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.", "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.", "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.", "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.",
@ -1763,6 +1775,8 @@
"Verify session": "Verify session", "Verify session": "Verify session",
"Your homeserver doesn't seem to support this feature.": "Your homeserver doesn't seem to support this feature.", "Your homeserver doesn't seem to support this feature.": "Your homeserver doesn't seem to support this feature.",
"Message edits": "Message edits", "Message edits": "Message edits",
"Modal Widget": "Modal Widget",
"Data on this screen is shared with %(widgetDomain)s": "Data on this screen is shared with %(widgetDomain)s",
"Your account is not secure": "Your account is not secure", "Your account is not secure": "Your account is not secure",
"Your password": "Your password", "Your password": "Your password",
"This session, or the other session": "This session, or the other session", "This session, or the other session": "This session, or the other session",
@ -1772,9 +1786,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:", "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.", "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", "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.", "Please fill why you're reporting.": "Please fill why you're reporting.",
"Report Content to Your Homeserver Administrator": "Report Content to Your Homeserver Administrator", "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.", "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.",
@ -2133,7 +2144,6 @@
"Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s", "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", "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", "Failed to find the general chat for this community": "Failed to find the general chat for this community",
"Feedback": "Feedback",
"Notification settings": "Notification settings", "Notification settings": "Notification settings",
"Security & privacy": "Security & privacy", "Security & privacy": "Security & privacy",
"All settings": "All settings", "All settings": "All settings",

View file

@ -630,6 +630,10 @@ export const SETTINGS: {[setting: string]: ISetting} = {
supportedLevels: LEVELS_ACCOUNT_SETTINGS, supportedLevels: LEVELS_ACCOUNT_SETTINGS,
default: null, default: null,
}, },
[UIFeature.RoomHistorySettings]: {
supportedLevels: LEVELS_UI_FEATURE,
default: true,
},
[UIFeature.AdvancedEncryption]: { [UIFeature.AdvancedEncryption]: {
supportedLevels: LEVELS_UI_FEATURE, supportedLevels: LEVELS_UI_FEATURE,
default: true, default: true,

View file

@ -31,4 +31,5 @@ export enum UIFeature {
Flair = "UIFeature.flair", Flair = "UIFeature.flair",
Communities = "UIFeature.communities", Communities = "UIFeature.communities",
AdvancedSettings = "UIFeature.advancedSettings", AdvancedSettings = "UIFeature.advancedSettings",
RoomHistorySettings = "UIFeature.roomHistorySettings",
} }

View file

@ -0,0 +1,87 @@
/*
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 { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import defaultDispatcher from "../dispatcher/dispatcher";
import { ActionPayload } from "../dispatcher/payloads";
import Modal, {IHandle, IModal} from "../Modal";
import ModalWidgetDialog from "../components/views/dialogs/ModalWidgetDialog";
import {WidgetMessagingStore} from "./widgets/WidgetMessagingStore";
import {IModalWidgetOpenRequestData, IModalWidgetReturnData, Widget} from "matrix-widget-api";
interface IState {
modal?: IModal<any>;
openedFromId?: string;
}
export class ModalWidgetStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new ModalWidgetStore();
private modalInstance: IHandle<void[]> = null;
private openSourceWidgetId: string = null;
private constructor() {
super(defaultDispatcher, {});
}
public static get instance(): ModalWidgetStore {
return ModalWidgetStore.internalInstance;
}
protected async onAction(payload: ActionPayload): Promise<any> {
// nothing
}
public canOpenModalWidget = () => {
return !this.modalInstance;
};
public openModalWidget = (requestData: IModalWidgetOpenRequestData, sourceWidget: Widget) => {
if (this.modalInstance) return;
this.openSourceWidgetId = sourceWidget.id;
this.modalInstance = Modal.createTrackedDialog('Modal Widget', '', ModalWidgetDialog, {
widgetDefinition: {...requestData},
sourceWidgetId: sourceWidget.id,
onFinished: (success: boolean, data?: IModalWidgetReturnData) => {
if (!success) {
this.closeModalWidget(sourceWidget, { "m.exited": true });
} else {
this.closeModalWidget(sourceWidget, data);
}
this.openSourceWidgetId = null;
this.modalInstance = null;
},
});
};
public closeModalWidget = (sourceWidget: Widget, data?: IModalWidgetReturnData) => {
if (!this.modalInstance) return;
if (this.openSourceWidgetId === sourceWidget.id) {
this.openSourceWidgetId = null;
this.modalInstance.close();
this.modalInstance = null;
const sourceMessaging = WidgetMessagingStore.instance.getMessaging(sourceWidget);
if (!sourceMessaging) {
console.error("No source widget messaging for modal widget");
return;
}
sourceMessaging.notifyModalWidgetClose(data);
}
};
}
window.mxModalWidgetStore = ModalWidgetStore.instance;

View file

@ -28,6 +28,7 @@ import { _t } from '../languageHandler';
import { getCachedRoomIDForAlias, storeRoomAliasInCache } from '../RoomAliasCache'; import { getCachedRoomIDForAlias, storeRoomAliasInCache } from '../RoomAliasCache';
import {ActionPayload} from "../dispatcher/payloads"; import {ActionPayload} from "../dispatcher/payloads";
import {retry} from "../utils/promise"; import {retry} from "../utils/promise";
import CountlyAnalytics from "../CountlyAnalytics";
const NUM_JOIN_RETRY = 5; const NUM_JOIN_RETRY = 5;
@ -264,6 +265,7 @@ class RoomViewStore extends Store<ActionPayload> {
} }
private async joinRoom(payload: ActionPayload) { private async joinRoom(payload: ActionPayload) {
const startTime = CountlyAnalytics.getTimestamp();
this.setState({ this.setState({
joining: true, joining: true,
}); });
@ -275,6 +277,7 @@ class RoomViewStore extends Store<ActionPayload> {
// if we received a Gateway timeout then retry // if we received a Gateway timeout then retry
return err.httpStatus === 504; 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 // 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 // have come down the sync stream yet, and that's the point at which we'd consider the user joined to the

View file

@ -32,6 +32,7 @@ import {
Widget, Widget,
WidgetApiToWidgetAction, WidgetApiToWidgetAction,
WidgetApiFromWidgetAction, WidgetApiFromWidgetAction,
IModalWidgetOpenRequest,
} from "matrix-widget-api"; } from "matrix-widget-api";
import { StopGapWidgetDriver } from "./StopGapWidgetDriver"; import { StopGapWidgetDriver } from "./StopGapWidgetDriver";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
@ -49,6 +50,10 @@ import defaultDispatcher from "../../dispatcher/dispatcher";
import { ElementWidgetActions } from "./ElementWidgetActions"; import { ElementWidgetActions } from "./ElementWidgetActions";
import Modal from "../../Modal"; import Modal from "../../Modal";
import WidgetOpenIDPermissionsDialog from "../../components/views/dialogs/WidgetOpenIDPermissionsDialog"; import WidgetOpenIDPermissionsDialog from "../../components/views/dialogs/WidgetOpenIDPermissionsDialog";
import {ModalWidgetStore} from "../ModalWidgetStore";
import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
import {getCustomTheme} from "../../theme";
import CountlyAnalytics from "../../CountlyAnalytics";
// TODO: Destroy all of this code // TODO: Destroy all of this code
@ -102,9 +107,25 @@ class ElementWidget extends Widget {
// v1 widgets default to jitsi.riot.im regardless of user settings // v1 widgets default to jitsi.riot.im regardless of user settings
domain = "jitsi.riot.im"; domain = "jitsi.riot.im";
} }
let theme = new ThemeWatcher().getEffectiveTheme();
if (theme.startsWith("custom-")) {
const customTheme = getCustomTheme(theme.substr(7));
// Jitsi only understands light/dark
theme = customTheme.is_dark ? "dark" : "light";
}
// only allow light/dark through, defaulting to dark as that was previously the only state
// accounts for legacy-light/legacy-dark themes too
if (theme.includes("light")) {
theme = "light";
} else {
theme = "dark";
}
return { return {
...super.rawData, ...super.rawData,
theme: SettingsStore.getValue("theme"), theme,
conferenceId, conferenceId,
domain, domain,
}; };
@ -201,7 +222,7 @@ export class StopGapWidget extends EventEmitter {
} }
private onOpenIdReq = async (ev: CustomEvent<IGetOpenIDActionRequest>) => { private onOpenIdReq = async (ev: CustomEvent<IGetOpenIDActionRequest>) => {
if (ev?.detail?.widgetId !== this.widgetId) return; ev.preventDefault();
const rawUrl = this.appTileProps.app.url; const rawUrl = this.appTileProps.app.url;
const widgetSecurityKey = WidgetUtils.getWidgetSecurityKey(this.widgetId, rawUrl, this.appTileProps.userWidget); const widgetSecurityKey = WidgetUtils.getWidgetSecurityKey(this.widgetId, rawUrl, this.appTileProps.userWidget);
@ -249,6 +270,20 @@ export class StopGapWidget extends EventEmitter {
}); });
}; };
private onOpenModal = async (ev: CustomEvent<IModalWidgetOpenRequest>) => {
ev.preventDefault();
if (ModalWidgetStore.instance.canOpenModalWidget()) {
ModalWidgetStore.instance.openModalWidget(ev.detail.data, this.mockWidget);
this.messaging.transport.reply(ev.detail, {}); // ack
} else {
this.messaging.transport.reply(ev.detail, {
error: {
message: "Unable to open modal at this time",
},
})
}
};
public start(iframe: HTMLIFrameElement) { public start(iframe: HTMLIFrameElement) {
if (this.started) return; if (this.started) return;
const driver = new StopGapWidgetDriver( this.appTileProps.whitelistCapabilities || []); const driver = new StopGapWidgetDriver( this.appTileProps.whitelistCapabilities || []);
@ -256,6 +291,7 @@ export class StopGapWidget extends EventEmitter {
this.messaging.on("preparing", () => this.emit("preparing")); this.messaging.on("preparing", () => this.emit("preparing"));
this.messaging.on("ready", () => this.emit("ready")); this.messaging.on("ready", () => this.emit("ready"));
this.messaging.on(`action:${WidgetApiFromWidgetAction.GetOpenIDCredentials}`, this.onOpenIdReq); this.messaging.on(`action:${WidgetApiFromWidgetAction.GetOpenIDCredentials}`, this.onOpenIdReq);
this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal);
WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.messaging); WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.messaging);
if (!this.appTileProps.userWidget && this.appTileProps.room) { if (!this.appTileProps.userWidget && this.appTileProps.room) {
@ -266,6 +302,7 @@ export class StopGapWidget extends EventEmitter {
this.messaging.on("action:set_always_on_screen", this.messaging.on("action:set_always_on_screen",
(ev: CustomEvent<IStickyActionRequest>) => { (ev: CustomEvent<IStickyActionRequest>) => {
if (this.messaging.hasCapability(MatrixCapabilities.AlwaysOnScreen)) { if (this.messaging.hasCapability(MatrixCapabilities.AlwaysOnScreen)) {
CountlyAnalytics.instance.trackJoinCall(this.appTileProps.room.roomId, true, true);
ActiveWidgetStore.setWidgetPersistence(this.mockWidget.id, ev.detail.data.value); ActiveWidgetStore.setWidgetPersistence(this.mockWidget.id, ev.detail.data.value);
ev.preventDefault(); ev.preventDefault();
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); // ack this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); // ack

View file

@ -6506,8 +6506,8 @@ mathml-tag-names@^2.0.1:
integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": "matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop":
version "8.5.0" version "9.0.1"
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/9f713781cdfea2349115ffaac2d665e8b07fd5dc" resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/f8863d5c2488a31a093f5a1b11761d7ac3829470"
dependencies: dependencies:
"@babel/runtime" "^7.11.2" "@babel/runtime" "^7.11.2"
another-json "^0.2.0" another-json "^0.2.0"