From 2a48d3c9bc83e9083c3a9d7366c008f3909c9f6d Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 07:40:39 +0100 Subject: [PATCH 001/131] First pass at a PosthogAnalytics class --- package.json | 1 + src/PosthogAnalytics.ts | 82 ++++++++++++++++++++++++++++++++++ test/PosthogAnalytics-test.ts | 84 +++++++++++++++++++++++++++++++++++ yarn.lock | 12 +++++ 4 files changed, 179 insertions(+) create mode 100644 src/PosthogAnalytics.ts create mode 100644 test/PosthogAnalytics-test.ts diff --git a/package.json b/package.json index e80ed8dd5a..805531abff 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "pako": "^2.0.3", "parse5": "^6.0.1", "png-chunks-extract": "^1.0.0", + "posthog-js": "^1.12.1", "prop-types": "^15.7.2", "qrcode": "^1.4.4", "re-resizable": "^6.9.0", diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts new file mode 100644 index 0000000000..1ca2d37de7 --- /dev/null +++ b/src/PosthogAnalytics.ts @@ -0,0 +1,82 @@ +import posthog from 'posthog-js'; +import SdkConfig from './SdkConfig'; + +export interface IEvent { + key: string; + properties: {} +} + +export interface IOnboardingLoginBegin extends IEvent { + key: "onboarding_login_begin", +} + +const hashHex = async (input: string): Promise => { + const buf = new TextEncoder().encode(input); + const digestBuf = await window.crypto.subtle.digest("sha-256", buf); + return [...new Uint8Array(digestBuf)].map((b: number) => b.toString(16).padStart(2, "0")).join(""); +}; + +export class PosthogAnalytics { + private onlyTrackAnonymousEvents = false; + private initialised = false; + private posthog = null; + + private static _instance = null; + + public static instance(): PosthogAnalytics { + if (!this.instance) { + this._instance = new PosthogAnalytics(posthog); + } + return this._instance; + } + + constructor(posthog) { + this.posthog = posthog; + } + + public init(onlyTrackAnonymousEvents: boolean) { + if (Boolean(navigator.doNotTrack === "1")) { + this.initialised = false; + return; + } + this.onlyTrackAnonymousEvents = onlyTrackAnonymousEvents; + const posthogConfig = SdkConfig.get()["posthog"]; + if (posthogConfig) { + console.log(`Initialising Posthog for ${posthogConfig.apiHost}`); + this.posthog.init(posthogConfig.projectApiKey, { api_host: posthogConfig.apiHost }); + this.initialised = true; + } + } + + public isInitialised(): boolean { + return this.initialised; + } + + public setOnlyTrackAnonymousEvents(enabled: boolean) { + this.onlyTrackAnonymousEvents = enabled; + } + + public track( + key: E["key"], + properties: E["properties"], + anonymous = false, + ) { + if (!this.initialised) return; + if (this.onlyTrackAnonymousEvents && !anonymous) return; + + this.posthog.capture(key, properties); + } + + public async trackRoomEvent( + key: E["key"], + roomId: string, + properties: E["properties"], + ...args + ) { + const updatedProperties = { + ...properties, + hashedRoomId: roomId ? await hashHex(roomId) : null, + }; + this.track(key, updatedProperties, ...args); + } +} diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts new file mode 100644 index 0000000000..a37d5cb2c8 --- /dev/null +++ b/test/PosthogAnalytics-test.ts @@ -0,0 +1,84 @@ +import { IEvent, PosthogAnalytics } from '../src/PosthogAnalytics'; +import SdkConfig from '../src/SdkConfig'; +const crypto = require('crypto'); + +class FakePosthog { + public capture; + public init; + + constructor() { + this.capture = jest.fn(); + this.init = jest.fn(); + } +} + +export interface ITestEvent extends IEvent { + key: "jest_test_event", + properties: { + foo: string + } +} + +describe("PosthogAnalytics", () => { + let analytics: PosthogAnalytics; + let fakePosthog: FakePosthog; + + beforeEach(() => { + fakePosthog = new FakePosthog(); + analytics = new PosthogAnalytics(fakePosthog); + window.crypto = { + subtle: crypto.webcrypto.subtle, + }; + }); + + afterEach(() => { + navigator.doNotTrack = null; + window.crypto = null; + }); + + it("Should not initialise if DNT is enabled", () => { + navigator.doNotTrack = "1"; + analytics.init(false); + expect(analytics.isInitialised()).toBe(false); + }); + + it("Should not initialise if config is not set", () => { + jest.spyOn(SdkConfig, "get").mockReturnValue({}); + analytics.init(false); + expect(analytics.isInitialised()).toBe(false); + }); + + it("Should initialise if config is set", () => { + jest.spyOn(SdkConfig, "get").mockReturnValue({ + posthog: { + projectApiKey: "foo", + apiHost: "bar", + }, + }); + analytics.init(false); + expect(analytics.isInitialised()).toBe(true); + }); + + it("Should pass track() to posthog", () => { + analytics.init(false); + analytics.track("jest_test_event", { + foo: "bar", + }); + expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); + expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ foo: "bar" }); + }); + + it("Should pass trackRoomEvent to posthog", () => { + analytics.init(false); + const roomId = "42"; + return analytics.trackRoomEvent("jest_test_event", roomId, { + foo: "bar", + }).then(() => { + expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); + expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ + foo: "bar", + hashedRoomId: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049", + }); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 96c02681fd..9d41c37b12 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3601,6 +3601,11 @@ fbjs@^0.8.4: setimmediate "^1.0.5" ua-parser-js "^0.7.18" +fflate@^0.4.1: + version "0.4.8" + resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae" + integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA== + file-entry-cache@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.0.tgz#7921a89c391c6d93efec2169ac6bf300c527ea0a" @@ -6287,6 +6292,13 @@ postcss@^8.0.2: nanoid "^3.1.20" source-map "^0.6.1" +posthog-js@^1.12.1: + version "1.12.1" + resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.12.1.tgz#97834ee2574f34ffb5db2f5b07452c847e3c4d27" + integrity sha512-Y3lzcWkS8xFY6Ryj3I4ees7qWP2WGkLw0Arcbk5xaT0+5YlA6UC2jlL/+fN9bz/Bl62EoN3BML901Cuot/QNjg== + dependencies: + fflate "^0.4.1" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" From d4550c1a28a61a8026590f83ebea5c6589406a8f Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 07:42:44 +0100 Subject: [PATCH 002/131] Remove console logging --- src/PosthogAnalytics.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 1ca2d37de7..5b2a601adc 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -42,7 +42,6 @@ export class PosthogAnalytics { this.onlyTrackAnonymousEvents = onlyTrackAnonymousEvents; const posthogConfig = SdkConfig.get()["posthog"]; if (posthogConfig) { - console.log(`Initialising Posthog for ${posthogConfig.apiHost}`); this.posthog.init(posthogConfig.projectApiKey, { api_host: posthogConfig.apiHost }); this.initialised = true; } From 3135e425865232bfd1e1b131a97ec067c150802d Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 07:42:55 +0100 Subject: [PATCH 003/131] Add test for silently ignoring messages when not initialised --- test/PosthogAnalytics-test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index a37d5cb2c8..56e6af8666 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -81,4 +81,12 @@ describe("PosthogAnalytics", () => { }); }); }); + + it("Should silently not send messages if not inititalised", () => { + analytics.track("jest_test_event", { + foo: "bar", + }); + + expect(fakePosthog.capture.mock.calls.length).toBe(0); + }); }); From 74b0e52f9a2ac243af50ab419da9c296b74a1540 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 08:23:42 +0100 Subject: [PATCH 004/131] Enforce anon/pseudo-anon via types --- src/PosthogAnalytics.ts | 48 +++++++++++++++++++++++++---------- test/PosthogAnalytics-test.ts | 25 +++++++++++++----- 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 5b2a601adc..133c9275d4 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -1,11 +1,28 @@ import posthog from 'posthog-js'; import SdkConfig from './SdkConfig'; -export interface IEvent { - key: string; +interface IEvent { + // The event name that will be used by PostHog. + // TODO: standard format (camel case? snake? UpperCase?) + eventName: string; + + // The properties of the event that will be stored in PostHog. properties: {} } +// If an event extends IPseudonymousEvent, the event contains pseudonymous data +// that won't be sent unless the user has explicitly consented to pseudonymous tracking. +// For example, hashed user IDs or room IDs. +export interface IPseudonymousEvent extends IEvent {} + +// If an event extends IAnonymousEvent, the event strictly contains *only* anonymous data which +// may be sent without explicit user consent. +export interface IAnonymousEvent extends IEvent {} + +export interface IRoomEvent extends IPseudonymousEvent { + hashedRoomId: string +} + export interface IOnboardingLoginBegin extends IEvent { key: "onboarding_login_begin", } @@ -55,27 +72,32 @@ export class PosthogAnalytics { this.onlyTrackAnonymousEvents = enabled; } - public track( - key: E["key"], + public trackPseudonymousEvent( + eventName: E["eventName"], properties: E["properties"], - anonymous = false, ) { if (!this.initialised) return; - if (this.onlyTrackAnonymousEvents && !anonymous) return; - - this.posthog.capture(key, properties); + if (this.onlyTrackAnonymousEvents) return; + this.posthog.capture(eventName, properties); } - public async trackRoomEvent( - key: E["key"], - roomId: string, + public trackAnonymousEvent( + eventName: E["eventName"], properties: E["properties"], - ...args + ) { + if (!this.initialised) return; + this.posthog.capture(eventName, properties); + } + + public async trackRoomEvent( + eventName: E["eventName"], + roomId: string, + properties: Omit, ) { const updatedProperties = { ...properties, hashedRoomId: roomId ? await hashHex(roomId) : null, }; - this.track(key, updatedProperties, ...args); + this.trackPseudonymousEvent(eventName, updatedProperties); } } diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index 56e6af8666..dfadac921d 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -1,4 +1,4 @@ -import { IEvent, PosthogAnalytics } from '../src/PosthogAnalytics'; +import { IAnonymousEvent, IRoomEvent, PosthogAnalytics } from '../src/PosthogAnalytics'; import SdkConfig from '../src/SdkConfig'; const crypto = require('crypto'); @@ -12,13 +12,20 @@ class FakePosthog { } } -export interface ITestEvent extends IEvent { +export interface ITestEvent extends IAnonymousEvent { key: "jest_test_event", properties: { foo: string } } +export interface ITestRoomEvent extends IRoomEvent { + key: "jest_test_room_event", + properties: { + foo: string + } +} + describe("PosthogAnalytics", () => { let analytics: PosthogAnalytics; let fakePosthog: FakePosthog; @@ -61,7 +68,7 @@ describe("PosthogAnalytics", () => { it("Should pass track() to posthog", () => { analytics.init(false); - analytics.track("jest_test_event", { + analytics.trackAnonymousEvent("jest_test_event", { foo: "bar", }); expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); @@ -71,7 +78,7 @@ describe("PosthogAnalytics", () => { it("Should pass trackRoomEvent to posthog", () => { analytics.init(false); const roomId = "42"; - return analytics.trackRoomEvent("jest_test_event", roomId, { + return analytics.trackRoomEvent("jest_test_event", roomId, { foo: "bar", }).then(() => { expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); @@ -82,11 +89,17 @@ describe("PosthogAnalytics", () => { }); }); - it("Should silently not send messages if not inititalised", () => { - analytics.track("jest_test_event", { + it("Should silently not track if not inititalised", () => { + analytics.trackAnonymousEvent("jest_test_event", { foo: "bar", }); expect(fakePosthog.capture.mock.calls.length).toBe(0); }); + + it("Should not track non-anonymous messages if onlyTrackAnonymousEvents is true", () => { + analytics.trackAnonymousEvent("jest_test_event", { + foo: "bar", + }); + }); }); From 4b0cb409a078e0baa701e91ff88eae2d49e41bb9 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 08:38:58 +0100 Subject: [PATCH 005/131] Add identifyUser --- src/PosthogAnalytics.ts | 5 +++++ test/PosthogAnalytics-test.ts | 18 +++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 133c9275d4..404f0e5f20 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -64,6 +64,11 @@ export class PosthogAnalytics { } } + public async identifyUser(userId: string) { + if (this.onlyTrackAnonymousEvents) return; + this.posthog.identify(await hashHex(userId)); + } + public isInitialised(): boolean { return this.initialised; } diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index dfadac921d..fd49255fa1 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -5,10 +5,12 @@ const crypto = require('crypto'); class FakePosthog { public capture; public init; + public identify; constructor() { this.capture = jest.fn(); this.init = jest.fn(); + this.identify = jest.fn(); } } @@ -75,7 +77,7 @@ describe("PosthogAnalytics", () => { expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ foo: "bar" }); }); - it("Should pass trackRoomEvent to posthog", () => { + it("Should pass trackRoomEvent to posthog", async () => { analytics.init(false); const roomId = "42"; return analytics.trackRoomEvent("jest_test_event", roomId, { @@ -102,4 +104,18 @@ describe("PosthogAnalytics", () => { foo: "bar", }); }); + + it("Should identify the user to posthog if onlyTrackAnonymousEvents is false", async () => { + analytics.init(false); + await analytics.identifyUser("foo"); + expect(fakePosthog.identify.mock.calls[0][0]) + .toBe("2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"); + }); + + it("Should not identify the user to posthog if onlyTrackAnonymousEvents is true", async () => { + analytics.init(true); + return analytics.identifyUser("foo").then(() => { + expect(fakePosthog.identify.mock.calls.length).toBe(0); + }); + }); }); From b5564a0de08088ad59179f7948c110b118f52e54 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 08:42:29 +0100 Subject: [PATCH 006/131] Add getAnalytics helper --- src/PosthogAnalytics.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 404f0e5f20..0195142d9b 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -106,3 +106,7 @@ export class PosthogAnalytics { this.trackPseudonymousEvent(eventName, updatedProperties); } } + +export default function getAnalytics() { + return PosthogAnalytics.instance(); +} From 678474c0e8abac4bb1353329ed85aebe835def03 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 11:23:18 +0100 Subject: [PATCH 007/131] Fix missing underscore --- src/PosthogAnalytics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 0195142d9b..1b5988e0df 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -41,7 +41,7 @@ export class PosthogAnalytics { private static _instance = null; public static instance(): PosthogAnalytics { - if (!this.instance) { + if (!this._instance) { this._instance = new PosthogAnalytics(posthog); } return this._instance; From d9594c428a8defd13ca31dd4200619bbf39c6141 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 11:23:42 +0100 Subject: [PATCH 008/131] login event should be IAnonymousEvent --- src/PosthogAnalytics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 1b5988e0df..d1149095e0 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -23,7 +23,7 @@ export interface IRoomEvent extends IPseudonymousEvent { hashedRoomId: string } -export interface IOnboardingLoginBegin extends IEvent { +export interface IOnboardingLoginBegin extends IAnonymousEvent { key: "onboarding_login_begin", } From 7e549f84e7da7a6286c1cfc1f806f9b0b1fb91fc Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 11:23:55 +0100 Subject: [PATCH 009/131] Don't make getAnalytics the default export, its weird --- src/PosthogAnalytics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index d1149095e0..80b5861c17 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -107,6 +107,6 @@ export class PosthogAnalytics { } } -export default function getAnalytics() { +export function getAnalytics(): PosthogAnalytics { return PosthogAnalytics.instance(); } From 6da3cc8ca1baf268d768ed63e4b9cb16e40ce33d Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 13:48:10 +0100 Subject: [PATCH 010/131] Redact sensitive data --- src/PosthogAnalytics.ts | 103 +++++++++++++++++++++++++++++----- test/PosthogAnalytics-test.ts | 66 ++++++++++++++++------ 2 files changed, 138 insertions(+), 31 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 80b5861c17..d5f9b1d83c 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -1,4 +1,4 @@ -import posthog from 'posthog-js'; +import posthog, { PostHog } from 'posthog-js'; import SdkConfig from './SdkConfig'; interface IEvent { @@ -10,6 +10,11 @@ interface IEvent { properties: {} } +export enum Anonymity { + Anonymous, + Pseudonymous +} + // If an event extends IPseudonymousEvent, the event contains pseudonymous data // that won't be sent unless the user has explicitly consented to pseudonymous tracking. // For example, hashed user IDs or room IDs. @@ -33,10 +38,38 @@ const hashHex = async (input: string): Promise => { 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", +]); + +export async function getRedactedCurrentLocation(origin: string, hash: string, pathname: string, anonymity: Anonymity) { + // Redact PII from the current location. + // If anonymous is true, redact entirely, if false, substitute it with a hash. + // For known screens, assumes a URL structure of //might/be/pii + if (origin.startsWith('file://')) { + pathname = "//"; + } + + let [_, screen, ...parts] = hash.split("/"); + + if (!knownScreens.has(screen)) { + screen = ""; + } + + for (let i = 0; i < parts.length; i++) { + parts[i] = anonymity === Anonymity.Anonymous ? `` : await hashHex(parts[i]); + } + + const hashStr = `${_}/${screen}/${parts.join("/")}`; + return origin + pathname + hashStr; +} + export class PosthogAnalytics { private onlyTrackAnonymousEvents = false; private initialised = false; - private posthog = null; + private posthog?: PostHog = null; + private redactedCurrentLocation = null; private static _instance = null; @@ -47,23 +80,63 @@ export class PosthogAnalytics { return this._instance; } - constructor(posthog) { + constructor(posthog: PostHog) { this.posthog = posthog; } - public init(onlyTrackAnonymousEvents: boolean) { + public async init(onlyTrackAnonymousEvents: boolean) { if (Boolean(navigator.doNotTrack === "1")) { this.initialised = false; return; } this.onlyTrackAnonymousEvents = onlyTrackAnonymousEvents; + const posthogConfig = SdkConfig.get()["posthog"]; if (posthogConfig) { - this.posthog.init(posthogConfig.projectApiKey, { api_host: posthogConfig.apiHost }); + // Update the redacted current location before initialising posthog, as posthog.init triggers + // an immediate pageview event which calls the sanitize_properties callback + await this.updateRedactedCurrentLocation(); + + this.posthog.init(posthogConfig.projectApiKey, { + api_host: posthogConfig.apiHost, + autocapture: false, + mask_all_text: true, + mask_all_element_attributes: true, + sanitize_properties: this.sanitizeProperties.bind(this), + }); this.initialised = true; } } + private async updateRedactedCurrentLocation() { + // TODO only calculate this when the location changes as its expensive + const { origin, hash, pathname } = window.location; + this.redactedCurrentLocation = await getRedactedCurrentLocation( + origin, hash, pathname, this.onlyTrackAnonymousEvents ? Anonymity.Anonymous : Anonymity.Pseudonymous); + } + + private sanitizeProperties(properties: posthog.Properties, _: string): posthog.Properties { + // Sanitize posthog's built in properties which leak PII e.g. url reporting + // see utils.js _.info.properties in posthog-js + + // this.redactedCurrentLocation needs to have been updated prior to reaching this point as + // updating it involves async, which this callback is not + properties['$current_url'] = this.redactedCurrentLocation; + + if (this.onlyTrackAnonymousEvents) { + // drop referrer information for anonymous users + properties['$referrer'] = null; + properties['$referring_domain'] = null; + properties['$initial_referrer'] = null; + properties['$initial_referring_domain'] = null; + + // drop device ID, which is a UUID persisted in local storage + properties['$device_id'] = null; + } + + return properties; + } + public async identifyUser(userId: string) { if (this.onlyTrackAnonymousEvents) return; this.posthog.identify(await hashHex(userId)); @@ -77,21 +150,25 @@ export class PosthogAnalytics { this.onlyTrackAnonymousEvents = enabled; } - public trackPseudonymousEvent( - eventName: E["eventName"], - properties: E["properties"], - ) { + private async capture(eventName: string, properties: posthog.Properties, anonymity: Anonymity) { if (!this.initialised) return; - if (this.onlyTrackAnonymousEvents) return; + await this.updateRedactedCurrentLocation(anonymity); this.posthog.capture(eventName, properties); } - public trackAnonymousEvent( + public async trackPseudonymousEvent( eventName: E["eventName"], properties: E["properties"], ) { - if (!this.initialised) return; - this.posthog.capture(eventName, properties); + if (this.onlyTrackAnonymousEvents) return; + this.capture(eventName, properties, Anonymity.Pseudonyomous); + } + + public async trackAnonymousEvent( + eventName: E["eventName"], + properties: E["properties"], + ) { + this.capture(eventName, properties, Anonymity.Anonymous); } public async trackRoomEvent( diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index fd49255fa1..e9efeffa7d 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -1,4 +1,5 @@ -import { IAnonymousEvent, IRoomEvent, PosthogAnalytics } from '../src/PosthogAnalytics'; +import { Anonymity, getRedactedCurrentLocation, IAnonymousEvent, IRoomEvent, + PosthogAnalytics } from '../src/PosthogAnalytics'; import SdkConfig from '../src/SdkConfig'; const crypto = require('crypto'); @@ -68,9 +69,9 @@ describe("PosthogAnalytics", () => { expect(analytics.isInitialised()).toBe(true); }); - it("Should pass track() to posthog", () => { + it("Should pass track() to posthog", async () => { analytics.init(false); - analytics.trackAnonymousEvent("jest_test_event", { + await analytics.trackAnonymousEvent("jest_test_event", { foo: "bar", }); expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); @@ -80,29 +81,29 @@ describe("PosthogAnalytics", () => { it("Should pass trackRoomEvent to posthog", async () => { analytics.init(false); const roomId = "42"; - return analytics.trackRoomEvent("jest_test_event", roomId, { + await analytics.trackRoomEvent("jest_test_event", roomId, { foo: "bar", - }).then(() => { - expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); - expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ - foo: "bar", - hashedRoomId: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049", - }); + }); + expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); + expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ + foo: "bar", + hashedRoomId: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049", }); }); - it("Should silently not track if not inititalised", () => { - analytics.trackAnonymousEvent("jest_test_event", { + it("Should silently not track if not inititalised", async () => { + await analytics.trackAnonymousEvent("jest_test_event", { foo: "bar", }); - expect(fakePosthog.capture.mock.calls.length).toBe(0); }); - it("Should not track non-anonymous messages if onlyTrackAnonymousEvents is true", () => { - analytics.trackAnonymousEvent("jest_test_event", { + it("Should not track non-anonymous messages if onlyTrackAnonymousEvents is true", async () => { + analytics.init(true); + await analytics.trackPseudonymousEvent("jest_test_event", { foo: "bar", }); + expect(fakePosthog.capture.mock.calls.length).toBe(0); }); it("Should identify the user to posthog if onlyTrackAnonymousEvents is false", async () => { @@ -114,8 +115,37 @@ describe("PosthogAnalytics", () => { it("Should not identify the user to posthog if onlyTrackAnonymousEvents is true", async () => { analytics.init(true); - return analytics.identifyUser("foo").then(() => { - expect(fakePosthog.identify.mock.calls.length).toBe(0); - }); + await analytics.identifyUser("foo"); + expect(fakePosthog.identify.mock.calls.length).toBe(0); + }); + + it("Should pseudonymise a location of a known screen", async () => { + const location = await getRedactedCurrentLocation( + "https://foo.bar", "#/register/some/pii", "/", Anonymity.Pseudonymous); + expect(location).toBe( + `https://foo.bar/#/register/\ +a6b46dd0d1ae5e86cbc8f37e75ceeb6760230c1ca4ffbcb0c97b96dd7d9c464b/\ +bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`); + }); + + it("Should anonymise a location of a known screen", async () => { + const location = await getRedactedCurrentLocation( + "https://foo.bar", "#/register/some/pii", "/", Anonymity.Anonymous); + expect(location).toBe("https://foo.bar/#/register//"); + }); + + it("Should pseudonymise a location of an unknown screen", async () => { + const location = await getRedactedCurrentLocation( + "https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Pseudonymous); + expect(location).toBe( + `https://foo.bar/#//\ +a6b46dd0d1ae5e86cbc8f37e75ceeb6760230c1ca4ffbcb0c97b96dd7d9c464b/\ +bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`); + }); + + it("Should anonymise a location of an unknown screen", async () => { + const location = await getRedactedCurrentLocation( + "https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Anonymous); + expect(location).toBe("https://foo.bar/#///"); }); }); From 4c6b0d35add7ae8d58f71ea1711587e31081444b Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 14:57:24 +0100 Subject: [PATCH 011/131] Improve analytics interface * Make it an error to call it before its initialised, and separately track whether its been enabled * Use anonmity enum in the public interface * Properly await upstream calls * Fix accidental test fixture cross-reliance --- src/PosthogAnalytics.ts | 60 ++++++++---- test/PosthogAnalytics-test.ts | 177 ++++++++++++++++++---------------- 2 files changed, 136 insertions(+), 101 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index d5f9b1d83c..63cb3bc422 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -66,10 +66,11 @@ export async function getRedactedCurrentLocation(origin: string, hash: string, p } export class PosthogAnalytics { - private onlyTrackAnonymousEvents = false; + private anonymity = Anonymity.Anonymous; private initialised = false; private posthog?: PostHog = null; private redactedCurrentLocation = null; + private enabled = false; private static _instance = null; @@ -84,12 +85,8 @@ export class PosthogAnalytics { this.posthog = posthog; } - public async init(onlyTrackAnonymousEvents: boolean) { - if (Boolean(navigator.doNotTrack === "1")) { - this.initialised = false; - return; - } - this.onlyTrackAnonymousEvents = onlyTrackAnonymousEvents; + public async init(anonymity: Anonymity) { + this.anonymity = Boolean(navigator.doNotTrack === "1") ? Anonymity.Anonymous : anonymity; const posthogConfig = SdkConfig.get()["posthog"]; if (posthogConfig) { @@ -105,6 +102,9 @@ export class PosthogAnalytics { sanitize_properties: this.sanitizeProperties.bind(this), }); this.initialised = true; + this.enabled = true; + } else { + this.enabled = false; } } @@ -112,7 +112,7 @@ export class PosthogAnalytics { // TODO only calculate this when the location changes as its expensive const { origin, hash, pathname } = window.location; this.redactedCurrentLocation = await getRedactedCurrentLocation( - origin, hash, pathname, this.onlyTrackAnonymousEvents ? Anonymity.Anonymous : Anonymity.Pseudonymous); + origin, hash, pathname, this.anonymity); } private sanitizeProperties(properties: posthog.Properties, _: string): posthog.Properties { @@ -123,7 +123,7 @@ export class PosthogAnalytics { // updating it involves async, which this callback is not properties['$current_url'] = this.redactedCurrentLocation; - if (this.onlyTrackAnonymousEvents) { + if (this.anonymity == Anonymity.Anonymous) { // drop referrer information for anonymous users properties['$referrer'] = null; properties['$referring_domain'] = null; @@ -138,21 +138,41 @@ export class PosthogAnalytics { } public async identifyUser(userId: string) { - if (this.onlyTrackAnonymousEvents) return; + if (this.anonymity == Anonymity.Anonymous) return; this.posthog.identify(await hashHex(userId)); } - public isInitialised(): boolean { + public isInitialised() { return this.initialised; } - public setOnlyTrackAnonymousEvents(enabled: boolean) { - this.onlyTrackAnonymousEvents = enabled; + public isEnabled() { + return this.enabled; } - private async capture(eventName: string, properties: posthog.Properties, anonymity: Anonymity) { - if (!this.initialised) return; - await this.updateRedactedCurrentLocation(anonymity); + public setAnonymity(anonymity: Anonymity) { + this.anonymity = anonymity; + } + + public getAnonymity() { + return this.anonymity; + } + + public logout() { + if (this.enabled) { + this.posthog.reset(); + } + this.setAnonymity(Anonymity.Anonymous); + } + + private async capture(eventName: string, properties: posthog.Properties) { + if (!this.enabled) { + return; + } + if (!this.initialised) { + throw Error("Tried to track event before PoshogAnalytics.init has completed"); + } + await this.updateRedactedCurrentLocation(); this.posthog.capture(eventName, properties); } @@ -160,15 +180,15 @@ export class PosthogAnalytics { eventName: E["eventName"], properties: E["properties"], ) { - if (this.onlyTrackAnonymousEvents) return; - this.capture(eventName, properties, Anonymity.Pseudonyomous); + if (this.anonymity == Anonymity.Anonymous) return; + await this.capture(eventName, properties); } public async trackAnonymousEvent( eventName: E["eventName"], properties: E["properties"], ) { - this.capture(eventName, properties, Anonymity.Anonymous); + await this.capture(eventName, properties); } public async trackRoomEvent( @@ -180,7 +200,7 @@ export class PosthogAnalytics { ...properties, hashedRoomId: roomId ? await hashHex(roomId) : null, }; - this.trackPseudonymousEvent(eventName, updatedProperties); + await this.trackPseudonymousEvent(eventName, updatedProperties); } } diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index e9efeffa7d..515d51b8e4 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -46,106 +46,121 @@ describe("PosthogAnalytics", () => { window.crypto = null; }); - it("Should not initialise if DNT is enabled", () => { - navigator.doNotTrack = "1"; - analytics.init(false); - expect(analytics.isInitialised()).toBe(false); - }); - - it("Should not initialise if config is not set", () => { - jest.spyOn(SdkConfig, "get").mockReturnValue({}); - analytics.init(false); - expect(analytics.isInitialised()).toBe(false); - }); - - it("Should initialise if config is set", () => { - jest.spyOn(SdkConfig, "get").mockReturnValue({ - posthog: { - projectApiKey: "foo", - apiHost: "bar", - }, + describe("Initialisation", () => { + it("Should not initialise if config is not set", async () => { + jest.spyOn(SdkConfig, "get").mockReturnValue({}); + await analytics.init(Anonymity.Pseudonymous); + expect(analytics.isEnabled()).toBe(false); }); - analytics.init(false); - expect(analytics.isInitialised()).toBe(true); - }); - it("Should pass track() to posthog", async () => { - analytics.init(false); - await analytics.trackAnonymousEvent("jest_test_event", { - foo: "bar", + it("Should initialise if config is set", async () => { + jest.spyOn(SdkConfig, "get").mockReturnValue({ + posthog: { + projectApiKey: "foo", + apiHost: "bar", + }, + }); + await analytics.init(Anonymity.Pseudonymous); + expect(analytics.isInitialised()).toBe(true); + expect(analytics.isEnabled()).toBe(true); }); - expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); - expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ foo: "bar" }); - }); - it("Should pass trackRoomEvent to posthog", async () => { - analytics.init(false); - const roomId = "42"; - await analytics.trackRoomEvent("jest_test_event", roomId, { - foo: "bar", - }); - expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); - expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ - foo: "bar", - hashedRoomId: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049", + it("Should force anonymous if DNT is enabled", async () => { + navigator.doNotTrack = "1"; + await analytics.init(Anonymity.Pseudonymous); + expect(analytics.getAnonymity()).toBe(Anonymity.Anonymous); }); }); - it("Should silently not track if not inititalised", async () => { - await analytics.trackAnonymousEvent("jest_test_event", { - foo: "bar", + describe("Tracking", () => { + beforeEach(() => { + navigator.doNotTrack = null; + jest.spyOn(SdkConfig, "get").mockReturnValue({ + posthog: { + projectApiKey: "foo", + apiHost: "bar", + }, + }); }); - expect(fakePosthog.capture.mock.calls.length).toBe(0); - }); - it("Should not track non-anonymous messages if onlyTrackAnonymousEvents is true", async () => { - analytics.init(true); - await analytics.trackPseudonymousEvent("jest_test_event", { - foo: "bar", + it("Should pass track() to posthog", async () => { + await analytics.init(Anonymity.Pseudonymous); + await analytics.trackAnonymousEvent("jest_test_event", { + foo: "bar", + }); + expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); + expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ foo: "bar" }); }); - expect(fakePosthog.capture.mock.calls.length).toBe(0); - }); - it("Should identify the user to posthog if onlyTrackAnonymousEvents is false", async () => { - analytics.init(false); - await analytics.identifyUser("foo"); - expect(fakePosthog.identify.mock.calls[0][0]) - .toBe("2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"); - }); + it("Should pass trackRoomEvent to posthog", async () => { + await analytics.init(Anonymity.Pseudonymous); + const roomId = "42"; + await analytics.trackRoomEvent("jest_test_event", roomId, { + foo: "bar", + }); + expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); + expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ + foo: "bar", + hashedRoomId: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049", + }); + }); - it("Should not identify the user to posthog if onlyTrackAnonymousEvents is true", async () => { - analytics.init(true); - await analytics.identifyUser("foo"); - expect(fakePosthog.identify.mock.calls.length).toBe(0); - }); + it("Should silently not track if not inititalised", async () => { + await analytics.trackAnonymousEvent("jest_test_event", { + foo: "bar", + }); + expect(fakePosthog.capture.mock.calls.length).toBe(0); + }); - it("Should pseudonymise a location of a known screen", async () => { - const location = await getRedactedCurrentLocation( - "https://foo.bar", "#/register/some/pii", "/", Anonymity.Pseudonymous); - expect(location).toBe( - `https://foo.bar/#/register/\ + it("Should not track non-anonymous messages if anonymous", async () => { + await analytics.init(Anonymity.Anonymous); + await analytics.trackPseudonymousEvent("jest_test_event", { + foo: "bar", + }); + expect(fakePosthog.capture.mock.calls.length).toBe(0); + }); + + it("Should pseudonymise a location of a known screen", async () => { + const location = await getRedactedCurrentLocation( + "https://foo.bar", "#/register/some/pii", "/", Anonymity.Pseudonymous); + expect(location).toBe( + `https://foo.bar/#/register/\ a6b46dd0d1ae5e86cbc8f37e75ceeb6760230c1ca4ffbcb0c97b96dd7d9c464b/\ bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`); - }); + }); - it("Should anonymise a location of a known screen", async () => { - const location = await getRedactedCurrentLocation( - "https://foo.bar", "#/register/some/pii", "/", Anonymity.Anonymous); - expect(location).toBe("https://foo.bar/#/register//"); - }); + it("Should anonymise a location of a known screen", async () => { + const location = await getRedactedCurrentLocation( + "https://foo.bar", "#/register/some/pii", "/", Anonymity.Anonymous); + expect(location).toBe("https://foo.bar/#/register//"); + }); - it("Should pseudonymise a location of an unknown screen", async () => { - const location = await getRedactedCurrentLocation( - "https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Pseudonymous); - expect(location).toBe( - `https://foo.bar/#//\ + it("Should pseudonymise a location of an unknown screen", async () => { + const location = await getRedactedCurrentLocation( + "https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Pseudonymous); + expect(location).toBe( + `https://foo.bar/#//\ a6b46dd0d1ae5e86cbc8f37e75ceeb6760230c1ca4ffbcb0c97b96dd7d9c464b/\ bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`); - }); + }); - it("Should anonymise a location of an unknown screen", async () => { - const location = await getRedactedCurrentLocation( - "https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Anonymous); - expect(location).toBe("https://foo.bar/#///"); + it("Should anonymise a location of an unknown screen", async () => { + const location = await getRedactedCurrentLocation( + "https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Anonymous); + expect(location).toBe("https://foo.bar/#///"); + }); + + it("Should identify the user to posthog if pseudonymous", async () => { + await analytics.init(Anonymity.Pseudonymous); + await analytics.identifyUser("foo"); + expect(fakePosthog.identify.mock.calls[0][0]) + .toBe("2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"); + }); + + it("Should not identify the user to posthog if anonymous", async () => { + await analytics.init(Anonymity.Anonymous); + await analytics.identifyUser("foo"); + expect(fakePosthog.identify.mock.calls.length).toBe(0); + }); }); }); From 726b4497b2a54fcf6fe743d988325879d8e77890 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 16:06:09 +0100 Subject: [PATCH 012/131] Remove redactedCurrentLocation and rely on posthog for DNT * Redact and pass the redacted url as a property. redactedCurrentLocation might have issues with concurrent events * Remove DNT code and rely on posthog --- src/PosthogAnalytics.ts | 42 +++++++++++++++++------------------ test/PosthogAnalytics-test.ts | 30 +++++++++---------------- 2 files changed, 32 insertions(+), 40 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 63cb3bc422..fd8bb44e0b 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -69,7 +69,6 @@ export class PosthogAnalytics { private anonymity = Anonymity.Anonymous; private initialised = false; private posthog?: PostHog = null; - private redactedCurrentLocation = null; private enabled = false; private static _instance = null; @@ -85,21 +84,20 @@ export class PosthogAnalytics { this.posthog = posthog; } - public async init(anonymity: Anonymity) { - this.anonymity = Boolean(navigator.doNotTrack === "1") ? Anonymity.Anonymous : anonymity; - + public init(anonymity: Anonymity) { const posthogConfig = SdkConfig.get()["posthog"]; if (posthogConfig) { - // Update the redacted current location before initialising posthog, as posthog.init triggers - // an immediate pageview event which calls the sanitize_properties callback - await this.updateRedactedCurrentLocation(); - this.posthog.init(posthogConfig.projectApiKey, { api_host: posthogConfig.apiHost, autocapture: false, mask_all_text: true, mask_all_element_attributes: true, + // this is disabled for now as its tricky to sanitize properties of the pageview + // event because sanitization requires async crypto calls and the sanitize_properties + // callback is synchronous. + capture_pageview: false, sanitize_properties: this.sanitizeProperties.bind(this), + respect_dnt: true, }); this.initialised = true; this.enabled = true; @@ -108,20 +106,20 @@ export class PosthogAnalytics { } } - private async updateRedactedCurrentLocation() { - // TODO only calculate this when the location changes as its expensive - const { origin, hash, pathname } = window.location; - this.redactedCurrentLocation = await getRedactedCurrentLocation( - origin, hash, pathname, this.anonymity); - } - private sanitizeProperties(properties: posthog.Properties, _: string): posthog.Properties { - // Sanitize posthog's built in properties which leak PII e.g. url reporting - // see utils.js _.info.properties in posthog-js + // Callback from posthog to sanitize properties before sending them to the server. + // + // Here we sanitize posthog's built in properties which leak PII e.g. url reporting. + // See utils.js _.info.properties in posthog-js. - // this.redactedCurrentLocation needs to have been updated prior to reaching this point as - // updating it involves async, which this callback is not - properties['$current_url'] = this.redactedCurrentLocation; + // Replace the $current_url with a redacted version. + // $redacted_current_url is injected by this class earlier in capture(), as its generation + // is async and can't be done in this non-async callback. + if (!properties['$redacted_current_url']) { + console.log("$redacted_current_url not set in sanitizeProperties, will drop $current_url entirely"); + } + properties['$current_url'] = properties['$redacted_current_url']; + delete properties['$redacted_current_url']; if (this.anonymity == Anonymity.Anonymous) { // drop referrer information for anonymous users @@ -172,7 +170,9 @@ export class PosthogAnalytics { if (!this.initialised) { throw Error("Tried to track event before PoshogAnalytics.init has completed"); } - await this.updateRedactedCurrentLocation(); + const { origin, hash, pathname } = window.location; + properties['$redacted_current_url'] = await getRedactedCurrentLocation( + origin, hash, pathname, this.anonymity); this.posthog.capture(eventName, properties); } diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index 515d51b8e4..a1e54dc05d 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -49,7 +49,7 @@ describe("PosthogAnalytics", () => { describe("Initialisation", () => { it("Should not initialise if config is not set", async () => { jest.spyOn(SdkConfig, "get").mockReturnValue({}); - await analytics.init(Anonymity.Pseudonymous); + analytics.init(Anonymity.Pseudonymous); expect(analytics.isEnabled()).toBe(false); }); @@ -60,21 +60,14 @@ describe("PosthogAnalytics", () => { apiHost: "bar", }, }); - await analytics.init(Anonymity.Pseudonymous); + analytics.init(Anonymity.Pseudonymous); expect(analytics.isInitialised()).toBe(true); expect(analytics.isEnabled()).toBe(true); }); - - it("Should force anonymous if DNT is enabled", async () => { - navigator.doNotTrack = "1"; - await analytics.init(Anonymity.Pseudonymous); - expect(analytics.getAnonymity()).toBe(Anonymity.Anonymous); - }); }); describe("Tracking", () => { beforeEach(() => { - navigator.doNotTrack = null; jest.spyOn(SdkConfig, "get").mockReturnValue({ posthog: { projectApiKey: "foo", @@ -84,25 +77,24 @@ describe("PosthogAnalytics", () => { }); it("Should pass track() to posthog", async () => { - await analytics.init(Anonymity.Pseudonymous); + analytics.init(Anonymity.Pseudonymous); await analytics.trackAnonymousEvent("jest_test_event", { foo: "bar", }); expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); - expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ foo: "bar" }); + expect(fakePosthog.capture.mock.calls[0][1]["foo"]).toEqual("bar"); }); it("Should pass trackRoomEvent to posthog", async () => { - await analytics.init(Anonymity.Pseudonymous); + analytics.init(Anonymity.Pseudonymous); const roomId = "42"; await analytics.trackRoomEvent("jest_test_event", roomId, { foo: "bar", }); expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); - expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ - foo: "bar", - hashedRoomId: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049", - }); + expect(fakePosthog.capture.mock.calls[0][1]["foo"]).toEqual("bar"); + expect(fakePosthog.capture.mock.calls[0][1]["hashedRoomId"]) + .toEqual("73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049"); }); it("Should silently not track if not inititalised", async () => { @@ -113,7 +105,7 @@ describe("PosthogAnalytics", () => { }); it("Should not track non-anonymous messages if anonymous", async () => { - await analytics.init(Anonymity.Anonymous); + analytics.init(Anonymity.Anonymous); await analytics.trackPseudonymousEvent("jest_test_event", { foo: "bar", }); @@ -151,14 +143,14 @@ bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`); }); it("Should identify the user to posthog if pseudonymous", async () => { - await analytics.init(Anonymity.Pseudonymous); + analytics.init(Anonymity.Pseudonymous); await analytics.identifyUser("foo"); expect(fakePosthog.identify.mock.calls[0][0]) .toBe("2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"); }); it("Should not identify the user to posthog if anonymous", async () => { - await analytics.init(Anonymity.Anonymous); + analytics.init(Anonymity.Anonymous); await analytics.identifyUser("foo"); expect(fakePosthog.identify.mock.calls.length).toBe(0); }); From 5697eeaab8aab98d5a8294775ef60a4bc0ebf577 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 16:08:00 +0100 Subject: [PATCH 013/131] Put back accidentally removed anonymity update --- src/PosthogAnalytics.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index fd8bb44e0b..2e5b9446de 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -85,6 +85,7 @@ export class PosthogAnalytics { } public init(anonymity: Anonymity) { + this.anonymity = anonymity; const posthogConfig = SdkConfig.get()["posthog"]; if (posthogConfig) { this.posthog.init(posthogConfig.projectApiKey, { From 53b6749f73d5b8f545024f64597d267dc7aa18ec Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 16:09:22 +0100 Subject: [PATCH 014/131] Change onboarding_login_begin to welcome_screen_load --- src/PosthogAnalytics.ts | 4 ++-- src/components/views/auth/Welcome.js | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 2e5b9446de..faf9af6ba5 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -28,8 +28,8 @@ export interface IRoomEvent extends IPseudonymousEvent { hashedRoomId: string } -export interface IOnboardingLoginBegin extends IAnonymousEvent { - key: "onboarding_login_begin", +export interface IWelcomeScreenLoad extends IAnonymousEvent { + key: "welcome_screen_load", } const hashHex = async (input: string): Promise => { diff --git a/src/components/views/auth/Welcome.js b/src/components/views/auth/Welcome.js index e3f7a601f2..470ea4223c 100644 --- a/src/components/views/auth/Welcome.js +++ b/src/components/views/auth/Welcome.js @@ -25,6 +25,7 @@ import SettingsStore from "../../../settings/SettingsStore"; import { UIFeature } from "../../../settings/UIFeature"; import CountlyAnalytics from "../../../CountlyAnalytics"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { getAnalytics } from "../../../PosthogAnalytics"; // translatable strings for Welcome pages _td("Sign in with SSO"); @@ -68,4 +69,8 @@ export default class Welcome extends React.PureComponent { ); } + + componentDidMount() { + getAnalytics().trackAnonymousEvent("welcome_screen_load", {}); + } } From 6737cfd2978e44c47d8eef73375457c0b91f4505 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 16:10:06 +0100 Subject: [PATCH 015/131] remove superflous dnt clear --- test/PosthogAnalytics-test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index a1e54dc05d..a33544e738 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -42,7 +42,6 @@ describe("PosthogAnalytics", () => { }); afterEach(() => { - navigator.doNotTrack = null; window.crypto = null; }); From 34f8c60b346a76ac3511999c7f6f256f4231d17e Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 16:15:18 +0100 Subject: [PATCH 016/131] Hook analytics into the SDK --- src/Lifecycle.ts | 3 +++ src/components/structures/MatrixChat.tsx | 5 +++++ .../views/settings/tabs/user/SecurityUserSettingsTab.js | 2 ++ 3 files changed, 10 insertions(+) diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 410124a637..8536f808ff 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -48,6 +48,7 @@ import { Jitsi } from "./widgets/Jitsi"; import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY } from "./BasePlatform"; import ThreepidInviteStore from "./stores/ThreepidInviteStore"; import CountlyAnalytics from "./CountlyAnalytics"; +import { getAnalytics } from "./PosthogAnalytics"; import CallHandler from './CallHandler'; import LifecycleCustomisations from "./customisations/Lifecycle"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; @@ -700,6 +701,8 @@ export function logout(): void { CountlyAnalytics.instance.enable(/* anonymous = */ true); } + getAnalytics().logout(); + if (MatrixClientPeg.get().isGuest()) { // logout doesn't work for guest sessions // Also we sometimes want to re-log in a guest session if we abort the login. diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 785838ffca..3edc463a23 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -107,6 +107,7 @@ import UIStore, { UI_EVENTS } from "../../stores/UIStore"; import SoftLogout from './auth/SoftLogout'; import { makeRoomPermalink } from "../../utils/permalinks/Permalinks"; import { copyPlaintext } from "../../utils/strings"; +import { Anonymity, getAnalytics } from '../../PosthogAnalytics'; /** constants for MatrixChat.state.view */ export enum Views { @@ -387,6 +388,7 @@ export default class MatrixChat extends React.PureComponent { if (SettingsStore.getValue("analyticsOptIn")) { Analytics.enable(); } + getAnalytics().init(SettingsStore.getValue("analyticsOptIn") ? Anonymity.Pseudonymous : Anonymity.Anonymous); CountlyAnalytics.instance.enable(/* anonymous = */ true); } @@ -498,6 +500,8 @@ export default class MatrixChat extends React.PureComponent { } else if (SettingsStore.getValue("analyticsOptIn")) { CountlyAnalytics.instance.enable(/* anonymous = */ false); } + getAnalytics().setAnonymity(SettingsStore.getValue("analyticsOptIn") ? + Anonymity.Pseudonymous: Anonymity.Anonymous); }); // Note we don't catch errors from this: we catch everything within // loadSession as there's logic there to ask the user if they want @@ -822,6 +826,7 @@ export default class MatrixChat extends React.PureComponent { if (CountlyAnalytics.instance.canEnable()) { CountlyAnalytics.instance.enable(/* anonymous = */ false); } + getAnalytics().setAnonymity(Anonymity.Pseudonymous); break; case 'reject_cookies': SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, false); diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js index a03598b21f..15b4992cd8 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js @@ -36,6 +36,7 @@ import { UIFeature } from "../../../../../settings/UIFeature"; import { isE2eAdvancedPanelPossible } from "../../E2eAdvancedPanel"; import CountlyAnalytics from "../../../../../CountlyAnalytics"; import { replaceableComponent } from "../../../../../utils/replaceableComponent"; +import { Anonymity, getAnalytics } from "../../../../../PosthogAnalytics"; export class IgnoredUser extends React.Component { static propTypes = { @@ -106,6 +107,7 @@ export default class SecurityUserSettingsTab extends React.Component { _updateAnalytics = (checked) => { checked ? Analytics.enable() : Analytics.disable(); CountlyAnalytics.instance.enable(/* anonymous = */ !checked); + getAnalytics().setAnonymity(checked ? Anonymity.Pseudonymous : Anonymity.Anonymous); }; _onExportE2eKeysClicked = () => { From 93962c0acaa0887aef2a5bf425226d8004798fcd Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 16:29:11 +0100 Subject: [PATCH 017/131] Update reasoning around disabling capture_pageview --- src/PosthogAnalytics.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index faf9af6ba5..c18cb98f03 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -93,9 +93,11 @@ export class PosthogAnalytics { autocapture: false, mask_all_text: true, mask_all_element_attributes: true, - // this is disabled for now as its tricky to sanitize properties of the pageview - // event because sanitization requires async crypto calls and the sanitize_properties - // callback is synchronous. + // This only triggers on page load, which for our SPA isn't particularly useful. + // Plus, the .capture call originating from somewhere in posthog makes it hard + // to redact URLs, which requires async code. + // + // To raise this manually, just call .capture("$pageview") or posthog.capture_pageview. capture_pageview: false, sanitize_properties: this.sanitizeProperties.bind(this), respect_dnt: true, From f358deb6c4dc2281993d275f5c8fdcff7d328da1 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 16:52:57 +0100 Subject: [PATCH 018/131] Manually track page views --- src/PosthogAnalytics.ts | 13 +++++++++++++ src/components/structures/MatrixChat.tsx | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index c18cb98f03..026c8d9c5e 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -28,6 +28,13 @@ export interface IRoomEvent extends IPseudonymousEvent { hashedRoomId: string } +interface IPageView extends IAnonymousEvent { + eventName: "$pageview", + properties: { + durationMs?: number + } +} + export interface IWelcomeScreenLoad extends IAnonymousEvent { key: "welcome_screen_load", } @@ -205,6 +212,12 @@ export class PosthogAnalytics { }; await this.trackPseudonymousEvent(eventName, updatedProperties); } + + public async trackPageView(durationMs: number) { + await this.trackAnonymousEvent("$pageview", { + durationMs, + }); + } } export function getAnalytics(): PosthogAnalytics { diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 3edc463a23..b29ede409b 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -107,7 +107,7 @@ import UIStore, { UI_EVENTS } from "../../stores/UIStore"; import SoftLogout from './auth/SoftLogout'; import { makeRoomPermalink } from "../../utils/permalinks/Permalinks"; import { copyPlaintext } from "../../utils/strings"; -import { Anonymity, getAnalytics } from '../../PosthogAnalytics'; +import { Anonymity, getAnalytics, IPageChange } from '../../PosthogAnalytics'; /** constants for MatrixChat.state.view */ export enum Views { @@ -445,6 +445,7 @@ export default class MatrixChat extends React.PureComponent { const durationMs = this.stopPageChangeTimer(); Analytics.trackPageChange(durationMs); CountlyAnalytics.instance.trackPageChange(durationMs); + getAnalytics().trackPageView(durationMs); } if (this.focusComposer) { dis.fire(Action.FocusSendMessageComposer); From b380a89ac6ab72526faba13dabfd00ab4ca3cffb Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 17:01:45 +0100 Subject: [PATCH 019/131] Fix wrong overriden attribute --- src/PosthogAnalytics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 026c8d9c5e..d49fb567a0 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -36,7 +36,7 @@ interface IPageView extends IAnonymousEvent { } export interface IWelcomeScreenLoad extends IAnonymousEvent { - key: "welcome_screen_load", + eventName: "welcome_screen_load", } const hashHex = async (input: string): Promise => { From e9e0e4847f45abda0eef86e98c9e01d75ceabe81 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 17:02:14 +0100 Subject: [PATCH 020/131] Move Welcome.js to tsx --- src/components/views/auth/{Welcome.js => Welcome.tsx} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename src/components/views/auth/{Welcome.js => Welcome.tsx} (93%) diff --git a/src/components/views/auth/Welcome.js b/src/components/views/auth/Welcome.tsx similarity index 93% rename from src/components/views/auth/Welcome.js rename to src/components/views/auth/Welcome.tsx index 470ea4223c..92fe9df4d5 100644 --- a/src/components/views/auth/Welcome.js +++ b/src/components/views/auth/Welcome.tsx @@ -25,7 +25,7 @@ import SettingsStore from "../../../settings/SettingsStore"; import { UIFeature } from "../../../settings/UIFeature"; import CountlyAnalytics from "../../../CountlyAnalytics"; import { replaceableComponent } from "../../../utils/replaceableComponent"; -import { getAnalytics } from "../../../PosthogAnalytics"; +import { getAnalytics, IWelcomeScreenLoad } from "../../../PosthogAnalytics"; // translatable strings for Welcome pages _td("Sign in with SSO"); @@ -71,6 +71,6 @@ export default class Welcome extends React.PureComponent { } componentDidMount() { - getAnalytics().trackAnonymousEvent("welcome_screen_load", {}); + getAnalytics().trackAnonymousEvent("welcome_screen_load", { foo: "bar" }); } } From 0c89eb51d4df8ce683584ec93d04d17bd9b11974 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 18:24:14 +0100 Subject: [PATCH 021/131] add registerSuperProperties --- src/PosthogAnalytics.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index d49fb567a0..3f13762327 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -150,6 +150,10 @@ export class PosthogAnalytics { this.posthog.identify(await hashHex(userId)); } + public registerSuperProperties(properties) { + this.posthog.register(properties); + } + public isInitialised() { return this.initialised; } From 585b702652d1ea1e86de130177867151bc29c5aa Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 18:25:05 +0100 Subject: [PATCH 022/131] Add tip about shasum --- src/PosthogAnalytics.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 3f13762327..8ebeff3565 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -40,6 +40,8 @@ export interface IWelcomeScreenLoad extends IAnonymousEvent { } const hashHex = async (input: string): Promise => { + // on os x (e.g. if you want to know the sha-256 of your own matrix ID so you can look it up): + // echo -n | shasum -a 256 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(""); From c34afdb4bdd834ea833016d0da795532c41b21b7 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 18:35:25 +0100 Subject: [PATCH 023/131] Refactor platform properties loading --- src/Lifecycle.ts | 10 +++++++++- src/PosthogAnalytics.ts | 17 +++++++++++++++++ src/components/structures/MatrixChat.tsx | 14 ++++++++++---- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 8536f808ff..c27c774cd7 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -48,7 +48,7 @@ import { Jitsi } from "./widgets/Jitsi"; import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY } from "./BasePlatform"; import ThreepidInviteStore from "./stores/ThreepidInviteStore"; import CountlyAnalytics from "./CountlyAnalytics"; -import { getAnalytics } from "./PosthogAnalytics"; +import { Anonymity, getAnalytics, getPlatformProperties } from "./PosthogAnalytics"; import CallHandler from './CallHandler'; import LifecycleCustomisations from "./customisations/Lifecycle"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; @@ -574,6 +574,14 @@ async function doSetLoggedIn( await abortLogin(); } + if (SettingsStore.getValue("analyticsOptIn")) { + const analytics = getAnalytics(); + analytics.setAnonymity(Anonymity.Pseudonymous); + await analytics.identifyUser(credentials.userId); + } else { + getAnalytics().setAnonymity(Anonymity.Anonymous); + } + Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl); MatrixClientPeg.replaceUsingCreds(credentials); diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 8ebeff3565..9c167f5464 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -1,4 +1,5 @@ import posthog, { PostHog } from 'posthog-js'; +import PlatformPeg from './PlatformPeg'; import SdkConfig from './SdkConfig'; interface IEvent { @@ -226,6 +227,22 @@ export class PosthogAnalytics { } } +export async function getPlatformProperties() { + const platform = PlatformPeg.get(); + let appVersion; + try { + appVersion = await platform.getAppVersion(); + } catch (e) { + // this happens if no version is set i.e. in dev + appVersion = "unknown"; + } + + return { + appVersion, + appPlatform: platform.getHumanReadableName(), + }; +} + export function getAnalytics(): PosthogAnalytics { return PosthogAnalytics.instance(); } diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index b29ede409b..513200520f 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -107,7 +107,7 @@ import UIStore, { UI_EVENTS } from "../../stores/UIStore"; import SoftLogout from './auth/SoftLogout'; import { makeRoomPermalink } from "../../utils/permalinks/Permalinks"; import { copyPlaintext } from "../../utils/strings"; -import { Anonymity, getAnalytics, IPageChange } from '../../PosthogAnalytics'; +import { Anonymity, getAnalytics, getPlatformProperties } from '../../PosthogAnalytics'; /** constants for MatrixChat.state.view */ export enum Views { @@ -388,7 +388,11 @@ export default class MatrixChat extends React.PureComponent { if (SettingsStore.getValue("analyticsOptIn")) { Analytics.enable(); } - getAnalytics().init(SettingsStore.getValue("analyticsOptIn") ? Anonymity.Pseudonymous : Anonymity.Anonymous); + + const analytics = getAnalytics(); + analytics.init(SettingsStore.getValue("analyticsOptIn") ? Anonymity.Pseudonymous : Anonymity.Anonymous); + getPlatformProperties().then((properties) => analytics.registerSuperProperties(properties)); + CountlyAnalytics.instance.enable(/* anonymous = */ true); } @@ -501,8 +505,6 @@ export default class MatrixChat extends React.PureComponent { } else if (SettingsStore.getValue("analyticsOptIn")) { CountlyAnalytics.instance.enable(/* anonymous = */ false); } - getAnalytics().setAnonymity(SettingsStore.getValue("analyticsOptIn") ? - Anonymity.Pseudonymous: Anonymity.Anonymous); }); // Note we don't catch errors from this: we catch everything within // loadSession as there's logic there to ask the user if they want @@ -828,6 +830,10 @@ export default class MatrixChat extends React.PureComponent { CountlyAnalytics.instance.enable(/* anonymous = */ false); } getAnalytics().setAnonymity(Anonymity.Pseudonymous); + // TODO: this is an async call and we're not waiting for it to complete - + // so potentially an event could be fired prior to it completing and would be + // missing the user identification. + getAnalytics().identifyUser(MatrixClientPeg.get().getUserId()); break; case 'reject_cookies': SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, false); From 0e3cc6b8f45856f467485b8404815099c3b7866f Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 21 Jul 2021 18:09:35 -0600 Subject: [PATCH 024/131] Remove voice messages labs flag Fixes https://github.com/vector-im/element-web/issues/17151 --- src/components/views/messages/MVoiceOrAudioBody.tsx | 3 +-- src/components/views/rooms/MessageComposer.tsx | 10 ++++------ src/settings/Settings.tsx | 6 ------ 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/src/components/views/messages/MVoiceOrAudioBody.tsx b/src/components/views/messages/MVoiceOrAudioBody.tsx index adfd102e19..8a9f39400e 100644 --- a/src/components/views/messages/MVoiceOrAudioBody.tsx +++ b/src/components/views/messages/MVoiceOrAudioBody.tsx @@ -27,8 +27,7 @@ export default class MVoiceOrAudioBody extends React.PureComponent { // MSC2516 is a legacy identifier. See https://github.com/matrix-org/matrix-doc/pull/3245 const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice'] || !!this.props.mxEvent.getContent()['org.matrix.msc3245.voice']; - const voiceMessagesEnabled = SettingsStore.getValue("feature_voice_messages"); - if (isVoiceMessage && voiceMessagesEnabled) { + if (isVoiceMessage) { return ; } else { return ; diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index b16d22b416..863d401442 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -391,12 +391,10 @@ export default class MessageComposer extends React.Component { controls.push(); } - if (SettingsStore.getValue("feature_voice_messages")) { - controls.push( this.voiceRecordingButton = c} - room={this.props.room} />); - } + controls.push( this.voiceRecordingButton = c} + room={this.props.room} />); if (!this.state.isComposerEmpty || this.state.haveRecording) { controls.push( diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index f0bdb2e0e5..bff16403e6 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -212,12 +212,6 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_FEATURE, default: false, }, - "feature_voice_messages": { - isFeature: true, - displayName: _td("Send and receive voice messages"), - supportedLevels: LEVELS_FEATURE, - default: false, - }, "feature_latex_maths": { isFeature: true, displayName: _td("Render LaTeX maths in messages"), From 11773fb0f476ac224bfdc75f58494e48d19c30be Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 21 Jul 2021 18:12:32 -0600 Subject: [PATCH 025/131] Clean up imports --- src/components/views/messages/MVoiceOrAudioBody.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/views/messages/MVoiceOrAudioBody.tsx b/src/components/views/messages/MVoiceOrAudioBody.tsx index 8a9f39400e..2d78ea192e 100644 --- a/src/components/views/messages/MVoiceOrAudioBody.tsx +++ b/src/components/views/messages/MVoiceOrAudioBody.tsx @@ -17,7 +17,6 @@ limitations under the License. import React from "react"; import MAudioBody from "./MAudioBody"; import { replaceableComponent } from "../../../utils/replaceableComponent"; -import SettingsStore from "../../../settings/SettingsStore"; import MVoiceMessageBody from "./MVoiceMessageBody"; import { IBodyProps } from "./IBodyProps"; From 363175e9a64f2a9f1276fb228d1e1297a9df0f6f Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 21 Jul 2021 18:13:01 -0600 Subject: [PATCH 026/131] Clean up i18n --- src/i18n/strings/en_EN.json | 1 - 1 file changed, 1 deletion(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 1116e4cdc1..85aabae46a 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -795,7 +795,6 @@ "If disabled, you can still add Direct Messages to Personal Spaces. If enabled, you'll automatically see everyone who is a member of the Space.": "If disabled, you can still add Direct Messages to Personal Spaces. If enabled, you'll automatically see everyone who is a member of the Space.", "Show notification badges for People in Spaces": "Show notification badges for People in Spaces", "Show options to enable 'Do not disturb' mode": "Show options to enable 'Do not disturb' mode", - "Send and receive voice messages": "Send and receive voice messages", "Render LaTeX maths in messages": "Render LaTeX maths in messages", "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.", "Message Pinning": "Message Pinning", From 43f809ccc89cc508143c06b360e7f202cffea90c Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Fri, 23 Jul 2021 15:15:44 +0200 Subject: [PATCH 027/131] Fix call events layout for message bubble --- res/css/views/rooms/_EventBubbleTile.scss | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss index 487bb38c49..f48468aee5 100644 --- a/res/css/views/rooms/_EventBubbleTile.scss +++ b/res/css/views/rooms/_EventBubbleTile.scss @@ -230,6 +230,12 @@ limitations under the License. } } + &.mx_EventTile_bubbleContainer { + .mx_EventTile_info { + min-width: 100%; + } + } + & ~ .mx_EventListSummary { --maxWidth: 80%; margin-left: calc(var(--avatarSize) + var(--gutterSize)); From 95f4275807ca3207f179288ae7cbde6352f61167 Mon Sep 17 00:00:00 2001 From: James Salter Date: Fri, 23 Jul 2021 16:47:02 +0100 Subject: [PATCH 028/131] Add Disabled anonymity, improve tests --- src/PosthogAnalytics.ts | 10 ++++---- test/PosthogAnalytics-test.ts | 45 ++++++++++++++++++++++++++++++----- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 9c167f5464..3e757060db 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -12,6 +12,7 @@ interface IEvent { } export enum Anonymity { + Disabled, Anonymous, Pseudonymous } @@ -181,12 +182,12 @@ export class PosthogAnalytics { } private async capture(eventName: string, properties: posthog.Properties) { - if (!this.enabled) { - return; - } if (!this.initialised) { throw Error("Tried to track event before PoshogAnalytics.init has completed"); } + if (!this.enabled) { + return; + } const { origin, hash, pathname } = window.location; properties['$redacted_current_url'] = await getRedactedCurrentLocation( origin, hash, pathname, this.anonymity); @@ -197,7 +198,7 @@ export class PosthogAnalytics { eventName: E["eventName"], properties: E["properties"], ) { - if (this.anonymity == Anonymity.Anonymous) return; + if (this.anonymity == Anonymity.Anonymous || this.anonymity == Anonymity.Disabled) return; await this.capture(eventName, properties); } @@ -205,6 +206,7 @@ export class PosthogAnalytics { eventName: E["eventName"], properties: E["properties"], ) { + if (this.anonymity == Anonymity.Disabled) return; await this.capture(eventName, properties); } diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index a33544e738..cefaafe78f 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -1,4 +1,4 @@ -import { Anonymity, getRedactedCurrentLocation, IAnonymousEvent, IRoomEvent, +import { Anonymity, getRedactedCurrentLocation, IAnonymousEvent, IPseudonymousEvent, IRoomEvent, PosthogAnalytics } from '../src/PosthogAnalytics'; import SdkConfig from '../src/SdkConfig'; const crypto = require('crypto'); @@ -22,6 +22,13 @@ export interface ITestEvent extends IAnonymousEvent { } } +export interface ITestPseudonymousEvent extends IPseudonymousEvent { + key: "jest_test_pseudo_event", + properties: { + foo: string + } +} + export interface ITestRoomEvent extends IRoomEvent { key: "jest_test_room_event", properties: { @@ -75,7 +82,7 @@ describe("PosthogAnalytics", () => { }); }); - it("Should pass track() to posthog", async () => { + it("Should pass trackAnonymousEvent() to posthog", async () => { analytics.init(Anonymity.Pseudonymous); await analytics.trackAnonymousEvent("jest_test_event", { foo: "bar", @@ -96,18 +103,44 @@ describe("PosthogAnalytics", () => { .toEqual("73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049"); }); - it("Should silently not track if not inititalised", async () => { - await analytics.trackAnonymousEvent("jest_test_event", { + it("Should pass trackPseudonymousEvent() to posthog", async () => { + analytics.init(Anonymity.Pseudonymous); + await analytics.trackPseudonymousEvent("jest_test_pseudo_event", { + foo: "bar", + }); + expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_pseudo_event"); + expect(fakePosthog.capture.mock.calls[0][1]["foo"]).toEqual("bar"); + }); + + it("Should blow up if not inititalised prior to tracking", async () => { + const fn = () => { + return analytics.trackAnonymousEvent("jest_test_event", { + foo: "bar", + }); + }; + await expect(fn()).rejects.toThrow(); + }); + + it("Should not track pseudonymous messages if anonymous", async () => { + analytics.init(Anonymity.Anonymous); + await analytics.trackPseudonymousEvent("jest_test_event", { foo: "bar", }); expect(fakePosthog.capture.mock.calls.length).toBe(0); }); - it("Should not track non-anonymous messages if anonymous", async () => { - analytics.init(Anonymity.Anonymous); + it("Should not track any events if disabled", async () => { + analytics.init(Anonymity.Disabled); await analytics.trackPseudonymousEvent("jest_test_event", { foo: "bar", }); + await analytics.trackAnonymousEvent("jest_test_event", { + foo: "bar", + }); + await analytics.trackRoomEvent("room id", "jest_test_room_event", { + foo: "bar", + }); + await analytics.trackPageView(200); expect(fakePosthog.capture.mock.calls.length).toBe(0); }); From 5e0a3976316d267ebedb285c6dd61f7e471dde22 Mon Sep 17 00:00:00 2001 From: James Salter Date: Fri, 23 Jul 2021 17:58:31 +0100 Subject: [PATCH 029/131] Refactor anonymity derivation --- src/PosthogAnalytics.ts | 32 ++++++++++++++++++++++-- src/components/structures/MatrixChat.tsx | 6 +++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 3e757060db..535781cb08 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -1,6 +1,7 @@ import posthog, { PostHog } from 'posthog-js'; import PlatformPeg from './PlatformPeg'; import SdkConfig from './SdkConfig'; +import SettingsStore from './settings/SettingsStore'; interface IEvent { // The event name that will be used by PostHog. @@ -78,10 +79,14 @@ export async function getRedactedCurrentLocation(origin: string, hash: string, p export class PosthogAnalytics { private anonymity = Anonymity.Anonymous; - private initialised = false; private posthog?: PostHog = null; + + // set true during init() if posthog config is present private enabled = false; + // set to true after init() has been called + private initialised = false; + private static _instance = null; public static instance(): PosthogAnalytics { @@ -155,7 +160,9 @@ export class PosthogAnalytics { } public registerSuperProperties(properties) { - this.posthog.register(properties); + if (this.enabled) { + this.posthog.register(properties); + } } public isInitialised() { @@ -248,3 +255,24 @@ export async function getPlatformProperties() { export function getAnalytics(): PosthogAnalytics { return PosthogAnalytics.instance(); } + +export function getAnonymityFromSettings(): Anonymity { + // determine the current anonymity level based on curernt user settings + + // "Send anonymous usage data which helps us improve Element. This will use a cookie." + const analyticsOptIn = SettingsStore.getValue("analyticsOptIn"); + + // "Send pseudonymous usage data which helps us improve Element. This will use a cookie." + const pseudonumousOptIn = SettingsStore.getValue("pseudonymousAnalyticsOptIn"); + + let anonymity; + if (pseudonumousOptIn) { + anonymity = Anonymity.Pseudonymous; + } else if (analyticsOptIn) { + anonymity = Anonymity.Anonymous; + } else { + anonymity = Anonymity.Disabled; + } + + return anonymity; +} diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 513200520f..bd54b0ebc9 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -107,7 +107,7 @@ import UIStore, { UI_EVENTS } from "../../stores/UIStore"; import SoftLogout from './auth/SoftLogout'; import { makeRoomPermalink } from "../../utils/permalinks/Permalinks"; import { copyPlaintext } from "../../utils/strings"; -import { Anonymity, getAnalytics, getPlatformProperties } from '../../PosthogAnalytics'; +import { Anonymity, getAnalytics, getAnonymityFromSettings, getPlatformProperties } from '../../PosthogAnalytics'; /** constants for MatrixChat.state.view */ export enum Views { @@ -390,7 +390,9 @@ export default class MatrixChat extends React.PureComponent { } const analytics = getAnalytics(); - analytics.init(SettingsStore.getValue("analyticsOptIn") ? Anonymity.Pseudonymous : Anonymity.Anonymous); + analytics.init(getAnonymityFromSettings()); + // note this requires a network request in the browser, so some events can potentially + // before before registerSuperProperties has been called getPlatformProperties().then((properties) => analytics.registerSuperProperties(properties)); CountlyAnalytics.instance.enable(/* anonymous = */ true); From 774e74374137f68f07e105b437e64c7e7397eda8 Mon Sep 17 00:00:00 2001 From: Germain Date: Mon, 26 Jul 2021 08:16:13 +0100 Subject: [PATCH 030/131] Update res/css/views/rooms/_EventBubbleTile.scss --- res/css/views/rooms/_EventBubbleTile.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss index f48468aee5..30985481da 100644 --- a/res/css/views/rooms/_EventBubbleTile.scss +++ b/res/css/views/rooms/_EventBubbleTile.scss @@ -231,6 +231,7 @@ limitations under the License. } &.mx_EventTile_bubbleContainer { + .mx_EventTile_line, .mx_EventTile_info { min-width: 100%; } From 257721185503d794a1f95e970c66e4945824639d Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Mon, 26 Jul 2021 09:42:17 +0200 Subject: [PATCH 031/131] Make CallEvent tiles the same width all the time --- res/css/views/messages/_CallEvent.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/res/css/views/messages/_CallEvent.scss b/res/css/views/messages/_CallEvent.scss index 54c7df3e0b..41bdb8bf6a 100644 --- a/res/css/views/messages/_CallEvent.scss +++ b/res/css/views/messages/_CallEvent.scss @@ -23,7 +23,7 @@ limitations under the License. background-color: $dark-panel-bg-color; border-radius: 8px; margin: 10px auto; - max-width: 75%; + width: 75%; box-sizing: border-box; height: 60px; From 5dd34de5fe89504ea1354d3e81c3a23cefcc3805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Tue, 27 Jul 2021 14:31:42 +0200 Subject: [PATCH 032/131] Handle mute state changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/components/views/voip/VideoFeed.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/components/views/voip/VideoFeed.tsx b/src/components/views/voip/VideoFeed.tsx index 95cc5ee3e3..3d873cef0a 100644 --- a/src/components/views/voip/VideoFeed.tsx +++ b/src/components/views/voip/VideoFeed.tsx @@ -85,10 +85,12 @@ export default class VideoFeed extends React.Component { if (oldFeed) { this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream); + this.props.feed.removeListener(CallFeedEvent.MuteStateChanged, this.onMuteStateChanged); this.stopMedia(); } if (newFeed) { this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream); + this.props.feed.addListener(CallFeedEvent.MuteStateChanged, this.onMuteStateChanged); this.playMedia(); } } @@ -137,6 +139,14 @@ export default class VideoFeed extends React.Component { this.playMedia(); }; + private onMuteStateChanged = () => { + this.setState({ + audioMuted: this.props.feed.isAudioMuted(), + videoMuted: this.props.feed.isVideoMuted(), + }); + this.playMedia(); + }; + private onResize = (e) => { if (this.props.onResize && !this.props.feed.isLocal()) { this.props.onResize(e); From 474561600e4e04cf112e367e1b3e1c1b8937a956 Mon Sep 17 00:00:00 2001 From: James Salter Date: Tue, 27 Jul 2021 13:29:56 +0100 Subject: [PATCH 033/131] Fix hash == "" --- src/PosthogAnalytics.ts | 23 ++++++++++++++--------- test/PosthogAnalytics-test.ts | 6 ++++++ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 535781cb08..cdb23e582c 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -63,17 +63,22 @@ export async function getRedactedCurrentLocation(origin: string, hash: string, p pathname = "//"; } - let [_, screen, ...parts] = hash.split("/"); + let hashStr; + if (hash == "") { + hashStr = ""; + } else { + let [_, screen, ...parts] = hash.split("/"); - if (!knownScreens.has(screen)) { - screen = ""; + if (!knownScreens.has(screen)) { + screen = ""; + } + + for (let i = 0; i < parts.length; i++) { + parts[i] = anonymity === Anonymity.Anonymous ? `` : await hashHex(parts[i]); + } + + hashStr = `${_}/${screen}/${parts.join("/")}`; } - - for (let i = 0; i < parts.length; i++) { - parts[i] = anonymity === Anonymity.Anonymous ? `` : await hashHex(parts[i]); - } - - const hashStr = `${_}/${screen}/${parts.join("/")}`; return origin + pathname + hashStr; } diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index cefaafe78f..7d81b6e86d 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -174,6 +174,12 @@ bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`); expect(location).toBe("https://foo.bar/#///"); }); + it("Should currently handle an empty hash", async () => { + const location = await getRedactedCurrentLocation( + "https://foo.bar", "", "/", Anonymity.Anonymous); + expect(location).toBe("https://foo.bar/"); + }); + it("Should identify the user to posthog if pseudonymous", async () => { analytics.init(Anonymity.Pseudonymous); await analytics.identifyUser("foo"); From 78eb8ffc261b948be714634bfecca04d35a19359 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 27 Jul 2021 15:51:16 +0100 Subject: [PATCH 034/131] Upgrade matrix-js-sdk to 12.2.0-rc.1 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index b73462d188..97367ae6d2 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "katex": "^0.12.0", "linkifyjs": "^2.1.9", "lodash": "^4.17.20", - "matrix-js-sdk": "12.1.0", + "matrix-js-sdk": "12.2.0-rc.1", "matrix-widget-api": "^0.1.0-beta.15", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", diff --git a/yarn.lock b/yarn.lock index ee531265b7..b339f69c1f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5445,10 +5445,10 @@ mathml-tag-names@^2.1.3: resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz#4ddadd67308e780cf16a47685878ee27b736a0a3" integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== -matrix-js-sdk@12.1.0: - version "12.1.0" - resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-12.1.0.tgz#7d159dd9bc03701e45a6b2777f1fa582a7e8b970" - integrity sha512-/fSqOjD+mTlMD+/B3s3Ja6BfI46FnTDl43ojzGDUOsHRRmSYUmoONb83qkH5Fjm8cI2q5ZBJMsBfjuZwLVeiZw== +matrix-js-sdk@12.2.0-rc.1: + version "12.2.0-rc.1" + resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-12.2.0-rc.1.tgz#fbbb462dd98c64edb6f4bcd5403d802c98625f01" + integrity sha512-aHxL6wsLRrnJMLJ17V1IVOm2dCGOA8jHWZi43xNzkdsmQeU9UiUmUcT9RxsYcc7YhNv8ZaZ1plIwvBmoz3H4mA== dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" From 3003d489145d301db81d15cdce40e044ed336a43 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 27 Jul 2021 16:01:51 +0100 Subject: [PATCH 035/131] Prepare changelog for v3.27.0-rc.1 --- CHANGELOG.md | 211 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73b383d76d..cfecd838bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,214 @@ +Changes in [3.27.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.27.0-rc.1) (2021-07-27) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.26.0...v3.27.0-rc.1) + + * Fix timing of voice message recording UI appearing + [\#6479](https://github.com/matrix-org/matrix-react-sdk/pull/6479) + * Fix PiP resize issues + [\#6483](https://github.com/matrix-org/matrix-react-sdk/pull/6483) + * Translations update from Weblate + [\#6482](https://github.com/matrix-org/matrix-react-sdk/pull/6482) + * Make new reply UI clickable + [\#6474](https://github.com/matrix-org/matrix-react-sdk/pull/6474) + * Fix infinite pagination loop when offline + [\#6478](https://github.com/matrix-org/matrix-react-sdk/pull/6478) + * Fix deleted message left offset in the timeline + [\#6473](https://github.com/matrix-org/matrix-react-sdk/pull/6473) + * Fix broken layout of the space hierarchy view + [\#6481](https://github.com/matrix-org/matrix-react-sdk/pull/6481) + * Add data-layout to MELS for better CSS structure + [\#6480](https://github.com/matrix-org/matrix-react-sdk/pull/6480) + * Style markdown quotes + [\#6468](https://github.com/matrix-org/matrix-react-sdk/pull/6468) + * Update ESLint Config + [\#6476](https://github.com/matrix-org/matrix-react-sdk/pull/6476) + * Fix VoIP event tile issues + [\#6471](https://github.com/matrix-org/matrix-react-sdk/pull/6471) + * Fix editing of & & + [\#6469](https://github.com/matrix-org/matrix-react-sdk/pull/6469) + * Fix avatar overlapping with timestamp + [\#6461](https://github.com/matrix-org/matrix-react-sdk/pull/6461) + * Fix reactions row pushing content on IRC layout + [\#6464](https://github.com/matrix-org/matrix-react-sdk/pull/6464) + * Fix blurhash rounded corners missing regression + [\#6467](https://github.com/matrix-org/matrix-react-sdk/pull/6467) + * Adhere to better eslint rules + [\#6459](https://github.com/matrix-org/matrix-react-sdk/pull/6459) + * Clean up voice messages code + [\#6453](https://github.com/matrix-org/matrix-react-sdk/pull/6453) + * Fix position of the space hierarchy spinner + [\#6462](https://github.com/matrix-org/matrix-react-sdk/pull/6462) + * Fix clipped avatar in room list + [\#6463](https://github.com/matrix-org/matrix-react-sdk/pull/6463) + * Make inline events feel less claustrophobic in bubble layout + [\#6460](https://github.com/matrix-org/matrix-react-sdk/pull/6460) + * Initial MSC3083 + MSC3244 support + [\#6212](https://github.com/matrix-org/matrix-react-sdk/pull/6212) + * Add event selected state for message bubbles + [\#6449](https://github.com/matrix-org/matrix-react-sdk/pull/6449) + * Make images fit inside message bubble + [\#6448](https://github.com/matrix-org/matrix-react-sdk/pull/6448) + * Don't show scrollbar for URL previews + [\#6450](https://github.com/matrix-org/matrix-react-sdk/pull/6450) + * Fix display of image messages that lack thumbnails + [\#6456](https://github.com/matrix-org/matrix-react-sdk/pull/6456) + * Fix avatar obstructing membership and state changes + [\#6439](https://github.com/matrix-org/matrix-react-sdk/pull/6439) + * Zoom images in lightbox to where the cursor points + [\#6418](https://github.com/matrix-org/matrix-react-sdk/pull/6418) + * Always display the Sender in the reply tile + [\#6446](https://github.com/matrix-org/matrix-react-sdk/pull/6446) + * Use modern layout in file and notification panel + [\#6447](https://github.com/matrix-org/matrix-react-sdk/pull/6447) + * Add right padding for event replies + [\#6444](https://github.com/matrix-org/matrix-react-sdk/pull/6444) + * Fix event tile cut off in share preview + [\#6445](https://github.com/matrix-org/matrix-react-sdk/pull/6445) + * Remove excessive padding after url previews + [\#6443](https://github.com/matrix-org/matrix-react-sdk/pull/6443) + * Make quotes thinner + [\#6441](https://github.com/matrix-org/matrix-react-sdk/pull/6441) + * Prevent action bar to overlap the event content + [\#6438](https://github.com/matrix-org/matrix-react-sdk/pull/6438) + * Use a MediaElementSourceAudioNode to process large audio files + [\#6436](https://github.com/matrix-org/matrix-react-sdk/pull/6436) + * Revert 100% on codeblocks + [\#6440](https://github.com/matrix-org/matrix-react-sdk/pull/6440) + * Fix duration placeholder parsing for audio files + [\#6435](https://github.com/matrix-org/matrix-react-sdk/pull/6435) + * Handle upload errors in voice messages + [\#6434](https://github.com/matrix-org/matrix-react-sdk/pull/6434) + * Render error state for audio components + [\#6433](https://github.com/matrix-org/matrix-react-sdk/pull/6433) + * Clean up visual style of files and voice messages + [\#6432](https://github.com/matrix-org/matrix-react-sdk/pull/6432) + * Convert a few things to TS + [\#6413](https://github.com/matrix-org/matrix-react-sdk/pull/6413) + * Fix onPaste handler to work with copying files from Finder + [\#5389](https://github.com/matrix-org/matrix-react-sdk/pull/5389) + * Increase hit area for timestamp in message bubbles + [\#6428](https://github.com/matrix-org/matrix-react-sdk/pull/6428) + * Navigate to the first room with notifications when clicked on space + notification dot + [\#5974](https://github.com/matrix-org/matrix-react-sdk/pull/5974) + * Add matrix: to the list of permitted URL schemes + [\#6388](https://github.com/matrix-org/matrix-react-sdk/pull/6388) + * Make diff colors in codeblocks more pleasant + [\#6355](https://github.com/matrix-org/matrix-react-sdk/pull/6355) + * Add alwaysShowTimestamps and others to RoomView setting watchers + [\#6261](https://github.com/matrix-org/matrix-react-sdk/pull/6261) + * Restore 'use default' naming on room notifications + [\#6431](https://github.com/matrix-org/matrix-react-sdk/pull/6431) + * Use cached value to read member count + [\#6429](https://github.com/matrix-org/matrix-react-sdk/pull/6429) + * yarn upgrade + [\#6430](https://github.com/matrix-org/matrix-react-sdk/pull/6430) + * Improve new layout switcher UI + [\#6427](https://github.com/matrix-org/matrix-react-sdk/pull/6427) + * Play only one audio file at a time + [\#6417](https://github.com/matrix-org/matrix-react-sdk/pull/6417) + * Improve file labeling in replies + [\#6404](https://github.com/matrix-org/matrix-react-sdk/pull/6404) + * Fix replies line clamping + [\#6425](https://github.com/matrix-org/matrix-react-sdk/pull/6425) + * Add null guard for room prop in EventTile + [\#6426](https://github.com/matrix-org/matrix-react-sdk/pull/6426) + * Fix font slider preview for message bubbles + [\#6421](https://github.com/matrix-org/matrix-react-sdk/pull/6421) + * Add spoiler support for message bubbles + [\#6419](https://github.com/matrix-org/matrix-react-sdk/pull/6419) + * Fix error when hovering over non-emoji reactions + [\#6416](https://github.com/matrix-org/matrix-react-sdk/pull/6416) + * Fix sticker display for message bubbles + [\#6423](https://github.com/matrix-org/matrix-react-sdk/pull/6423) + * Reintroduce grouped events padding on modern layout + [\#6420](https://github.com/matrix-org/matrix-react-sdk/pull/6420) + * TypeScript migration for auth components + [\#6412](https://github.com/matrix-org/matrix-react-sdk/pull/6412) + * Fix grecaptcha throwing useless error sometimes + [\#6401](https://github.com/matrix-org/matrix-react-sdk/pull/6401) + * Move download button for media to the action bar + [\#6386](https://github.com/matrix-org/matrix-react-sdk/pull/6386) + * Remove hover effect from files in the files panel + [\#6405](https://github.com/matrix-org/matrix-react-sdk/pull/6405) + * Revert accidental renaming of dispatcherRef + [\#6415](https://github.com/matrix-org/matrix-react-sdk/pull/6415) + * Add VoIP event tiles + [\#6121](https://github.com/matrix-org/matrix-react-sdk/pull/6121) + * Update Emojibase and Twemoji and switch to IamCal (Slack-style) shortcodes + [\#6347](https://github.com/matrix-org/matrix-react-sdk/pull/6347) + * Conform to new react and typescript eslint rules + [\#6408](https://github.com/matrix-org/matrix-react-sdk/pull/6408) + * Remove unwanted comma in EventTile + [\#6414](https://github.com/matrix-org/matrix-react-sdk/pull/6414) + * 💭 Message bubble layout + [\#6291](https://github.com/matrix-org/matrix-react-sdk/pull/6291) + * Fix sticker placeholder centering + [\#6411](https://github.com/matrix-org/matrix-react-sdk/pull/6411) + * Fix avatar placeholders not getting capitalized + [\#6407](https://github.com/matrix-org/matrix-react-sdk/pull/6407) + * Revert order of notification setting radios + [\#6406](https://github.com/matrix-org/matrix-react-sdk/pull/6406) + * Respect compound emojis in default avatar initial generation + [\#6397](https://github.com/matrix-org/matrix-react-sdk/pull/6397) + * Update eslint-plugin-matrix-org + [\#6403](https://github.com/matrix-org/matrix-react-sdk/pull/6403) + * Rename Copy Link to Copy Room Link + [\#6402](https://github.com/matrix-org/matrix-react-sdk/pull/6402) + * Don't throw exception from setStickyRoom as it split-brains the + RoomListStore + [\#6399](https://github.com/matrix-org/matrix-react-sdk/pull/6399) + * Fix bug where 'other homeserver' would unfocus + [\#6394](https://github.com/matrix-org/matrix-react-sdk/pull/6394) + * Cleanup reply code + [\#6392](https://github.com/matrix-org/matrix-react-sdk/pull/6392) + * Match colors of room and user avatars in DMs + [\#6393](https://github.com/matrix-org/matrix-react-sdk/pull/6393) + * Remove title from Image View + [\#6395](https://github.com/matrix-org/matrix-react-sdk/pull/6395) + * Notification settings UI refresh + [\#6352](https://github.com/matrix-org/matrix-react-sdk/pull/6352) + * Avoid hitting the settings store from TextForEvent + [\#6205](https://github.com/matrix-org/matrix-react-sdk/pull/6205) + * Fix issues with room list duplication + [\#6391](https://github.com/matrix-org/matrix-react-sdk/pull/6391) + * Use URLSearchParams instead of transitive dependency `querystring` + [\#4399](https://github.com/matrix-org/matrix-react-sdk/pull/4399) + * Add "Copy Link" to room context menu + [\#6374](https://github.com/matrix-org/matrix-react-sdk/pull/6374) + * Fix EventIndex double handling events and erroring + [\#6385](https://github.com/matrix-org/matrix-react-sdk/pull/6385) + * Fix IRC layout replies + [\#6387](https://github.com/matrix-org/matrix-react-sdk/pull/6387) + * Improve reply rendering + [\#3553](https://github.com/matrix-org/matrix-react-sdk/pull/3553) + * Update PR template for new changelog generation + [\#6380](https://github.com/matrix-org/matrix-react-sdk/pull/6380) + * Silence / Fix some console warnings/errors + [\#6382](https://github.com/matrix-org/matrix-react-sdk/pull/6382) + * Cache value of feature_spaces* flags as they cause page refresh so are + immutable + [\#6381](https://github.com/matrix-org/matrix-react-sdk/pull/6381) + * Standardise spelling and casing of homeserver, identity server, and + integration manager + [\#6365](https://github.com/matrix-org/matrix-react-sdk/pull/6365) + * Convert CONTRIBUTING to markdown + [\#6379](https://github.com/matrix-org/matrix-react-sdk/pull/6379) + * Move blurhashing into a Worker and use OffscreenCanvas for thumbnailing + [\#6366](https://github.com/matrix-org/matrix-react-sdk/pull/6366) + * Exclude state events from widgets reading room events + [\#6378](https://github.com/matrix-org/matrix-react-sdk/pull/6378) + * Use webpack worker-loader instead of homegrown hack + [\#6356](https://github.com/matrix-org/matrix-react-sdk/pull/6356) + * Send clear events to widgets when permitted + [\#6371](https://github.com/matrix-org/matrix-react-sdk/pull/6371) + * Comment why end to end tests are only on the develop branch + [\#6377](https://github.com/matrix-org/matrix-react-sdk/pull/6377) + * Improve and consolidate typing + [\#6345](https://github.com/matrix-org/matrix-react-sdk/pull/6345) + * Fix 'User' type import + [\#6375](https://github.com/matrix-org/matrix-react-sdk/pull/6375) + Changes in [3.26.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.26.0) (2021-07-19) ===================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.26.0-rc.1...v3.26.0) From cc0ff41360e7378ae920a17939d6410999948e6d Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 27 Jul 2021 16:01:52 +0100 Subject: [PATCH 036/131] v3.27.0-rc.1 --- package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 97367ae6d2..83f260eae3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.26.0", + "version": "3.27.0-rc.1", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -25,7 +25,7 @@ "bin": { "reskindex": "scripts/reskindex.js" }, - "main": "./src/index.js", + "main": "./lib/index.js", "matrix_src_main": "./src/index.js", "matrix_lib_main": "./lib/index.js", "matrix_lib_typings": "./lib/index.d.ts", @@ -200,5 +200,6 @@ "coverageReporters": [ "text" ] - } + }, + "typings": "./lib/index.d.ts" } From 12461a79e1ddaa45355b231bc18accd867cfa7a2 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 27 Jul 2021 17:19:45 +0100 Subject: [PATCH 037/131] Move SettingsStore `setting_updated` dispatch to action enum --- src/dispatcher/actions.ts | 7 +++++ .../payloads/SettingUpdatedPayload.ts | 29 +++++++++++++++++++ src/settings/SettingsStore.ts | 10 ++++--- src/stores/BreadcrumbsStore.ts | 9 ++++-- src/stores/room-list/RoomListStore.ts | 9 ++++-- 5 files changed, 54 insertions(+), 10 deletions(-) create mode 100644 src/dispatcher/payloads/SettingUpdatedPayload.ts diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 5732428201..043c69df36 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -193,4 +193,11 @@ export enum Action { * Switches space. Should be used with SwitchSpacePayload. */ SwitchSpace = "switch_space", + + /** + * Fires when a monitored setting is updated, + * see SettingsStore::monitorSetting for more details. + * Should be used with SettingUpdatedPayload. + */ + SettingUpdated = "setting_updated", } diff --git a/src/dispatcher/payloads/SettingUpdatedPayload.ts b/src/dispatcher/payloads/SettingUpdatedPayload.ts new file mode 100644 index 0000000000..8d457facfb --- /dev/null +++ b/src/dispatcher/payloads/SettingUpdatedPayload.ts @@ -0,0 +1,29 @@ +/* +Copyright 2021 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 { ActionPayload } from "../payloads"; +import { Action } from "../actions"; +import { SettingLevel } from "../../settings/SettingLevel"; + +export interface SettingUpdatedPayload extends ActionPayload { + action: Action.SettingUpdated; + + settingName: string; + roomId: string; + level: SettingLevel; + newValueAtLevel: SettingLevel; + newValue: any; +} diff --git a/src/settings/SettingsStore.ts b/src/settings/SettingsStore.ts index 44f3d5d838..c5b83cbcd0 100644 --- a/src/settings/SettingsStore.ts +++ b/src/settings/SettingsStore.ts @@ -29,6 +29,8 @@ import LocalEchoWrapper from "./handlers/LocalEchoWrapper"; import { WatchManager, CallbackFn as WatchCallbackFn } from "./WatchManager"; import { SettingLevel } from "./SettingLevel"; import SettingsHandler from "./handlers/SettingsHandler"; +import { SettingUpdatedPayload } from "../dispatcher/payloads/SettingUpdatedPayload"; +import { Action } from "../dispatcher/actions"; const defaultWatchManager = new WatchManager(); @@ -147,7 +149,7 @@ export default class SettingsStore { * if the change in value is worthwhile enough to react upon. * @returns {string} A reference to the watcher that was employed. */ - public static watchSetting(settingName: string, roomId: string, callbackFn: CallbackFn): string { + public static watchSetting(settingName: string, roomId: string | null, callbackFn: CallbackFn): string { const setting = SETTINGS[settingName]; const originalSettingName = settingName; if (!setting) throw new Error(`${settingName} is not a setting`); @@ -193,7 +195,7 @@ export default class SettingsStore { * @param {string} settingName The setting name to monitor. * @param {String} roomId The room ID to monitor for changes in. Use null for all rooms. */ - public static monitorSetting(settingName: string, roomId: string) { + public static monitorSetting(settingName: string, roomId: string | null) { roomId = roomId || null; // the thing wants null specifically to work, so appease it. if (!this.monitors.has(settingName)) this.monitors.set(settingName, new Map()); @@ -201,8 +203,8 @@ export default class SettingsStore { const registerWatcher = () => { this.monitors.get(settingName).set(roomId, SettingsStore.watchSetting( settingName, roomId, (settingName, inRoomId, level, newValueAtLevel, newValue) => { - dis.dispatch({ - action: 'setting_updated', + dis.dispatch({ + action: Action.SettingUpdated, settingName, roomId: inRoomId, level, diff --git a/src/stores/BreadcrumbsStore.ts b/src/stores/BreadcrumbsStore.ts index aceaf8b898..8a85ca354f 100644 --- a/src/stores/BreadcrumbsStore.ts +++ b/src/stores/BreadcrumbsStore.ts @@ -23,6 +23,8 @@ import { arrayHasDiff } from "../utils/arrays"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { SettingLevel } from "../settings/SettingLevel"; import SpaceStore from "./SpaceStore"; +import { Action } from "../dispatcher/actions"; +import { SettingUpdatedPayload } from "../dispatcher/payloads/SettingUpdatedPayload"; const MAX_ROOMS = 20; // arbitrary const AUTOJOIN_WAIT_THRESHOLD_MS = 90000; // 90s, the time we wait for an autojoined room to show up @@ -63,10 +65,11 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { protected async onAction(payload: ActionPayload) { if (!this.matrixClient) return; - if (payload.action === 'setting_updated') { - if (payload.settingName === 'breadcrumb_rooms') { + if (payload.action === Action.SettingUpdated) { + const settingUpdatedPayload = payload as SettingUpdatedPayload; + if (settingUpdatedPayload.settingName === 'breadcrumb_rooms') { await this.updateRooms(); - } else if (payload.settingName === 'breadcrumbs') { + } else if (settingUpdatedPayload.settingName === 'breadcrumbs') { await this.updateState({ enabled: SettingsStore.getValue("breadcrumbs", null) }); } } else if (payload.action === 'view_room') { diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index 3913a2220f..b7af70ad99 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -36,6 +36,8 @@ import { RoomNotificationStateStore } from "../notifications/RoomNotificationSta import { VisibilityProvider } from "./filters/VisibilityProvider"; import { SpaceWatcher } from "./SpaceWatcher"; import SpaceStore from "../SpaceStore"; +import { Action } from "../../dispatcher/actions"; +import { SettingUpdatedPayload } from "../../dispatcher/payloads/SettingUpdatedPayload"; interface IState { tagsEnabled?: boolean; @@ -213,10 +215,11 @@ export class RoomListStoreClass extends AsyncStoreWithClient { const logicallyReady = this.matrixClient && this.initialListsGenerated; if (!logicallyReady) return; - if (payload.action === 'setting_updated') { - if (this.watchedSettings.includes(payload.settingName)) { + if (payload.action === Action.SettingUpdated) { + const settingUpdatedPayload = payload as SettingUpdatedPayload; + if (this.watchedSettings.includes(settingUpdatedPayload.settingName)) { // TODO: Remove with https://github.com/vector-im/element-web/issues/14602 - if (payload.settingName === "advancedRoomListLogging") { + if (settingUpdatedPayload.settingName === "advancedRoomListLogging") { // Log when the setting changes so we know when it was turned on in the rageshake const enabled = SettingsStore.getValue("advancedRoomListLogging"); console.warn("Advanced room list logging is enabled? " + enabled); From 8c073a643904bc70d71a7b864aa2a149cceefff7 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 27 Jul 2021 17:53:03 +0100 Subject: [PATCH 038/131] RoomListStore removeFilter skip triggering update if nothing changed --- src/stores/room-list/RoomListStore.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index b7af70ad99..1a5ef0484e 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -711,6 +711,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient { } let promise = Promise.resolve(); let idx = this.filterConditions.indexOf(filter); + let removed = false; if (idx >= 0) { this.filterConditions.splice(idx, 1); @@ -721,14 +722,20 @@ export class RoomListStoreClass extends AsyncStoreWithClient { if (SpaceStore.spacesEnabled) { promise = this.recalculatePrefiltering(); } + removed = true; } + idx = this.prefilterConditions.indexOf(filter); if (idx >= 0) { filter.off(FILTER_CHANGED, this.onPrefilterUpdated); this.prefilterConditions.splice(idx, 1); promise = this.recalculatePrefiltering(); + removed = true; + } + + if (removed) { + promise.then(() => this.updateFn.trigger()); } - promise.then(() => this.updateFn.trigger()); } /** From ec173e74e60246d2e4bf096d6345c1eb41c569a1 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 27 Jul 2021 20:15:40 +0100 Subject: [PATCH 039/131] Test & Refactor SpaceWatcher to allow all rooms/home change without needing reload --- src/stores/room-list/SpaceWatcher.ts | 40 +++-- test/stores/SpaceStore-test.ts | 68 ++------ test/stores/room-list/SpaceWatcher-test.ts | 186 +++++++++++++++++++++ test/utils/test-utils.ts | 51 ++++++ 4 files changed, 275 insertions(+), 70 deletions(-) create mode 100644 test/stores/room-list/SpaceWatcher-test.ts diff --git a/src/stores/room-list/SpaceWatcher.ts b/src/stores/room-list/SpaceWatcher.ts index 1cec612e6f..fe2eb1e881 100644 --- a/src/stores/room-list/SpaceWatcher.ts +++ b/src/stores/room-list/SpaceWatcher.ts @@ -18,39 +18,47 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { RoomListStoreClass } from "./RoomListStore"; import { SpaceFilterCondition } from "./filters/SpaceFilterCondition"; -import SpaceStore, { UPDATE_SELECTED_SPACE } from "../SpaceStore"; +import SpaceStore, { UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../SpaceStore"; /** * Watches for changes in spaces to manage the filter on the provided RoomListStore */ export class SpaceWatcher { - private filter: SpaceFilterCondition; + private readonly filter = new SpaceFilterCondition(); + // we track these separately to the SpaceStore as we need to observe transitions private activeSpace: Room = SpaceStore.instance.activeSpace; + private allRoomsInHome: boolean = SpaceStore.instance.allRoomsInHome; constructor(private store: RoomListStoreClass) { - if (!SpaceStore.spacesTweakAllRoomsEnabled) { - this.filter = new SpaceFilterCondition(); + if (!this.allRoomsInHome || this.activeSpace) { this.updateFilter(); store.addFilter(this.filter); } SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated); + SpaceStore.instance.on(UPDATE_HOME_BEHAVIOUR, this.onHomeBehaviourUpdated); } - private onSelectedSpaceUpdated = (activeSpace?: Room) => { - this.activeSpace = activeSpace; + private onSelectedSpaceUpdated = (activeSpace?: Room, allRoomsInHome = this.allRoomsInHome) => { + if (activeSpace === this.activeSpace && allRoomsInHome === this.allRoomsInHome) return; // nop - if (this.filter) { - if (activeSpace || !SpaceStore.spacesTweakAllRoomsEnabled) { - this.updateFilter(); - } else { - this.store.removeFilter(this.filter); - this.filter = null; - } - } else if (activeSpace) { - this.filter = new SpaceFilterCondition(); + const oldActiveSpace = this.activeSpace; + const oldAllRoomsInHome = this.allRoomsInHome; + this.activeSpace = activeSpace; + this.allRoomsInHome = allRoomsInHome; + + if (activeSpace || !allRoomsInHome) { this.updateFilter(); - this.store.addFilter(this.filter); } + + if (oldAllRoomsInHome && !oldActiveSpace) { + this.store.addFilter(this.filter); + } else if (allRoomsInHome && !activeSpace) { + this.store.removeFilter(this.filter); + } + }; + + private onHomeBehaviourUpdated = (allRoomsInHome: boolean) => { + this.onSelectedSpaceUpdated(this.activeSpace, allRoomsInHome); }; private updateFilter = () => { diff --git a/test/stores/SpaceStore-test.ts b/test/stores/SpaceStore-test.ts index d772a7a658..8b809be95d 100644 --- a/test/stores/SpaceStore-test.ts +++ b/test/stores/SpaceStore-test.ts @@ -16,7 +16,6 @@ limitations under the License. import { EventEmitter } from "events"; import { EventType } from "matrix-js-sdk/src/@types/event"; -import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import "./SpaceStore-setup"; // enable space lab @@ -26,31 +25,14 @@ import SpaceStore, { UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES, } from "../../src/stores/SpaceStore"; -import { resetAsyncStoreWithClient, setupAsyncStoreWithClient } from "../utils/test-utils"; -import { mkEvent, mkStubRoom, stubClient } from "../test-utils"; -import { EnhancedMap } from "../../src/utils/maps"; +import * as testUtils from "../utils/test-utils"; +import { mkEvent, stubClient } from "../test-utils"; import DMRoomMap from "../../src/utils/DMRoomMap"; import { MatrixClientPeg } from "../../src/MatrixClientPeg"; import defaultDispatcher from "../../src/dispatcher/dispatcher"; jest.useFakeTimers(); -const mockStateEventImplementation = (events: MatrixEvent[]) => { - const stateMap = new EnhancedMap>(); - events.forEach(event => { - stateMap.getOrCreate(event.getType(), new Map()).set(event.getStateKey(), event); - }); - - return (eventType: string, stateKey?: string) => { - if (stateKey || stateKey === "") { - return stateMap.get(eventType)?.get(stateKey) || null; - } - return Array.from(stateMap.get(eventType)?.values() || []); - }; -}; - -const emitPromise = (e: EventEmitter, k: string | symbol) => new Promise(r => e.once(k, r)); - const testUserId = "@test:user"; const getUserIdForRoomId = jest.fn(); @@ -87,36 +69,13 @@ describe("SpaceStore", () => { const client = MatrixClientPeg.get(); let rooms = []; - - const mkRoom = (roomId: string) => { - const room = mkStubRoom(roomId, roomId, client); - room.currentState.getStateEvents.mockImplementation(mockStateEventImplementation([])); - rooms.push(room); - return room; - }; - - const mkSpace = (spaceId: string, children: string[] = []) => { - const space = mkRoom(spaceId); - space.isSpaceRoom.mockReturnValue(true); - space.currentState.getStateEvents.mockImplementation(mockStateEventImplementation(children.map(roomId => - mkEvent({ - event: true, - type: EventType.SpaceChild, - room: spaceId, - user: testUserId, - skey: roomId, - content: { via: [] }, - ts: Date.now(), - }), - ))); - return space; - }; - + const mkRoom = (roomId: string) => testUtils.mkRoom(client, roomId, rooms); + const mkSpace = (spaceId: string, children: string[] = []) => testUtils.mkSpace(client, spaceId, rooms, children); const viewRoom = roomId => defaultDispatcher.dispatch({ action: "view_room", room_id: roomId }, true); const run = async () => { client.getRoom.mockImplementation(roomId => rooms.find(room => room.roomId === roomId)); - await setupAsyncStoreWithClient(store, client); + await testUtils.setupAsyncStoreWithClient(store, client); jest.runAllTimers(); }; @@ -125,7 +84,7 @@ describe("SpaceStore", () => { client.getVisibleRooms.mockReturnValue(rooms = []); }); afterEach(async () => { - await resetAsyncStoreWithClient(store); + await testUtils.resetAsyncStoreWithClient(store); }); describe("static hierarchy resolution tests", () => { @@ -488,7 +447,7 @@ describe("SpaceStore", () => { await run(); expect(store.spacePanelSpaces).toStrictEqual([]); const space = mkSpace(space1); - const prom = emitPromise(store, UPDATE_TOP_LEVEL_SPACES); + const prom = testUtils.emitPromise(store, UPDATE_TOP_LEVEL_SPACES); emitter.emit("Room", space); await prom; expect(store.spacePanelSpaces).toStrictEqual([space]); @@ -501,7 +460,7 @@ describe("SpaceStore", () => { expect(store.spacePanelSpaces).toStrictEqual([space]); space.getMyMembership.mockReturnValue("leave"); - const prom = emitPromise(store, UPDATE_TOP_LEVEL_SPACES); + const prom = testUtils.emitPromise(store, UPDATE_TOP_LEVEL_SPACES); emitter.emit("Room.myMembership", space, "leave", "join"); await prom; expect(store.spacePanelSpaces).toStrictEqual([]); @@ -513,7 +472,7 @@ describe("SpaceStore", () => { expect(store.invitedSpaces).toStrictEqual([]); const space = mkSpace(space1); space.getMyMembership.mockReturnValue("invite"); - const prom = emitPromise(store, UPDATE_INVITED_SPACES); + const prom = testUtils.emitPromise(store, UPDATE_INVITED_SPACES); emitter.emit("Room", space); await prom; expect(store.spacePanelSpaces).toStrictEqual([]); @@ -528,7 +487,7 @@ describe("SpaceStore", () => { expect(store.spacePanelSpaces).toStrictEqual([]); expect(store.invitedSpaces).toStrictEqual([space]); space.getMyMembership.mockReturnValue("join"); - const prom = emitPromise(store, UPDATE_TOP_LEVEL_SPACES); + const prom = testUtils.emitPromise(store, UPDATE_TOP_LEVEL_SPACES); emitter.emit("Room.myMembership", space, "join", "invite"); await prom; expect(store.spacePanelSpaces).toStrictEqual([space]); @@ -543,7 +502,7 @@ describe("SpaceStore", () => { expect(store.spacePanelSpaces).toStrictEqual([]); expect(store.invitedSpaces).toStrictEqual([space]); space.getMyMembership.mockReturnValue("leave"); - const prom = emitPromise(store, UPDATE_INVITED_SPACES); + const prom = testUtils.emitPromise(store, UPDATE_INVITED_SPACES); emitter.emit("Room.myMembership", space, "leave", "invite"); await prom; expect(store.spacePanelSpaces).toStrictEqual([]); @@ -563,7 +522,7 @@ describe("SpaceStore", () => { const invite = mkRoom(invite1); invite.getMyMembership.mockReturnValue("invite"); - const prom = emitPromise(store, space1); + const prom = testUtils.emitPromise(store, space1); emitter.emit("Room", space); await prom; @@ -704,7 +663,8 @@ describe("SpaceStore", () => { mkSpace(space1, [room1, room2, room3]); mkSpace(space2, [room1, room2]); - client.getRoom(room2).currentState.getStateEvents.mockImplementation(mockStateEventImplementation([ + const cliRoom2 = client.getRoom(room2); + cliRoom2.currentState.getStateEvents.mockImplementation(testUtils.mockStateEventImplementation([ mkEvent({ event: true, type: EventType.SpaceParent, diff --git a/test/stores/room-list/SpaceWatcher-test.ts b/test/stores/room-list/SpaceWatcher-test.ts new file mode 100644 index 0000000000..c27088b643 --- /dev/null +++ b/test/stores/room-list/SpaceWatcher-test.ts @@ -0,0 +1,186 @@ +/* +Copyright 2021 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 "../SpaceStore-setup"; // enable space lab +import "../../skinned-sdk"; // Must be first for skinning to work +import { SpaceWatcher } from "../../../src/stores/room-list/SpaceWatcher"; +import type { RoomListStoreClass } from "../../../src/stores/room-list/RoomListStore"; +import SettingsStore from "../../../src/settings/SettingsStore"; +import SpaceStore, { UPDATE_HOME_BEHAVIOUR } from "../../../src/stores/SpaceStore"; +import { stubClient } from "../../test-utils"; +import { SettingLevel } from "../../../src/settings/SettingLevel"; +import { setupAsyncStoreWithClient } from "../../utils/test-utils"; +import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; +import * as testUtils from "../../utils/test-utils"; +import { SpaceFilterCondition } from "../../../src/stores/room-list/filters/SpaceFilterCondition"; + +let filter: SpaceFilterCondition = null; + +const mockRoomListStore = { + addFilter: f => filter = f, + removeFilter: () => filter = null, +} as unknown as RoomListStoreClass; + +const space1Id = "!space1:server"; +const space2Id = "!space2:server"; + +describe("SpaceWatcher", () => { + stubClient(); + const store = SpaceStore.instance; + const client = MatrixClientPeg.get(); + + let rooms = []; + const mkSpace = (spaceId: string, children: string[] = []) => testUtils.mkSpace(client, spaceId, rooms, children); + + const setShowAllRooms = async (value: boolean) => { + if (store.allRoomsInHome === value) return; + await SettingsStore.setValue("feature_spaces.all_rooms", null, SettingLevel.DEVICE, value); + await testUtils.emitPromise(store, UPDATE_HOME_BEHAVIOUR); + }; + + let space1; + let space2; + + beforeEach(async () => { + filter = null; + store.removeAllListeners(); + await store.setActiveSpace(null); + client.getVisibleRooms.mockReturnValue(rooms = []); + + space1 = mkSpace(space1Id); + space2 = mkSpace(space2Id); + + client.getRoom.mockImplementation(roomId => rooms.find(room => room.roomId === roomId)); + await setupAsyncStoreWithClient(store, client); + }); + + it("initialises sanely with home behaviour", async () => { + await setShowAllRooms(false); + new SpaceWatcher(mockRoomListStore); + + expect(filter).toBeInstanceOf(SpaceFilterCondition); + }); + + it("initialises sanely with all behaviour", async () => { + await setShowAllRooms(true); + new SpaceWatcher(mockRoomListStore); + + expect(filter).toBeNull(); + }); + + it("sets space=null filter for all -> home transition", async () => { + await setShowAllRooms(true); + new SpaceWatcher(mockRoomListStore); + + await setShowAllRooms(false); + + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBeNull(); + }); + + it("sets filter correctly for all -> space transition", async () => { + await setShowAllRooms(true); + new SpaceWatcher(mockRoomListStore); + + await SpaceStore.instance.setActiveSpace(space1); + + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBe(space1); + }); + + it("removes filter for home -> all transition", async () => { + await setShowAllRooms(false); + new SpaceWatcher(mockRoomListStore); + + await setShowAllRooms(true); + + expect(filter).toBeNull(); + }); + + it("sets filter correctly for home -> space transition", async () => { + await setShowAllRooms(false); + new SpaceWatcher(mockRoomListStore); + + await SpaceStore.instance.setActiveSpace(space1); + + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBe(space1); + }); + + it("removes filter for space -> all transition", async () => { + await setShowAllRooms(true); + new SpaceWatcher(mockRoomListStore); + + await SpaceStore.instance.setActiveSpace(space1); + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBe(space1); + await SpaceStore.instance.setActiveSpace(null); + + expect(filter).toBeNull(); + }); + + it("updates filter correctly for space -> home transition", async () => { + await setShowAllRooms(false); + await SpaceStore.instance.setActiveSpace(space1); + + new SpaceWatcher(mockRoomListStore); + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBe(space1); + await SpaceStore.instance.setActiveSpace(null); + + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBe(null); + }); + + it("updates filter correctly for space -> space transition", async () => { + await setShowAllRooms(false); + await SpaceStore.instance.setActiveSpace(space1); + + new SpaceWatcher(mockRoomListStore); + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBe(space1); + await SpaceStore.instance.setActiveSpace(space2); + + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBe(space2); + }); + + it("doesn't change filter when changing showAllRooms mode to true", async () => { + await setShowAllRooms(false); + await SpaceStore.instance.setActiveSpace(space1); + + new SpaceWatcher(mockRoomListStore); + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBe(space1); + await setShowAllRooms(true); + + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBe(space1); + }); + + it("doesn't change filter when changing showAllRooms mode to false", async () => { + await setShowAllRooms(true); + await SpaceStore.instance.setActiveSpace(space1); + + new SpaceWatcher(mockRoomListStore); + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBe(space1); + await setShowAllRooms(false); + + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBe(space1); + }); +}); diff --git a/test/utils/test-utils.ts b/test/utils/test-utils.ts index af92987a3d..8bc602fe35 100644 --- a/test/utils/test-utils.ts +++ b/test/utils/test-utils.ts @@ -15,7 +15,13 @@ limitations under the License. */ import { MatrixClient } from "matrix-js-sdk/src/client"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { EventType } from "matrix-js-sdk/src/@types/event"; + import { AsyncStoreWithClient } from "../../src/stores/AsyncStoreWithClient"; +import { mkEvent, mkStubRoom } from "../test-utils"; +import { EnhancedMap } from "../../src/utils/maps"; +import { EventEmitter } from "events"; // These methods make some use of some private methods on the AsyncStoreWithClient to simplify getting into a consistent // ready state without needing to wire up a dispatcher and pretend to be a js-sdk client. @@ -31,3 +37,48 @@ export const resetAsyncStoreWithClient = async (store: AsyncStoreWithClient // @ts-ignore await store.onNotReady(); }; + +export const mockStateEventImplementation = (events: MatrixEvent[]) => { + const stateMap = new EnhancedMap>(); + events.forEach(event => { + stateMap.getOrCreate(event.getType(), new Map()).set(event.getStateKey(), event); + }); + + return (eventType: string, stateKey?: string) => { + if (stateKey || stateKey === "") { + return stateMap.get(eventType)?.get(stateKey) || null; + } + return Array.from(stateMap.get(eventType)?.values() || []); + }; +}; + +export const mkRoom = (client: MatrixClient, roomId: string, rooms?: ReturnType[]) => { + const room = mkStubRoom(roomId, roomId, client); + room.currentState.getStateEvents.mockImplementation(mockStateEventImplementation([])); + rooms?.push(room); + return room; +}; + +export const mkSpace = ( + client: MatrixClient, + spaceId: string, + rooms?: ReturnType[], + children: string[] = [], +) => { + const space = mkRoom(client, spaceId, rooms); + space.isSpaceRoom.mockReturnValue(true); + space.currentState.getStateEvents.mockImplementation(mockStateEventImplementation(children.map(roomId => + mkEvent({ + event: true, + type: EventType.SpaceChild, + room: spaceId, + user: "@user:server", + skey: roomId, + content: { via: [] }, + ts: Date.now(), + }), + ))); + return space; +}; + +export const emitPromise = (e: EventEmitter, k: string | symbol) => new Promise(r => e.once(k, r)); From 0a9d3302baf96ed0c3b2d8497fcd44a65475c895 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 27 Jul 2021 21:11:47 +0100 Subject: [PATCH 040/131] Fix home vs all rooms requiring app reload and change default to `home` Consolidate ALL_ROOMS and HOME_SPACE storage Fix behaviour when recalled room is no longer part of the target space Improve tests --- src/components/views/spaces/SpacePanel.tsx | 13 +++- src/settings/Settings.tsx | 3 +- src/stores/SpaceStore.tsx | 75 ++++++++++++++-------- test/stores/SpaceStore-test.ts | 48 ++++++++++++-- 4 files changed, 100 insertions(+), 39 deletions(-) diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index 1c4043f150..8223d84dbb 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -137,15 +137,22 @@ const InnerSpacePanel = React.memo(({ children, isPanelCo const [invites, spaces, activeSpace] = useSpaces(); const activeSpaces = activeSpace ? [activeSpace] : []; - const homeNotificationState = SpaceStore.spacesTweakAllRoomsEnabled - ? RoomNotificationStateStore.instance.globalState : SpaceStore.instance.getNotificationState(HOME_SPACE); + let homeTooltip: string; + let homeNotificationState: NotificationState; + if (SpaceStore.instance.allRoomsInHome) { + homeTooltip = _t("All rooms"); + homeNotificationState = RoomNotificationStateStore.instance.globalState; + } else { + homeTooltip = _t("Home"); + homeNotificationState = SpaceStore.instance.getNotificationState(HOME_SPACE); + } return
SpaceStore.instance.setActiveSpace(null)} selected={!activeSpace} - tooltip={SpaceStore.spacesTweakAllRoomsEnabled ? _t("All rooms") : _t("Home")} + tooltip={homeTooltip} notificationState={homeNotificationState} isNarrow={isPanelCollapsed} /> diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 5aa49df8a1..54153b3d75 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -187,8 +187,7 @@ export const SETTINGS: {[setting: string]: ISetting} = { "feature_spaces.all_rooms": { displayName: _td("Show all rooms in Home"), supportedLevels: LEVELS_FEATURE, - default: true, - controller: new ReloadOnChangeController(), + default: false, }, "feature_dnd": { isFeature: true, diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index d064b01257..42ecc25651 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -37,9 +37,8 @@ import { EnhancedMap, mapDiff } from "../utils/maps"; import { setHasDiff } from "../utils/sets"; import RoomViewStore from "./RoomViewStore"; import { Action } from "../dispatcher/actions"; -import { arrayHasDiff } from "../utils/arrays"; +import { arrayHasDiff, arrayHasOrderChange } from "../utils/arrays"; import { objectDiff } from "../utils/objects"; -import { arrayHasOrderChange } from "../utils/arrays"; import { reorderLexicographically } from "../utils/stringOrderField"; import { TAG_ORDER } from "../components/views/rooms/RoomList"; import { shouldShowSpaceSettings } from "../utils/space"; @@ -48,6 +47,7 @@ import { _t } from "../languageHandler"; import GenericToast from "../components/views/toasts/GenericToast"; import Modal from "../Modal"; import InfoDialog from "../components/views/dialogs/InfoDialog"; +import { SettingUpdatedPayload } from "../dispatcher/payloads/SettingUpdatedPayload"; type SpaceKey = string | symbol; @@ -61,6 +61,7 @@ export const SUGGESTED_ROOMS = Symbol("suggested-rooms"); export const UPDATE_TOP_LEVEL_SPACES = Symbol("top-level-spaces"); export const UPDATE_INVITED_SPACES = Symbol("invited-spaces"); export const UPDATE_SELECTED_SPACE = Symbol("selected-space"); +export const UPDATE_HOME_BEHAVIOUR = Symbol("home-behaviour"); // Space Room ID/HOME_SPACE will be emitted when a Space's children change export interface ISuggestedRoom extends ISpaceSummaryRoom { @@ -69,12 +70,10 @@ export interface ISuggestedRoom extends ISpaceSummaryRoom { const MAX_SUGGESTED_ROOMS = 20; -// All of these settings cause the page to reload and can be costly if read frequently, so read them here only +// This setting causes the page to reload and can be costly if read frequently, so read it here only const spacesEnabled = SettingsStore.getValue("feature_spaces"); -const spacesTweakAllRoomsEnabled = SettingsStore.getValue("feature_spaces.all_rooms"); -const homeSpaceKey = spacesTweakAllRoomsEnabled ? "ALL_ROOMS" : "HOME_SPACE"; -const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || homeSpaceKey}`; +const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || "HOME_SPACE"}`; const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, rooms] return arr.reduce((result, room: Room) => { @@ -102,10 +101,6 @@ const getRoomFn: FetchRoomFn = (room: Room) => { }; export class SpaceStoreClass extends AsyncStoreWithClient { - constructor() { - super(defaultDispatcher, {}); - } - // The spaces representing the roots of the various tree-like hierarchies private rootSpaces: Room[] = []; // The list of rooms not present in any currently joined spaces @@ -122,6 +117,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient { private _invitedSpaces = new Set(); private spaceOrderLocalEchoMap = new Map(); private _restrictedJoinRuleSupport?: IRoomCapability; + private _allRoomsInHome: boolean = SettingsStore.getValue("feature_spaces.all_rooms"); + + constructor() { + super(defaultDispatcher, {}); + + SettingsStore.monitorSetting("feature_spaces.all_rooms", null); + } public get invitedSpaces(): Room[] { return Array.from(this._invitedSpaces); @@ -139,13 +141,16 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return this._suggestedRooms; } + public get allRoomsInHome(): boolean { + return this._allRoomsInHome; + } + public async setActiveRoomInSpace(space: Room | null): Promise { if (space && !space.isSpaceRoom()) return; if (space !== this.activeSpace) await this.setActiveSpace(space); if (space) { - const notificationState = this.getNotificationState(space.roomId); - const roomId = notificationState.getFirstRoomWithNotifications(); + const roomId = this.getNotificationState(space.roomId).getFirstRoomWithNotifications(); defaultDispatcher.dispatch({ action: "view_room", room_id: roomId, @@ -200,7 +205,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // else if the last viewed room in this space is joined then view that // else view space home or home depending on what is being clicked on if (space?.getMyMembership() !== "invite" && - this.matrixClient?.getRoom(roomId)?.getMyMembership() === "join" + this.matrixClient?.getRoom(roomId)?.getMyMembership() === "join" && + this.getSpaceFilteredRoomIds(space).has(roomId) ) { defaultDispatcher.dispatch({ action: "view_room", @@ -377,7 +383,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } public getSpaceFilteredRoomIds = (space: Room | null): Set => { - if (!space && spacesTweakAllRoomsEnabled) { + if (!space && this.allRoomsInHome) { return new Set(this.matrixClient.getVisibleRooms().map(r => r.roomId)); } return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set(); @@ -474,7 +480,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { }; private showInHomeSpace = (room: Room) => { - if (spacesTweakAllRoomsEnabled) return true; + if (this.allRoomsInHome) return true; if (room.isSpaceRoom()) return false; return !this.parentMap.get(room.roomId)?.size // put all orphaned rooms in the Home Space || DMRoomMap.shared().getUserIdForRoomId(room.roomId) // put all DMs in the Home Space @@ -506,7 +512,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const oldFilteredRooms = this.spaceFilteredRooms; this.spaceFilteredRooms = new Map(); - if (!spacesTweakAllRoomsEnabled) { + if (!this.allRoomsInHome) { // put all room invites in the Home Space const invites = visibleRooms.filter(r => !r.isSpaceRoom() && r.getMyMembership() === "invite"); this.spaceFilteredRooms.set(HOME_SPACE, new Set(invites.map(room => room.roomId))); @@ -562,8 +568,10 @@ export class SpaceStoreClass extends AsyncStoreWithClient { }); this.spaceFilteredRooms.forEach((roomIds, s) => { + if (this.allRoomsInHome && s === HOME_SPACE) return; // we'll be using the global notification state, skip + // Update NotificationStates - this.getNotificationState(s)?.setRooms(visibleRooms.filter(room => { + this.getNotificationState(s).setRooms(visibleRooms.filter(room => { if (!roomIds.has(room.roomId)) return false; if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) { @@ -663,7 +671,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // TODO confirm this after implementing parenting behaviour if (room.isSpaceRoom()) { this.onSpaceUpdate(); - } else if (!spacesTweakAllRoomsEnabled) { + } else if (!this.allRoomsInHome) { this.onRoomUpdate(room); } this.emit(room.roomId); @@ -687,7 +695,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (order !== lastOrder) { this.notifyIfOrderChanged(); } - } else if (ev.getType() === EventType.Tag && !spacesTweakAllRoomsEnabled) { + } else if (ev.getType() === EventType.Tag && !this.allRoomsInHome) { // If the room was in favourites and now isn't or the opposite then update its position in the trees const oldTags = lastEv?.getContent()?.tags || {}; const newTags = ev.getContent()?.tags || {}; @@ -698,7 +706,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { }; private onAccountData = (ev: MatrixEvent, lastEvent: MatrixEvent) => { - if (ev.getType() === EventType.Direct) { + if (!this.allRoomsInHome && ev.getType() === EventType.Direct) { const lastContent = lastEvent.getContent(); const content = ev.getContent(); @@ -733,9 +741,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.matrixClient.removeListener("Room.myMembership", this.onRoom); this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData); this.matrixClient.removeListener("RoomState.events", this.onRoomState); - if (!spacesTweakAllRoomsEnabled) { - this.matrixClient.removeListener("accountData", this.onAccountData); - } + this.matrixClient.removeListener("accountData", this.onAccountData); } await this.reset(); } @@ -746,9 +752,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.matrixClient.on("Room.myMembership", this.onRoom); this.matrixClient.on("Room.accountData", this.onRoomAccountData); this.matrixClient.on("RoomState.events", this.onRoomState); - if (!spacesTweakAllRoomsEnabled) { - this.matrixClient.on("accountData", this.onAccountData); - } + this.matrixClient.on("accountData", this.onAccountData); this.matrixClient.getCapabilities().then(capabilities => { this._restrictedJoinRuleSupport = capabilities @@ -779,7 +783,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // as it will cause you to end up in the wrong room this.setActiveSpace(room, false); } else if ( - (!spacesTweakAllRoomsEnabled || this.activeSpace) && + (!this.allRoomsInHome || this.activeSpace) && !this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId) ) { this.switchToRelatedSpace(roomId); @@ -791,17 +795,33 @@ export class SpaceStoreClass extends AsyncStoreWithClient { window.localStorage.setItem(getSpaceContextKey(this.activeSpace), payload.room_id); break; } + case "after_leave_room": if (this._activeSpace && payload.room_id === this._activeSpace.roomId) { this.setActiveSpace(null, false); } break; + case Action.SwitchSpace: if (payload.num === 0) { this.setActiveSpace(null); } else if (this.spacePanelSpaces.length >= payload.num) { this.setActiveSpace(this.spacePanelSpaces[payload.num - 1]); } + break; + + case Action.SettingUpdated: { + const settingUpdatedPayload = payload as SettingUpdatedPayload; + if (settingUpdatedPayload.settingName === "feature_spaces.all_rooms") { + const newValue = SettingsStore.getValue("feature_spaces.all_rooms"); + if (this.allRoomsInHome !== newValue) { + this._allRoomsInHome = newValue; + this.emit(UPDATE_HOME_BEHAVIOUR, this.allRoomsInHome); + this.rebuild(); // rebuild everything + } + } + break; + } } } @@ -872,7 +892,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient { export default class SpaceStore { public static spacesEnabled = spacesEnabled; - public static spacesTweakAllRoomsEnabled = spacesTweakAllRoomsEnabled; private static internalInstance = new SpaceStoreClass(); diff --git a/test/stores/SpaceStore-test.ts b/test/stores/SpaceStore-test.ts index 8b809be95d..09005e3d84 100644 --- a/test/stores/SpaceStore-test.ts +++ b/test/stores/SpaceStore-test.ts @@ -21,6 +21,7 @@ import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import "./SpaceStore-setup"; // enable space lab import "../skinned-sdk"; // Must be first for skinning to work import SpaceStore, { + UPDATE_HOME_BEHAVIOUR, UPDATE_INVITED_SPACES, UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES, @@ -30,6 +31,8 @@ import { mkEvent, stubClient } from "../test-utils"; import DMRoomMap from "../../src/utils/DMRoomMap"; import { MatrixClientPeg } from "../../src/MatrixClientPeg"; import defaultDispatcher from "../../src/dispatcher/dispatcher"; +import SettingsStore from "../../src/settings/SettingsStore"; +import { SettingLevel } from "../../src/settings/SettingLevel"; jest.useFakeTimers(); @@ -79,8 +82,16 @@ describe("SpaceStore", () => { jest.runAllTimers(); }; + const setShowAllRooms = async (value: boolean) => { + if (store.allRoomsInHome === value) return; + const emitProm = testUtils.emitPromise(store, UPDATE_HOME_BEHAVIOUR); + await SettingsStore.setValue("feature_spaces.all_rooms", null, SettingLevel.DEVICE, value); + jest.runAllTimers(); // run async dispatch + await emitProm; + }; + beforeEach(() => { - jest.runAllTimers(); + jest.runAllTimers(); // run async dispatch client.getVisibleRooms.mockReturnValue(rooms = []); }); afterEach(async () => { @@ -346,10 +357,16 @@ describe("SpaceStore", () => { expect(store.getSpaceFilteredRoomIds(null).has(invite2)).toBeTruthy(); }); - it("home space does contain rooms/low priority even if they are also shown in a space", () => { + it("all rooms space does contain rooms/low priority even if they are also shown in a space", async () => { + await setShowAllRooms(true); expect(store.getSpaceFilteredRoomIds(null).has(room1)).toBeTruthy(); }); + it("home space doesn't contain rooms/low priority if they are also shown in a space", async () => { + await setShowAllRooms(false); + expect(store.getSpaceFilteredRoomIds(null).has(room1)).toBeFalsy(); + }); + it("space contains child rooms", () => { const space = client.getRoom(space1); expect(store.getSpaceFilteredRoomIds(space).has(fav1)).toBeTruthy(); @@ -592,20 +609,30 @@ describe("SpaceStore", () => { }); describe("context switching tests", () => { - const fn = jest.spyOn(defaultDispatcher, "dispatch"); + let dispatcherRef; + let currentRoom = null; beforeEach(async () => { [room1, room2, orphan1].forEach(mkRoom); mkSpace(space1, [room1, room2]); mkSpace(space2, [room2]); await run(); + + dispatcherRef = defaultDispatcher.register(payload => { + if (payload.action === "view_room" || payload.action === "view_home_page") { + currentRoom = payload.room_id || null; + } + }); }); afterEach(() => { - fn.mockClear(); localStorage.clear(); + defaultDispatcher.unregister(dispatcherRef); }); - const getCurrentRoom = () => fn.mock.calls.reverse().find(([p]) => p.action === "view_room")?.[0].room_id; + const getCurrentRoom = () => { + jest.runAllTimers(); + return currentRoom; + }; it("last viewed room in target space is the current viewed and in both spaces", async () => { await store.setActiveSpace(client.getRoom(space1)); @@ -642,6 +669,14 @@ describe("SpaceStore", () => { expect(getCurrentRoom()).toBe(space2); }); + it("last viewed room is target space is no longer in that space", async () => { + await store.setActiveSpace(client.getRoom(space1)); + viewRoom(room1); + localStorage.setItem(`mx_space_context_${space2}`, room1); + await store.setActiveSpace(client.getRoom(space2)); + expect(getCurrentRoom()).toBe(space2); // Space home instead of room1 + }); + it("no last viewed room in target space", async () => { await store.setActiveSpace(client.getRoom(space1)); viewRoom(room1); @@ -653,7 +688,7 @@ describe("SpaceStore", () => { await store.setActiveSpace(client.getRoom(space1)); viewRoom(room1); await store.setActiveSpace(null); - expect(fn.mock.calls[fn.mock.calls.length - 1][0]).toStrictEqual({ action: "view_home_page" }); + expect(getCurrentRoom()).toBeNull(); // Home }); }); @@ -707,6 +742,7 @@ describe("SpaceStore", () => { }); it("when switching rooms in the all rooms home space don't switch to related space", async () => { + await setShowAllRooms(true); viewRoom(room2); await store.setActiveSpace(null, false); viewRoom(room1); From 776435f6208e48d9845cbdcbaafc2b2d8bdf1d64 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 27 Jul 2021 21:17:24 +0100 Subject: [PATCH 041/131] Switch all-rooms toggle for spaces to non-feature settings key --- src/i18n/strings/en_EN.json | 2 +- src/settings/Settings.tsx | 12 ++++++------ src/stores/SpaceStore.tsx | 8 ++++---- test/stores/SpaceStore-setup.ts | 1 - test/stores/SpaceStore-test.ts | 2 +- test/stores/room-list/SpaceWatcher-test.ts | 2 +- 6 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index de432d6177..2cd2a096ad 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -797,7 +797,6 @@ "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "You can leave the beta any time from settings or tapping on a beta badge, like the one above.", "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.", "Your feedback will help make spaces better. The more detail you can go into, the better.": "Your feedback will help make spaces better. The more detail you can go into, the better.", - "Show all rooms in Home": "Show all rooms in Home", "Show options to enable 'Do not disturb' mode": "Show options to enable 'Do not disturb' mode", "Send and receive voice messages": "Send and receive voice messages", "Render LaTeX maths in messages": "Render LaTeX maths in messages", @@ -868,6 +867,7 @@ "Manually verify all remote sessions": "Manually verify all remote sessions", "IRC display name width": "IRC display name width", "Show chat effects (animations when receiving e.g. confetti)": "Show chat effects (animations when receiving e.g. confetti)", + "Show all rooms in Home": "Show all rooms in Home", "Collecting app version information": "Collecting app version information", "Collecting logs": "Collecting logs", "Uploading logs": "Uploading logs", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 54153b3d75..dfd6f1eec9 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -180,15 +180,10 @@ export const SETTINGS: {[setting: string]: ISetting} = { "The more detail you can go into, the better."), feedbackLabel: "spaces-feedback", extraSettings: [ - "feature_spaces.all_rooms", + "Spaces.all_rooms_in_home", ], }, }, - "feature_spaces.all_rooms": { - displayName: _td("Show all rooms in Home"), - supportedLevels: LEVELS_FEATURE, - default: false, - }, "feature_dnd": { isFeature: true, displayName: _td("Show options to enable 'Do not disturb' mode"), @@ -756,6 +751,11 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_ACCOUNT_SETTINGS, default: null, }, + "Spaces.all_rooms_in_home": { + displayName: _td("Show all rooms in Home"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, [UIFeature.RoomHistorySettings]: { supportedLevels: LEVELS_UI_FEATURE, default: true, diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index 42ecc25651..3fc4a1bc6d 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -117,12 +117,12 @@ export class SpaceStoreClass extends AsyncStoreWithClient { private _invitedSpaces = new Set(); private spaceOrderLocalEchoMap = new Map(); private _restrictedJoinRuleSupport?: IRoomCapability; - private _allRoomsInHome: boolean = SettingsStore.getValue("feature_spaces.all_rooms"); + private _allRoomsInHome: boolean = SettingsStore.getValue("Spaces.all_rooms_in_home"); constructor() { super(defaultDispatcher, {}); - SettingsStore.monitorSetting("feature_spaces.all_rooms", null); + SettingsStore.monitorSetting("Spaces.all_rooms_in_home", null); } public get invitedSpaces(): Room[] { @@ -812,8 +812,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient { case Action.SettingUpdated: { const settingUpdatedPayload = payload as SettingUpdatedPayload; - if (settingUpdatedPayload.settingName === "feature_spaces.all_rooms") { - const newValue = SettingsStore.getValue("feature_spaces.all_rooms"); + if (settingUpdatedPayload.settingName === "Spaces.all_rooms_in_home") { + const newValue = SettingsStore.getValue("Spaces.all_rooms_in_home"); if (this.allRoomsInHome !== newValue) { this._allRoomsInHome = newValue; this.emit(UPDATE_HOME_BEHAVIOUR, this.allRoomsInHome); diff --git a/test/stores/SpaceStore-setup.ts b/test/stores/SpaceStore-setup.ts index b9b865e89a..78418d45cc 100644 --- a/test/stores/SpaceStore-setup.ts +++ b/test/stores/SpaceStore-setup.ts @@ -18,4 +18,3 @@ limitations under the License. // SpaceStore reads the SettingsStore which needs the localStorage values set at init time. localStorage.setItem("mx_labs_feature_feature_spaces", "true"); -localStorage.setItem("mx_labs_feature_feature_spaces.all_rooms", "true"); diff --git a/test/stores/SpaceStore-test.ts b/test/stores/SpaceStore-test.ts index 09005e3d84..eb3d5f0b97 100644 --- a/test/stores/SpaceStore-test.ts +++ b/test/stores/SpaceStore-test.ts @@ -85,7 +85,7 @@ describe("SpaceStore", () => { const setShowAllRooms = async (value: boolean) => { if (store.allRoomsInHome === value) return; const emitProm = testUtils.emitPromise(store, UPDATE_HOME_BEHAVIOUR); - await SettingsStore.setValue("feature_spaces.all_rooms", null, SettingLevel.DEVICE, value); + await SettingsStore.setValue("Spaces.all_rooms_in_home", null, SettingLevel.DEVICE, value); jest.runAllTimers(); // run async dispatch await emitProm; }; diff --git a/test/stores/room-list/SpaceWatcher-test.ts b/test/stores/room-list/SpaceWatcher-test.ts index c27088b643..c6254349b5 100644 --- a/test/stores/room-list/SpaceWatcher-test.ts +++ b/test/stores/room-list/SpaceWatcher-test.ts @@ -47,7 +47,7 @@ describe("SpaceWatcher", () => { const setShowAllRooms = async (value: boolean) => { if (store.allRoomsInHome === value) return; - await SettingsStore.setValue("feature_spaces.all_rooms", null, SettingLevel.DEVICE, value); + await SettingsStore.setValue("Spaces.all_rooms_in_home", null, SettingLevel.DEVICE, value); await testUtils.emitPromise(store, UPDATE_HOME_BEHAVIOUR); }; From f8106ef39b45212c56f20cbe19fc9ba5daa724a7 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 28 Jul 2021 08:39:04 +0100 Subject: [PATCH 042/131] Fix CreateRoomDialog exploding when making public room outside of a space --- src/components/views/dialogs/CreateRoomDialog.tsx | 8 +++++++- src/i18n/strings/en_EN.json | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/views/dialogs/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx index a06f508908..572212a96c 100644 --- a/src/components/views/dialogs/CreateRoomDialog.tsx +++ b/src/components/views/dialogs/CreateRoomDialog.tsx @@ -250,7 +250,7 @@ export default class CreateRoomDialog extends React.Component {   { _t("You can change this at any time from room settings.") }

; - } else if (this.state.joinRule === JoinRule.Public) { + } else if (this.state.joinRule === JoinRule.Public && this.props.parentSpace) { publicPrivateLabel =

{ _t( "Anyone will be able to find and join this room, not just members of .", {}, { @@ -260,6 +260,12 @@ export default class CreateRoomDialog extends React.Component {   { _t("You can change this at any time from room settings.") }

; + } else if (this.state.joinRule === JoinRule.Public) { + publicPrivateLabel =

+ { _t("Anyone will be able to find and join this room.") } +   + { _t("You can change this at any time from room settings.") } +

; } else if (this.state.joinRule === JoinRule.Invite) { publicPrivateLabel =

{ _t( diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 102a481f52..1093f478bb 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2195,6 +2195,7 @@ "Everyone in will be able to find and join this room.": "Everyone in will be able to find and join this room.", "You can change this at any time from room settings.": "You can change this at any time from room settings.", "Anyone will be able to find and join this room, not just members of .": "Anyone will be able to find and join this room, not just members of .", + "Anyone will be able to find and join this room.": "Anyone will be able to find and join this room.", "Only people invited will be able to find and join this room.": "Only people invited will be able to find and join this room.", "You can’t disable this later. Bridges & most bots won’t work yet.": "You can’t disable this later. Bridges & most bots won’t work yet.", "Your server requires encryption to be enabled in private rooms.": "Your server requires encryption to be enabled in private rooms.", From 1d81bdc6f9a676d075e6ad83b055b4ee43080a86 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 09:37:08 +0100 Subject: [PATCH 043/131] Interface changes and anonymity fixes --- src/Lifecycle.ts | 10 +- src/PosthogAnalytics.ts | 118 +++++++++--------- src/components/structures/MatrixChat.tsx | 13 +- .../tabs/user/SecurityUserSettingsTab.js | 4 +- src/settings/Settings.tsx | 8 ++ .../PseudonymousAnalyticsController.ts | 26 ++++ test/PosthogAnalytics-test.ts | 44 ++++--- 7 files changed, 124 insertions(+), 99 deletions(-) create mode 100644 src/settings/controllers/PseudonymousAnalyticsController.ts diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index c27c774cd7..b0a521d886 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -48,7 +48,7 @@ import { Jitsi } from "./widgets/Jitsi"; import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY } from "./BasePlatform"; import ThreepidInviteStore from "./stores/ThreepidInviteStore"; import CountlyAnalytics from "./CountlyAnalytics"; -import { Anonymity, getAnalytics, getPlatformProperties } from "./PosthogAnalytics"; +import { getAnalytics } from "./PosthogAnalytics"; import CallHandler from './CallHandler'; import LifecycleCustomisations from "./customisations/Lifecycle"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; @@ -574,13 +574,7 @@ async function doSetLoggedIn( await abortLogin(); } - if (SettingsStore.getValue("analyticsOptIn")) { - const analytics = getAnalytics(); - analytics.setAnonymity(Anonymity.Pseudonymous); - await analytics.identifyUser(credentials.userId); - } else { - getAnalytics().setAnonymity(Anonymity.Anonymous); - } + getAnalytics().updateAnonymityFromSettings(credentials.userId); Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl); diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index cdb23e582c..fa530b5309 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -85,14 +85,10 @@ export async function getRedactedCurrentLocation(origin: string, hash: string, p export class PosthogAnalytics { private anonymity = Anonymity.Anonymous; private posthog?: PostHog = null; - - // set true during init() if posthog config is present + // set true during the constructor if posthog config is present, otherwise false private enabled = false; - - // set to true after init() has been called - private initialised = false; - private static _instance = null; + private platformSuperProperties = {}; public static instance(): PosthogAnalytics { if (!this._instance) { @@ -103,10 +99,6 @@ export class PosthogAnalytics { constructor(posthog: PostHog) { this.posthog = posthog; - } - - public init(anonymity: Anonymity) { - this.anonymity = anonymity; const posthogConfig = SdkConfig.get()["posthog"]; if (posthogConfig) { this.posthog.init(posthogConfig.projectApiKey, { @@ -123,7 +115,6 @@ export class PosthogAnalytics { sanitize_properties: this.sanitizeProperties.bind(this), respect_dnt: true, }); - this.initialised = true; this.enabled = true; } else { this.enabled = false; @@ -159,19 +150,39 @@ export class PosthogAnalytics { return properties; } - public async identifyUser(userId: string) { - if (this.anonymity == Anonymity.Anonymous) return; - this.posthog.identify(await hashHex(userId)); + private static getAnonymityFromSettings(): Anonymity { + // determine the current anonymity level based on curernt user settings + + // "Send anonymous usage data which helps us improve Element. This will use a cookie." + const analyticsOptIn = SettingsStore.getValue("analyticsOptIn"); + + // "Send pseudonymous usage data which helps us improve Element. This will use a cookie." + // + // Currently, this is only a labs flag, for testing purposes. + const pseudonumousOptIn = SettingsStore.getValue("feature_pseudonymousAnalyticsOptIn"); + + let anonymity; + if (pseudonumousOptIn) { + anonymity = Anonymity.Pseudonymous; + } else if (analyticsOptIn) { + anonymity = Anonymity.Anonymous; + } else { + anonymity = Anonymity.Disabled; + } + + return anonymity; } - public registerSuperProperties(properties) { - if (this.enabled) { - this.posthog.register(properties); + public async identifyUser(userId: string) { + if (this.anonymity == Anonymity.Pseudonymous) { + this.posthog.identify(await hashHex(userId)); } } - public isInitialised() { - return this.initialised; + private registerSuperProperties(properties) { + if (this.enabled) { + this.posthog.register(properties); + } } public isEnabled() { @@ -179,6 +190,13 @@ export class PosthogAnalytics { } public setAnonymity(anonymity: Anonymity) { + if (this.enabled && (anonymity == Anonymity.Disabled || anonymity == Anonymity.Anonymous)) { + // when transitioning to Disabled or Anonymous ensure we clear out any prior state + // set in posthog e.g. distinct ID + this.posthog.reset(); + // Restore any previously set platform super properties + this.registerSuperProperties(this.platformSuperProperties); + } this.anonymity = anonymity; } @@ -194,9 +212,6 @@ export class PosthogAnalytics { } private async capture(eventName: string, properties: posthog.Properties) { - if (!this.initialised) { - throw Error("Tried to track event before PoshogAnalytics.init has completed"); - } if (!this.enabled) { return; } @@ -239,45 +254,36 @@ export class PosthogAnalytics { durationMs, }); } -} -export async function getPlatformProperties() { - const platform = PlatformPeg.get(); - let appVersion; - try { - appVersion = await platform.getAppVersion(); - } catch (e) { - // this happens if no version is set i.e. in dev - appVersion = "unknown"; + public async updatePlatformSuperProperties() { + this.platformSuperProperties = await PosthogAnalytics.getPlatformProperties(); + this.registerSuperProperties(this.platformSuperProperties); } - return { - appVersion, - appPlatform: platform.getHumanReadableName(), - }; + private static async getPlatformProperties() { + const platform = PlatformPeg.get(); + let appVersion; + try { + appVersion = await platform.getAppVersion(); + } catch (e) { + // this happens if no version is set i.e. in dev + appVersion = "unknown"; + } + + return { + appVersion, + appPlatform: platform.getHumanReadableName(), + }; + } + + public async updateAnonymityFromSettings(userId?: string) { + this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings()); + if (userId && this.getAnonymity() == Anonymity.Pseudonymous) { + await this.identifyUser(userId); + } + } } export function getAnalytics(): PosthogAnalytics { return PosthogAnalytics.instance(); } - -export function getAnonymityFromSettings(): Anonymity { - // determine the current anonymity level based on curernt user settings - - // "Send anonymous usage data which helps us improve Element. This will use a cookie." - const analyticsOptIn = SettingsStore.getValue("analyticsOptIn"); - - // "Send pseudonymous usage data which helps us improve Element. This will use a cookie." - const pseudonumousOptIn = SettingsStore.getValue("pseudonymousAnalyticsOptIn"); - - let anonymity; - if (pseudonumousOptIn) { - anonymity = Anonymity.Pseudonymous; - } else if (analyticsOptIn) { - anonymity = Anonymity.Anonymous; - } else { - anonymity = Anonymity.Disabled; - } - - return anonymity; -} diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index bd54b0ebc9..1a477970fa 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -107,7 +107,7 @@ import UIStore, { UI_EVENTS } from "../../stores/UIStore"; import SoftLogout from './auth/SoftLogout'; import { makeRoomPermalink } from "../../utils/permalinks/Permalinks"; import { copyPlaintext } from "../../utils/strings"; -import { Anonymity, getAnalytics, getAnonymityFromSettings, getPlatformProperties } from '../../PosthogAnalytics'; +import { getAnalytics } from '../../PosthogAnalytics'; /** constants for MatrixChat.state.view */ export enum Views { @@ -390,10 +390,8 @@ export default class MatrixChat extends React.PureComponent { } const analytics = getAnalytics(); - analytics.init(getAnonymityFromSettings()); - // note this requires a network request in the browser, so some events can potentially - // before before registerSuperProperties has been called - getPlatformProperties().then((properties) => analytics.registerSuperProperties(properties)); + analytics.updateAnonymityFromSettings(); + analytics.updatePlatformSuperProperties(); CountlyAnalytics.instance.enable(/* anonymous = */ true); } @@ -831,11 +829,6 @@ export default class MatrixChat extends React.PureComponent { if (CountlyAnalytics.instance.canEnable()) { CountlyAnalytics.instance.enable(/* anonymous = */ false); } - getAnalytics().setAnonymity(Anonymity.Pseudonymous); - // TODO: this is an async call and we're not waiting for it to complete - - // so potentially an event could be fired prior to it completing and would be - // missing the user identification. - getAnalytics().identifyUser(MatrixClientPeg.get().getUserId()); break; case 'reject_cookies': SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, false); diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js index 15b4992cd8..670e2ec757 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js @@ -36,7 +36,7 @@ import { UIFeature } from "../../../../../settings/UIFeature"; import { isE2eAdvancedPanelPossible } from "../../E2eAdvancedPanel"; import CountlyAnalytics from "../../../../../CountlyAnalytics"; import { replaceableComponent } from "../../../../../utils/replaceableComponent"; -import { Anonymity, getAnalytics } from "../../../../../PosthogAnalytics"; +import { getAnalytics } from "../../../../../PosthogAnalytics"; export class IgnoredUser extends React.Component { static propTypes = { @@ -107,7 +107,7 @@ export default class SecurityUserSettingsTab extends React.Component { _updateAnalytics = (checked) => { checked ? Analytics.enable() : Analytics.disable(); CountlyAnalytics.instance.enable(/* anonymous = */ !checked); - getAnalytics().setAnonymity(checked ? Anonymity.Pseudonymous : Anonymity.Anonymous); + getAnalytics().updateAnonymityFromSettings(MatrixClientPeg.get().getUserId()); }; _onExportE2eKeysClicked = () => { diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 830ea9e32e..db0cb05c9f 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -41,6 +41,7 @@ import { Layout } from "./Layout"; import ReducedMotionController from './controllers/ReducedMotionController'; import IncompatibleController from "./controllers/IncompatibleController"; import SdkConfig from "../SdkConfig"; +import PseudonymousAnalyticsController from './controllers/PseudonymousAnalyticsController'; // These are just a bunch of helper arrays to avoid copy/pasting a bunch of times const LEVELS_ROOM_SETTINGS = [ @@ -297,6 +298,13 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_FEATURE, default: false, }, + "feature_pseudonymousAnalyticsOptIn": { + isFeature: true, + supportedLevels: LEVELS_FEATURE, + displayName: _td('Send pseudonymous analytics data'), + default: false, + controller: new PseudonymousAnalyticsController(), + }, "advancedRoomListLogging": { // TODO: Remove flag before launch: https://github.com/vector-im/element-web/issues/14231 displayName: _td("Enable advanced debugging for the room list"), diff --git a/src/settings/controllers/PseudonymousAnalyticsController.ts b/src/settings/controllers/PseudonymousAnalyticsController.ts new file mode 100644 index 0000000000..d55efe3c74 --- /dev/null +++ b/src/settings/controllers/PseudonymousAnalyticsController.ts @@ -0,0 +1,26 @@ +/* +Copyright 2021 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 SettingController from "./SettingController"; +import { SettingLevel } from "../SettingLevel"; +import { getAnalytics } from "../../PosthogAnalytics"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; + +export default class PseudonymousAnalyticsController extends SettingController { + public onChange(level: SettingLevel, roomId: string, newValue: any) { + getAnalytics().updateAnonymityFromSettings(MatrixClientPeg.get().getUserId()); + } +} diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index 7d81b6e86d..f726fe0b13 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -7,11 +7,15 @@ class FakePosthog { public capture; public init; public identify; + public reset; + public register; constructor() { this.capture = jest.fn(); this.init = jest.fn(); this.identify = jest.fn(); + this.reset = jest.fn(); + this.register = jest.fn(); } } @@ -37,12 +41,11 @@ export interface ITestRoomEvent extends IRoomEvent { } describe("PosthogAnalytics", () => { - let analytics: PosthogAnalytics; let fakePosthog: FakePosthog; beforeEach(() => { fakePosthog = new FakePosthog(); - analytics = new PosthogAnalytics(fakePosthog); + window.crypto = { subtle: crypto.webcrypto.subtle, }; @@ -53,26 +56,28 @@ describe("PosthogAnalytics", () => { }); describe("Initialisation", () => { - it("Should not initialise if config is not set", async () => { + it("Should not be enabled without config being set", () => { jest.spyOn(SdkConfig, "get").mockReturnValue({}); - analytics.init(Anonymity.Pseudonymous); + const analytics = new PosthogAnalytics(fakePosthog); expect(analytics.isEnabled()).toBe(false); }); - it("Should initialise if config is set", async () => { + it("Should be enabled if config is set", () => { jest.spyOn(SdkConfig, "get").mockReturnValue({ posthog: { projectApiKey: "foo", apiHost: "bar", }, }); - analytics.init(Anonymity.Pseudonymous); - expect(analytics.isInitialised()).toBe(true); + const analytics = new PosthogAnalytics(fakePosthog); + analytics.setAnonymity(Anonymity.Pseudonymous); expect(analytics.isEnabled()).toBe(true); }); }); describe("Tracking", () => { + let analytics: PosthogAnalytics; + beforeEach(() => { jest.spyOn(SdkConfig, "get").mockReturnValue({ posthog: { @@ -80,10 +85,12 @@ describe("PosthogAnalytics", () => { apiHost: "bar", }, }); + + analytics = new PosthogAnalytics(fakePosthog); }); it("Should pass trackAnonymousEvent() to posthog", async () => { - analytics.init(Anonymity.Pseudonymous); + analytics.setAnonymity(Anonymity.Pseudonymous); await analytics.trackAnonymousEvent("jest_test_event", { foo: "bar", }); @@ -92,7 +99,7 @@ describe("PosthogAnalytics", () => { }); it("Should pass trackRoomEvent to posthog", async () => { - analytics.init(Anonymity.Pseudonymous); + analytics.setAnonymity(Anonymity.Pseudonymous); const roomId = "42"; await analytics.trackRoomEvent("jest_test_event", roomId, { foo: "bar", @@ -104,7 +111,7 @@ describe("PosthogAnalytics", () => { }); it("Should pass trackPseudonymousEvent() to posthog", async () => { - analytics.init(Anonymity.Pseudonymous); + analytics.setAnonymity(Anonymity.Pseudonymous); await analytics.trackPseudonymousEvent("jest_test_pseudo_event", { foo: "bar", }); @@ -112,17 +119,8 @@ describe("PosthogAnalytics", () => { expect(fakePosthog.capture.mock.calls[0][1]["foo"]).toEqual("bar"); }); - it("Should blow up if not inititalised prior to tracking", async () => { - const fn = () => { - return analytics.trackAnonymousEvent("jest_test_event", { - foo: "bar", - }); - }; - await expect(fn()).rejects.toThrow(); - }); - it("Should not track pseudonymous messages if anonymous", async () => { - analytics.init(Anonymity.Anonymous); + analytics.setAnonymity(Anonymity.Anonymous); await analytics.trackPseudonymousEvent("jest_test_event", { foo: "bar", }); @@ -130,7 +128,7 @@ describe("PosthogAnalytics", () => { }); it("Should not track any events if disabled", async () => { - analytics.init(Anonymity.Disabled); + analytics.setAnonymity(Anonymity.Disabled); await analytics.trackPseudonymousEvent("jest_test_event", { foo: "bar", }); @@ -181,14 +179,14 @@ bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`); }); it("Should identify the user to posthog if pseudonymous", async () => { - analytics.init(Anonymity.Pseudonymous); + analytics.setAnonymity(Anonymity.Pseudonymous); await analytics.identifyUser("foo"); expect(fakePosthog.identify.mock.calls[0][0]) .toBe("2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"); }); it("Should not identify the user to posthog if anonymous", async () => { - analytics.init(Anonymity.Anonymous); + analytics.setAnonymity(Anonymity.Anonymous); await analytics.identifyUser("foo"); expect(fakePosthog.identify.mock.calls.length).toBe(0); }); From ab7d38717c1fc5046a4d581c4ccb964ec0af2507 Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Tue, 27 Jul 2021 16:24:05 +0200 Subject: [PATCH 044/131] Restore padding for single person state events --- res/css/views/rooms/_EventTile.scss | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 4a419244ff..808af30329 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -132,15 +132,6 @@ $hover-select-border: 4px; } } - &.mx_EventTile_info .mx_EventTile_line, - & ~ .mx_EventListSummary > :not(.mx_EventTile) .mx_EventTile_avatar ~ .mx_EventTile_line { - padding-left: calc($left-gutter + 18px); - } - - & ~ .mx_EventListSummary .mx_EventTile_line { - padding-left: calc($left-gutter); - } - &.mx_EventTile_selected.mx_EventTile_info .mx_EventTile_line { padding-left: calc($left-gutter + 18px - $hover-select-border); } @@ -280,6 +271,15 @@ $hover-select-border: 4px; } } +.mx_EventTile:not([data-layout=bubble]).mx_EventTile_info .mx_EventTile_line, +.mx_EventListSummary:not([data-layout=bubble]) > :not(.mx_EventTile) .mx_EventTile_avatar ~ .mx_EventTile_line { + padding-left: calc($left-gutter + 18px); +} + +.mx_EventListSummary:not([data-layout=bubble]) .mx_EventTile_line { + padding-left: calc($left-gutter); +} + /* all the overflow-y: hidden; are to trap Zalgos - but they introduce an implicit overflow-x: auto. so make that explicitly hidden too to avoid random From a6df687196916cb3b31a4fa0cc52cbebed1bb939 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 09:54:37 +0100 Subject: [PATCH 045/131] Tidy up interface and add some comments --- src/PosthogAnalytics.ts | 102 ++++++++++++++++++++++------------ test/PosthogAnalytics-test.ts | 2 +- 2 files changed, 66 insertions(+), 38 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index fa530b5309..6329598685 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -20,11 +20,12 @@ export enum Anonymity { // If an event extends IPseudonymousEvent, the event contains pseudonymous data // that won't be sent unless the user has explicitly consented to pseudonymous tracking. -// For example, hashed user IDs or room IDs. +// For example, it might contain hashed user IDs or room IDs. +// Such events will be automatically dropped if PosthogAnalytics.anonymity isn't set to Pseudonymous. export interface IPseudonymousEvent extends IEvent {} -// If an event extends IAnonymousEvent, the event strictly contains *only* anonymous data which -// may be sent without explicit user consent. +// If an event extends IAnonymousEvent, the event strictly contains *only* anonymous data; +// i.e. no identifiers that can be associated with the user. export interface IAnonymousEvent extends IEvent {} export interface IRoomEvent extends IPseudonymousEvent { @@ -83,6 +84,23 @@ export async function getRedactedCurrentLocation(origin: string, hash: string, p } export class PosthogAnalytics { + /* Wrapper for Posthog analytics. + * + * 3 modes of anonymity are supported, governed by this.anonymity + * - Anonymity.Disabled means *no data* is passed to posthog + * - Anonymity.Anonymous means all identifers will be redacted before being passed to posthog + * - Anonymity.Pseudonymous means all identifiers will be hashed via SHA-256 before being passed + * to Posthog + * + * To update anonymity, call updateAnonymityFromSettings() or you can set it directly via setAnonymity(). + * + * To pass an event to Posthog: + * + * 1. Declare a type for the event, extending IAnonymousEvent, IPseudonymousEvent or IRoomEvent. + * 2. Call the appropriate track*() method. Pseudonymous events will be dropped when anonymity is + * Anonymous or Disabled; Anonymous events will be dropped when anonymity is Disabled. + */ + private anonymity = Anonymity.Anonymous; private posthog?: PostHog = null; // set true during the constructor if posthog config is present, otherwise false @@ -156,9 +174,9 @@ export class PosthogAnalytics { // "Send anonymous usage data which helps us improve Element. This will use a cookie." const analyticsOptIn = SettingsStore.getValue("analyticsOptIn"); - // "Send pseudonymous usage data which helps us improve Element. This will use a cookie." + // (proposed wording) "Send pseudonymous usage data which helps us improve Element. This will use a cookie." // - // Currently, this is only a labs flag, for testing purposes. + // TODO: Currently, this is only a labs flag, for testing purposes. const pseudonumousOptIn = SettingsStore.getValue("feature_pseudonymousAnalyticsOptIn"); let anonymity; @@ -173,23 +191,46 @@ export class PosthogAnalytics { return anonymity; } - public async identifyUser(userId: string) { - if (this.anonymity == Anonymity.Pseudonymous) { - this.posthog.identify(await hashHex(userId)); - } - } - private registerSuperProperties(properties) { if (this.enabled) { this.posthog.register(properties); } } + private static async getPlatformProperties() { + const platform = PlatformPeg.get(); + let appVersion; + try { + appVersion = await platform.getAppVersion(); + } catch (e) { + // this happens if no version is set i.e. in dev + appVersion = "unknown"; + } + + return { + appVersion, + appPlatform: platform.getHumanReadableName(), + }; + } + + private async capture(eventName: string, properties: posthog.Properties) { + if (!this.enabled) { + return; + } + const { origin, hash, pathname } = window.location; + properties['$redacted_current_url'] = await getRedactedCurrentLocation( + origin, hash, pathname, this.anonymity); + this.posthog.capture(eventName, properties); + } + public isEnabled() { return this.enabled; } public setAnonymity(anonymity: Anonymity) { + // Update this.anonymity. + // This is public for testing purposes, typically you want to call updateAnonymityFromSettings + // to ensure this value is in step with the user's settings. if (this.enabled && (anonymity == Anonymity.Disabled || anonymity == Anonymity.Anonymous)) { // when transitioning to Disabled or Anonymous ensure we clear out any prior state // set in posthog e.g. distinct ID @@ -200,6 +241,12 @@ export class PosthogAnalytics { this.anonymity = anonymity; } + public async identifyUser(userId: string) { + if (this.anonymity == Anonymity.Pseudonymous) { + this.posthog.identify(await hashHex(userId)); + } + } + public getAnonymity() { return this.anonymity; } @@ -211,16 +258,6 @@ export class PosthogAnalytics { this.setAnonymity(Anonymity.Anonymous); } - private async capture(eventName: string, properties: posthog.Properties) { - if (!this.enabled) { - return; - } - const { origin, hash, pathname } = window.location; - properties['$redacted_current_url'] = await getRedactedCurrentLocation( - origin, hash, pathname, this.anonymity); - this.posthog.capture(eventName, properties); - } - public async trackPseudonymousEvent( eventName: E["eventName"], properties: E["properties"], @@ -256,27 +293,18 @@ export class PosthogAnalytics { } public async updatePlatformSuperProperties() { + // Update super properties in posthog with our platform (app version, platform). + // These properties will be subsequently passed in every event. + // + // This only needs to be done once per page lifetime. Note that getPlatformProperties + // is async and can involve a network request if we are running in a browser. this.platformSuperProperties = await PosthogAnalytics.getPlatformProperties(); this.registerSuperProperties(this.platformSuperProperties); } - private static async getPlatformProperties() { - const platform = PlatformPeg.get(); - let appVersion; - try { - appVersion = await platform.getAppVersion(); - } catch (e) { - // this happens if no version is set i.e. in dev - appVersion = "unknown"; - } - - return { - appVersion, - appPlatform: platform.getHumanReadableName(), - }; - } - public async updateAnonymityFromSettings(userId?: string) { + // Update this.anonymity based on the user's analytics opt-in settings + // Identify the user (via hashed user ID) to posthog if anonymity is pseudonmyous this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings()); if (userId && this.getAnonymity() == Anonymity.Pseudonymous) { await this.identifyUser(userId); diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index f726fe0b13..a0cfec2406 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -172,7 +172,7 @@ bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`); expect(location).toBe("https://foo.bar/#///"); }); - it("Should currently handle an empty hash", async () => { + it("Should handle an empty hash", async () => { const location = await getRedactedCurrentLocation( "https://foo.bar", "", "/", Anonymity.Anonymous); expect(location).toBe("https://foo.bar/"); From 4048cb3c37132596ae52a3b5c2f336201213a3ac Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 10:20:16 +0100 Subject: [PATCH 046/131] Default to Anonymous tracking when no OptIn setting is present --- src/PosthogAnalytics.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 6329598685..bce6548cb3 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -172,17 +172,19 @@ export class PosthogAnalytics { // determine the current anonymity level based on curernt user settings // "Send anonymous usage data which helps us improve Element. This will use a cookie." - const analyticsOptIn = SettingsStore.getValue("analyticsOptIn"); + const analyticsOptIn = SettingsStore.getValue("analyticsOptIn", null, true); // (proposed wording) "Send pseudonymous usage data which helps us improve Element. This will use a cookie." // // TODO: Currently, this is only a labs flag, for testing purposes. - const pseudonumousOptIn = SettingsStore.getValue("feature_pseudonymousAnalyticsOptIn"); + const pseudonumousOptIn = SettingsStore.getValue("feature_pseudonymousAnalyticsOptIn", null, true); let anonymity; if (pseudonumousOptIn) { anonymity = Anonymity.Pseudonymous; - } else if (analyticsOptIn) { + } else if (analyticsOptIn || analyticsOptIn === null) { + // If no analyticsOptIn has been set (i.e. before the user has logged in, or if they haven't answered the + // opt-in question, assume Anonymous) anonymity = Anonymity.Anonymous; } else { anonymity = Anonymity.Disabled; From c206127f68f8ab72a42fa55d38aeb88604fd84b8 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 10:45:03 +0100 Subject: [PATCH 047/131] Track screen name when tracking page view --- src/PosthogAnalytics.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index bce6548cb3..e28972060a 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -36,6 +36,7 @@ interface IPageView extends IAnonymousEvent { eventName: "$pageview", properties: { durationMs?: number + screen?: string } } @@ -289,8 +290,17 @@ export class PosthogAnalytics { } public async trackPageView(durationMs: number) { + const hash = window.location.hash; + + let screen = null; + const split = hash.split("/"); + if (split.length >= 2) { + screen = split[1]; + } + await this.trackAnonymousEvent("$pageview", { durationMs, + screen, }); } From c3e715c1ca525beff68c8fa03ecbe64c4d8df6f3 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 10:45:32 +0100 Subject: [PATCH 048/131] i18n --- src/i18n/strings/en_EN.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 2790e17eed..403374edb4 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -821,6 +821,7 @@ "Show message previews for reactions in DMs": "Show message previews for reactions in DMs", "Show message previews for reactions in all rooms": "Show message previews for reactions in all rooms", "Offline encrypted messaging using dehydrated devices": "Offline encrypted messaging using dehydrated devices", + "Send pseudonymous analytics data": "Send pseudonymous analytics data", "Enable advanced debugging for the room list": "Enable advanced debugging for the room list", "Show info about bridges in room settings": "Show info about bridges in room settings", "Font size": "Font size", From 8ef18d0f9a97067676741349710853faac16fc07 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 11:01:59 +0100 Subject: [PATCH 049/131] Add module level comment about anonymity behaviour --- src/PosthogAnalytics.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index e28972060a..0435a0f22c 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -3,6 +3,21 @@ import PlatformPeg from './PlatformPeg'; import SdkConfig from './SdkConfig'; import SettingsStore from './settings/SettingsStore'; +/* Posthog analytics tracking. + * + * Anonymity behaviour is as follows: + * + * - If Posthog isn't configured in `config.json`, events are not sent. + * - If [Do Not Track](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/doNotTrack) is + * enabled, events are not sent (this detection is built into posthog and turned on via the + * `respect_dnt` flag being passed to `posthog.init`). + * - If the `feature_pseudonymousAnalyticsOptIn` labs flag is `true`, track pseudonomously, i.e. + * hash all matrix identifiers in tracking events (user IDs, room IDs etc) using SHA-256. + * - Otherwise, if the existing `analyticsOptIn` flag is `true`, or not present (i.e. prior to + * logging in), track anonymously, i.e. redact all matrix identifiers in tracking events. + * - If both flags are false, events are not sent. +*/ + interface IEvent { // The event name that will be used by PostHog. // TODO: standard format (camel case? snake? UpperCase?) @@ -86,7 +101,6 @@ export async function getRedactedCurrentLocation(origin: string, hash: string, p export class PosthogAnalytics { /* Wrapper for Posthog analytics. - * * 3 modes of anonymity are supported, governed by this.anonymity * - Anonymity.Disabled means *no data* is passed to posthog * - Anonymity.Anonymous means all identifers will be redacted before being passed to posthog From c697079eb42727f84cfb6b9f79d73931710d10b9 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 11:22:40 +0100 Subject: [PATCH 050/131] Fix import --- test/PosthogAnalytics-test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index a0cfec2406..095e216262 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -1,7 +1,7 @@ import { Anonymity, getRedactedCurrentLocation, IAnonymousEvent, IPseudonymousEvent, IRoomEvent, PosthogAnalytics } from '../src/PosthogAnalytics'; import SdkConfig from '../src/SdkConfig'; -const crypto = require('crypto'); +import crypto = require('crypto'); class FakePosthog { public capture; From 7c62386915b2e027d260c77d700610bea16791ef Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 11:28:19 +0100 Subject: [PATCH 051/131] lint --- src/PosthogAnalytics.ts | 14 +++++++------- test/PosthogAnalytics-test.ts | 18 +++++++++--------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 0435a0f22c..ee45f57e70 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -24,7 +24,7 @@ interface IEvent { eventName: string; // The properties of the event that will be stored in PostHog. - properties: {} + properties: {}; } export enum Anonymity { @@ -44,19 +44,19 @@ export interface IPseudonymousEvent extends IEvent {} export interface IAnonymousEvent extends IEvent {} export interface IRoomEvent extends IPseudonymousEvent { - hashedRoomId: string + hashedRoomId: string; } interface IPageView extends IAnonymousEvent { - eventName: "$pageview", + eventName: "$pageview"; properties: { - durationMs?: number - screen?: string - } + durationMs?: number; + screen?: string; + }; } export interface IWelcomeScreenLoad extends IAnonymousEvent { - eventName: "welcome_screen_load", + eventName: "welcome_screen_load"; } const hashHex = async (input: string): Promise => { diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index 095e216262..920f449bab 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -20,24 +20,24 @@ class FakePosthog { } export interface ITestEvent extends IAnonymousEvent { - key: "jest_test_event", + key: "jest_test_event"; properties: { - foo: string - } + foo: string; + }; } export interface ITestPseudonymousEvent extends IPseudonymousEvent { - key: "jest_test_pseudo_event", + key: "jest_test_pseudo_event"; properties: { - foo: string - } + foo: string; + }; } export interface ITestRoomEvent extends IRoomEvent { - key: "jest_test_room_event", + key: "jest_test_room_event"; properties: { - foo: string - } + foo: string; + }; } describe("PosthogAnalytics", () => { From 7cf28de9c90a53708821ce4a7cb82752f4401502 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 11:32:17 +0100 Subject: [PATCH 052/131] take 2 at fixing import --- test/PosthogAnalytics-test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index 920f449bab..57eb8ed72b 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -1,7 +1,7 @@ import { Anonymity, getRedactedCurrentLocation, IAnonymousEvent, IPseudonymousEvent, IRoomEvent, PosthogAnalytics } from '../src/PosthogAnalytics'; import SdkConfig from '../src/SdkConfig'; -import crypto = require('crypto'); +import crypto from 'crypto'; class FakePosthog { public capture; From d96e7e3375d64931af8eb9bebf49664a3fde0df6 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 12:08:55 +0100 Subject: [PATCH 053/131] Add transitive dev dependencies of posthog This is needed during tsc lint as posthog imports types from these libraries into its type definitions --- package.json | 4 +++- yarn.lock | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index c0062db46c..084f413605 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "what-input": "^5.2.10", "zxcvbn": "^4.4.2" }, - "devDependencies": { + "devDependencies": { "@babel/cli": "^7.12.10", "@babel/core": "^7.12.10", "@babel/eslint-parser": "^7.12.10", @@ -125,6 +125,7 @@ "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz", "@peculiar/webcrypto": "^1.1.4", "@sinonjs/fake-timers": "^7.0.2", + "@sentry/types": "^6.2.2", "@types/classnames": "^2.2.11", "@types/commonmark": "^0.27.4", "@types/counterpart": "^0.18.1", @@ -167,6 +168,7 @@ "matrix-web-i18n": "github:matrix-org/matrix-web-i18n", "react-test-renderer": "^17.0.2", "rimraf": "^3.0.2", + "rrweb": "^0.9.9", "stylelint": "^13.9.0", "stylelint-config-standard": "^20.0.0", "stylelint-scss": "^3.18.0", diff --git a/yarn.lock b/yarn.lock index c4d1456612..78f4838a09 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1352,6 +1352,11 @@ tslib "^2.2.0" webcrypto-core "^1.2.0" +"@sentry/types@^6.2.2": + version "6.10.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.10.0.tgz#6b1f44e5ed4dbc2710bead24d1b32fb08daf04e1" + integrity sha512-M7s0JFgG7/6/yNVYoPUbxzaXDhnzyIQYRRJJKRaTD77YO4MHvi4Ke8alBWqD5fer0cPIfcSkBqa9BLdqRqcMWw== + "@sinonjs/commons@^1.7.0": version "1.8.3" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" @@ -1448,6 +1453,11 @@ resolved "https://registry.yarnpkg.com/@types/counterpart/-/counterpart-0.18.1.tgz#b1b784d9e54d9879f0a8cb12f2caedab65430fe8" integrity sha512-PRuFlBBkvdDOtxlIASzTmkEFar+S66Ek48NVVTWMUjtJAdn5vyMSN8y6IZIoIymGpR36q2nZbIYazBWyFxL+IQ== +"@types/css-font-loading-module@0.0.4": + version "0.0.4" + resolved "https://registry.yarnpkg.com/@types/css-font-loading-module/-/css-font-loading-module-0.0.4.tgz#94a835e27d1af444c65cba88523533c174463d64" + integrity sha512-ENdXf7MW4m9HeDojB2Ukbi7lYMIuQNBHVf98dbzaiG4EEJREBd6oleVAjrLRCrp7dm6CK1mmdmU9tcgF61acbw== + "@types/css-font-loading-module@^0.0.6": version "0.0.6" resolved "https://registry.yarnpkg.com/@types/css-font-loading-module/-/css-font-loading-module-0.0.6.tgz#1ac3417ed31eeb953134d29b56bca921644b87c0" @@ -1790,6 +1800,11 @@ object.fromentries "^2.0.0" prop-types "^15.7.0" +"@xstate/fsm@^1.4.0": + version "1.6.1" + resolved "https://registry.yarnpkg.com/@xstate/fsm/-/fsm-1.6.1.tgz#c92972b835540c4e3c5e14277f40dbcbdaee9571" + integrity sha512-xYKDNuPR36/fUK+jmhM+oauBmbdUAfuJKnDjg3/7NbN+Pj03TX7e94LXnzkwGgAR+U/HWoMqM5UPTuGIYfIx9g== + abab@^2.0.3, abab@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a" @@ -3601,7 +3616,7 @@ fbjs@^0.8.4: setimmediate "^1.0.5" ua-parser-js "^0.7.18" -fflate@^0.4.1: +fflate@^0.4.1, fflate@^0.4.4: version "0.4.8" resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae" integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA== @@ -5639,6 +5654,11 @@ minimist@>=1.2.2, minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== +mitt@^1.1.3: + version "1.2.0" + resolved "https://registry.yarnpkg.com/mitt/-/mitt-1.2.0.tgz#cb24e6569c806e31bd4e3995787fe38a04fdf90d" + integrity sha512-r6lj77KlwqLhIUku9UWYes7KJtsczvolZkzp8hbaDPPaE24OmWl5s539Mytlj22siEQKosZ26qCBgda2PKwoJw== + mixin-deep@^1.2.0: version "1.3.2" resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" @@ -6841,6 +6861,22 @@ rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" +rrweb-snapshot@^1.0.3: + version "1.1.7" + resolved "https://registry.yarnpkg.com/rrweb-snapshot/-/rrweb-snapshot-1.1.7.tgz#92a3b47b1112a1b566c2fae2edb02fa48a6f6653" + integrity sha512-+f2kCCvIQ1hbEeCWnV7mPVPDEdWEExqwcYqMd/r1nfK52QE7qU52jefUOyTe85Vy67rZGqWnfK/B25e/OTSgYg== + +rrweb@^0.9.9: + version "0.9.14" + resolved "https://registry.yarnpkg.com/rrweb/-/rrweb-0.9.14.tgz#09bec604fc44c74801e4fe910606e5a6cde008ec" + integrity sha512-nm2rrVNoyWFPrbGQmcvTTlA7XjbbgPIgO7qsW0Zyr5iOURIFJDGPHFmOVLRyLpWiriVtEoXh6a+x+D1sj+qwWg== + dependencies: + "@types/css-font-loading-module" "0.0.4" + "@xstate/fsm" "^1.4.0" + fflate "^0.4.4" + mitt "^1.1.3" + rrweb-snapshot "^1.0.3" + rst-selector-parser@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz#81b230ea2fcc6066c89e3472de794285d9b03d91" From 55e8173ee9011e6dff733090f6671639244be692 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 12:12:10 +0100 Subject: [PATCH 054/131] remove whitespace --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 084f413605..e5ecbb31a9 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "what-input": "^5.2.10", "zxcvbn": "^4.4.2" }, - "devDependencies": { + "devDependencies": { "@babel/cli": "^7.12.10", "@babel/core": "^7.12.10", "@babel/eslint-parser": "^7.12.10", From 3ff7de3c967a7937830fcddf5e6785b1e0c5ff5a Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 13:43:06 +0100 Subject: [PATCH 055/131] Mock SHA-256 to avoid problems loading crypto on Node 14 --- test/PosthogAnalytics-test.ts | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index 57eb8ed72b..b7fae3c196 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -1,7 +1,6 @@ import { Anonymity, getRedactedCurrentLocation, IAnonymousEvent, IPseudonymousEvent, IRoomEvent, PosthogAnalytics } from '../src/PosthogAnalytics'; import SdkConfig from '../src/SdkConfig'; -import crypto from 'crypto'; class FakePosthog { public capture; @@ -42,12 +41,37 @@ export interface ITestRoomEvent extends IRoomEvent { describe("PosthogAnalytics", () => { let fakePosthog: FakePosthog; + const shaHashes = { + "42": "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049", + "some": "a6b46dd0d1ae5e86cbc8f37e75ceeb6760230c1ca4ffbcb0c97b96dd7d9c464b", + "pii": "bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4", + "foo": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae", + }; beforeEach(() => { fakePosthog = new FakePosthog(); window.crypto = { - subtle: crypto.webcrypto.subtle, + subtle: { + digest: async (_, encodedMessage) => { + const message = new TextDecoder().decode(encodedMessage); + const hexHash = shaHashes[message]; + const bytes = []; + for (let c = 0; c < hexHash.length; c += 2) { + bytes.push(parseInt(hexHash.substr(c, 2), 16)); + } + return bytes; + }, + /*console.log(message); + const digest = sha256(new WordArray(message)); + const digestBuf = new ArrayBuffer(digest.words.length * 4); + console.log(digest); + const view = new Uint32Array(digestBuf); + for (let i = 0; i < digest.words.length; i++) { + view[i] = digest.words[i]; + } + return digestBuf*/ + }, }; }); @@ -135,7 +159,7 @@ describe("PosthogAnalytics", () => { await analytics.trackAnonymousEvent("jest_test_event", { foo: "bar", }); - await analytics.trackRoomEvent("room id", "jest_test_room_event", { + await analytics.trackRoomEvent("room id", "foo", { foo: "bar", }); await analytics.trackPageView(200); From df7ebb2e7ce5894afd349f3a1f8cf930f80f5f90 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:00:37 +0100 Subject: [PATCH 056/131] Remove commented out block --- test/PosthogAnalytics-test.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index b7fae3c196..9b8e703c8e 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -62,15 +62,6 @@ describe("PosthogAnalytics", () => { } return bytes; }, - /*console.log(message); - const digest = sha256(new WordArray(message)); - const digestBuf = new ArrayBuffer(digest.words.length * 4); - console.log(digest); - const view = new Uint32Array(digestBuf); - for (let i = 0; i < digest.words.length; i++) { - view[i] = digest.words[i]; - } - return digestBuf*/ }, }; }); From 868d92781d402a46a5ec758c8ad10a77ef11f27d Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:01:21 +0100 Subject: [PATCH 057/131] Add copyright header --- src/PosthogAnalytics.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index ee45f57e70..9628ed1e4e 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -1,3 +1,19 @@ +/* +Copyright 2021 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 posthog, { PostHog } from 'posthog-js'; import PlatformPeg from './PlatformPeg'; import SdkConfig from './SdkConfig'; From d5bef53f8bc9f7c523afdc78c3c0c3500e268a30 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:03:17 +0100 Subject: [PATCH 058/131] Use snake case for feature name --- src/PosthogAnalytics.ts | 4 ++-- src/settings/Settings.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 9628ed1e4e..15bd10ad67 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -27,7 +27,7 @@ import SettingsStore from './settings/SettingsStore'; * - If [Do Not Track](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/doNotTrack) is * enabled, events are not sent (this detection is built into posthog and turned on via the * `respect_dnt` flag being passed to `posthog.init`). - * - If the `feature_pseudonymousAnalyticsOptIn` labs flag is `true`, track pseudonomously, i.e. + * - If the `feature_pseudonymous_analytics_opt_in` labs flag is `true`, track pseudonomously, i.e. * hash all matrix identifiers in tracking events (user IDs, room IDs etc) using SHA-256. * - Otherwise, if the existing `analyticsOptIn` flag is `true`, or not present (i.e. prior to * logging in), track anonymously, i.e. redact all matrix identifiers in tracking events. @@ -208,7 +208,7 @@ export class PosthogAnalytics { // (proposed wording) "Send pseudonymous usage data which helps us improve Element. This will use a cookie." // // TODO: Currently, this is only a labs flag, for testing purposes. - const pseudonumousOptIn = SettingsStore.getValue("feature_pseudonymousAnalyticsOptIn", null, true); + const pseudonumousOptIn = SettingsStore.getValue("feature_pseudonymous_analytics_opt_in", null, true); let anonymity; if (pseudonumousOptIn) { diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 810d8bb323..c287a3fd9d 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -299,7 +299,7 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_FEATURE, default: false, }, - "feature_pseudonymousAnalyticsOptIn": { + "feature_pseudonymous_analytics_opt_in": { isFeature: true, supportedLevels: LEVELS_FEATURE, displayName: _td('Send pseudonymous analytics data'), From 91e65534fa270fc22f62e30f77585ac1f67689c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Wed, 28 Jul 2021 15:04:33 +0200 Subject: [PATCH 059/131] await setState to avoid races where we would try to play media without an HTMLVideoElement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/components/views/voip/VideoFeed.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/views/voip/VideoFeed.tsx b/src/components/views/voip/VideoFeed.tsx index fef3aa0691..ad5b6f42fd 100644 --- a/src/components/views/voip/VideoFeed.tsx +++ b/src/components/views/voip/VideoFeed.tsx @@ -140,16 +140,16 @@ export default class VideoFeed extends React.Component { // seem to be necessary - Šimon } - private onNewStream = () => { - this.setState({ + private onNewStream = async () => { + await this.setState({ audioMuted: this.props.feed.isAudioMuted(), videoMuted: this.props.feed.isVideoMuted(), }); this.playMedia(); }; - private onMuteStateChanged = () => { - this.setState({ + private onMuteStateChanged = async () => { + await this.setState({ audioMuted: this.props.feed.isAudioMuted(), videoMuted: this.props.feed.isVideoMuted(), }); From 7c4e3efbff953c100efcd30e813c2447f9527775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Wed, 28 Jul 2021 15:11:31 +0200 Subject: [PATCH 060/131] Extend PureComponent to avoid unnecessary renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/components/views/voip/VideoFeed.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/voip/VideoFeed.tsx b/src/components/views/voip/VideoFeed.tsx index ad5b6f42fd..41c6b5185c 100644 --- a/src/components/views/voip/VideoFeed.tsx +++ b/src/components/views/voip/VideoFeed.tsx @@ -47,7 +47,7 @@ interface IState { } @replaceableComponent("views.voip.VideoFeed") -export default class VideoFeed extends React.Component { +export default class VideoFeed extends React.PureComponent { private element: HTMLVideoElement; constructor(props: IProps) { From a09e046c18d62dd3f14f18b6bf991f646bcad9d8 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:11:55 +0100 Subject: [PATCH 061/131] Update test/PosthogAnalytics-test.ts Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- test/PosthogAnalytics-test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index 9b8e703c8e..d80f2946c3 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -1,5 +1,11 @@ -import { Anonymity, getRedactedCurrentLocation, IAnonymousEvent, IPseudonymousEvent, IRoomEvent, - PosthogAnalytics } from '../src/PosthogAnalytics'; +import { + Anonymity, + getRedactedCurrentLocation, + IAnonymousEvent, + IPseudonymousEvent, + IRoomEvent, + PosthogAnalytics, +} from '../src/PosthogAnalytics'; import SdkConfig from '../src/SdkConfig'; class FakePosthog { From ecbc536a3eb67bee130793a396fa2693ceb02f74 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:15:27 +0100 Subject: [PATCH 062/131] Add copyright header --- test/PosthogAnalytics-test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index d80f2946c3..6cb1743051 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -1,3 +1,19 @@ +/* +Copyright 2021 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 { Anonymity, getRedactedCurrentLocation, @@ -6,6 +22,7 @@ import { IRoomEvent, PosthogAnalytics, } from '../src/PosthogAnalytics'; + import SdkConfig from '../src/SdkConfig'; class FakePosthog { From da3bf5a097b437b912d5d1ad78e00e5f0aceb7c2 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:14:15 +0100 Subject: [PATCH 063/131] rename knownScreens -> whitelistedScreens --- src/PosthogAnalytics.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 15bd10ad67..66f17a4937 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -83,7 +83,7 @@ const hashHex = async (input: string): Promise => { return [...new Uint8Array(digestBuf)].map((b: number) => b.toString(16).padStart(2, "0")).join(""); }; -const knownScreens = new Set([ +const whitelistedScreens = 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", ]); @@ -102,7 +102,7 @@ export async function getRedactedCurrentLocation(origin: string, hash: string, p } else { let [_, screen, ...parts] = hash.split("/"); - if (!knownScreens.has(screen)) { + if (!whitelistedScreens.has(screen)) { screen = ""; } From 9420b81eebdfc2b4edb3d6dcf0f179c4d29e358c Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:22:32 +0100 Subject: [PATCH 064/131] Rename mysterious _ to beforeFirstSlash --- src/PosthogAnalytics.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 66f17a4937..7331b2edd1 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -100,7 +100,7 @@ export async function getRedactedCurrentLocation(origin: string, hash: string, p if (hash == "") { hashStr = ""; } else { - let [_, screen, ...parts] = hash.split("/"); + let [beforeFirstSlash, screen, ...parts] = hash.split("/"); if (!whitelistedScreens.has(screen)) { screen = ""; @@ -110,7 +110,7 @@ export async function getRedactedCurrentLocation(origin: string, hash: string, p parts[i] = anonymity === Anonymity.Anonymous ? `` : await hashHex(parts[i]); } - hashStr = `${_}/${screen}/${parts.join("/")}`; + hashStr = `${beforeFirstSlash}/${screen}/${parts.join("/")}`; } return origin + pathname + hashStr; } From 60bc283455f22e3bbf640b538e9590a28dea4ef7 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:22:57 +0100 Subject: [PATCH 065/131] Add return type to getRedactedCurrentLocation --- src/PosthogAnalytics.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 7331b2edd1..9ba33e37c1 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -88,7 +88,12 @@ const whitelistedScreens = new Set([ "start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group", ]); -export async function getRedactedCurrentLocation(origin: string, hash: string, pathname: string, anonymity: Anonymity) { +export async function getRedactedCurrentLocation( + origin: string, + hash: string, + pathname: string, + anonymity: Anonymity, +): Promise { // Redact PII from the current location. // If anonymous is true, redact entirely, if false, substitute it with a hash. // For known screens, assumes a URL structure of //might/be/pii From a687bab52f6e5bcd66b9883a9a519b1fff54714e Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:38:41 +0100 Subject: [PATCH 066/131] Use readonly shorthand for posthog param --- src/PosthogAnalytics.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 9ba33e37c1..d10ea01f4e 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -138,7 +138,6 @@ export class PosthogAnalytics { */ private anonymity = Anonymity.Anonymous; - private posthog?: PostHog = null; // set true during the constructor if posthog config is present, otherwise false private enabled = false; private static _instance = null; @@ -151,8 +150,7 @@ export class PosthogAnalytics { return this._instance; } - constructor(posthog: PostHog) { - this.posthog = posthog; + constructor(private readonly posthog: PostHog) { const posthogConfig = SdkConfig.get()["posthog"]; if (posthogConfig) { this.posthog.init(posthogConfig.projectApiKey, { From 07b9d6b30b7468c6030cf5be71e89ec1854d9494 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 28 Jul 2021 14:39:19 +0100 Subject: [PATCH 067/131] Fix styling of setting flag descriptions in preferences and add description to spaces all/home setting and make it an account setting rather than device one and hide it from the Beta card --- res/css/views/settings/tabs/_SettingsTab.scss | 7 +++++++ .../settings/tabs/user/PreferencesUserSettingsTab.tsx | 10 ++++++++++ src/i18n/strings/en_EN.json | 1 + src/settings/Settings.tsx | 6 ++---- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/res/css/views/settings/tabs/_SettingsTab.scss b/res/css/views/settings/tabs/_SettingsTab.scss index 0d679af4e5..804a06186d 100644 --- a/res/css/views/settings/tabs/_SettingsTab.scss +++ b/res/css/views/settings/tabs/_SettingsTab.scss @@ -73,6 +73,13 @@ limitations under the License. padding-right: 10px; } +.mx_SettingsTab_section .mx_SettingsFlag .mx_SettingsFlag_microcopy { + margin-top: 4px; + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; +} + .mx_SettingsTab_section .mx_SettingsFlag .mx_ToggleSwitch { float: right; } diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx index 2e5db59d9b..53d8d41f69 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx @@ -26,6 +26,7 @@ import { replaceableComponent } from "../../../../../utils/replaceableComponent" import SettingsFlag from '../../../elements/SettingsFlag'; import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts"; import AccessibleButton from "../../../elements/AccessibleButton"; +import SpaceStore from "../../../../../stores/SpaceStore"; interface IState { autoLaunch: boolean; @@ -47,6 +48,10 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta 'breadcrumbs', ]; + static SPACES_SETTINGS = [ + "Spaces.all_rooms_in_home", + ]; + static KEYBINDINGS_SETTINGS = [ 'ctrlFForSearch', ]; @@ -231,6 +236,11 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta { this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS) }

+ { SpaceStore.spacesEnabled &&
+ { _t("Spaces") } + { this.renderGroup(PreferencesUserSettingsTab.SPACES_SETTINGS) } +
} +
{ _t("Keyboard shortcuts") } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 2cd2a096ad..4a728b72b6 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -868,6 +868,7 @@ "IRC display name width": "IRC display name width", "Show chat effects (animations when receiving e.g. confetti)": "Show chat effects (animations when receiving e.g. confetti)", "Show all rooms in Home": "Show all rooms in Home", + "All rooms you're in will appear in Home.": "All rooms you're in will appear in Home.", "Collecting app version information": "Collecting app version information", "Collecting logs": "Collecting logs", "Uploading logs": "Uploading logs", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index dfd6f1eec9..290f5de789 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -179,9 +179,6 @@ export const SETTINGS: {[setting: string]: ISetting} = { feedbackSubheading: _td("Your feedback will help make spaces better. " + "The more detail you can go into, the better."), feedbackLabel: "spaces-feedback", - extraSettings: [ - "Spaces.all_rooms_in_home", - ], }, }, "feature_dnd": { @@ -753,7 +750,8 @@ export const SETTINGS: {[setting: string]: ISetting} = { }, "Spaces.all_rooms_in_home": { displayName: _td("Show all rooms in Home"), - supportedLevels: LEVELS_FEATURE, + description: _td("All rooms you're in will appear in Home."), + supportedLevels: LEVELS_ACCOUNT_SETTINGS, default: false, }, [UIFeature.RoomHistorySettings]: { From df6d772d8d1df34988884d2af3acb6664657f8e0 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:40:32 +0100 Subject: [PATCH 068/131] Pin posthog version We'd like to manually review each posthog change to avoid unanticipated tracking leakages; each upgrade should include reviewing the data coming in on events --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e5ecbb31a9..bd989ac9bc 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "pako": "^2.0.3", "parse5": "^6.0.1", "png-chunks-extract": "^1.0.0", - "posthog-js": "^1.12.1", + "posthog-js": "1.12.1", "prop-types": "^15.7.2", "qrcode": "^1.4.4", "re-resizable": "^6.9.0", From 279871ce01f6e9c01dc5d5f691bcb01b0afb9c90 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:44:16 +0100 Subject: [PATCH 069/131] Add types --- src/PosthogAnalytics.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index d10ea01f4e..345f778c89 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -120,6 +120,11 @@ export async function getRedactedCurrentLocation( return origin + pathname + hashStr; } +interface PlatformProperties { + appVersion: string, + appPlatform: string +} + export class PosthogAnalytics { /* Wrapper for Posthog analytics. * 3 modes of anonymity are supported, governed by this.anonymity @@ -227,13 +232,13 @@ export class PosthogAnalytics { return anonymity; } - private registerSuperProperties(properties) { + private registerSuperProperties(properties: posthog.Properties) { if (this.enabled) { this.posthog.register(properties); } } - private static async getPlatformProperties() { + private static async getPlatformProperties(): Promise { const platform = PlatformPeg.get(); let appVersion; try { From ce80e5a4639cd1d698e58e229c609a50e0870743 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:44:28 +0100 Subject: [PATCH 070/131] Remove superfluous unused argument --- src/PosthogAnalytics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 345f778c89..7aae756894 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -178,7 +178,7 @@ export class PosthogAnalytics { } } - private sanitizeProperties(properties: posthog.Properties, _: string): posthog.Properties { + private sanitizeProperties(properties: posthog.Properties): posthog.Properties { // Callback from posthog to sanitize properties before sending them to the server. // // Here we sanitize posthog's built in properties which leak PII e.g. url reporting. From 537ce40f429c0284c7ba837f6e7912238242b9cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Wed, 28 Jul 2021 16:32:55 +0200 Subject: [PATCH 071/131] Add a TODO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/components/views/voip/VideoFeed.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/views/voip/VideoFeed.tsx b/src/components/views/voip/VideoFeed.tsx index 41c6b5185c..9975f70d62 100644 --- a/src/components/views/voip/VideoFeed.tsx +++ b/src/components/views/voip/VideoFeed.tsx @@ -46,6 +46,7 @@ interface IState { videoMuted: boolean; } +// TODO: We shouldn't be calling playMedia() all the time @replaceableComponent("views.voip.VideoFeed") export default class VideoFeed extends React.PureComponent { private element: HTMLVideoElement; From 13ef819ba6a36e3c3d39f4de373a32ec284495d2 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 16:42:56 +0100 Subject: [PATCH 072/131] isEnabled returns a boolean --- src/PosthogAnalytics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 7aae756894..7e7703a9aa 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -264,7 +264,7 @@ export class PosthogAnalytics { this.posthog.capture(eventName, properties); } - public isEnabled() { + public isEnabled(): boolean { return this.enabled; } From b1bd5f57a4deb3674e59179e0e72962dc99d1edd Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 16:43:13 +0100 Subject: [PATCH 073/131] Document IEvent.properties, fix IWelcomeScreenLoad IEvent.properties is a placeholder that needs to be overriden by extenders for type validation to take place. IWelcomeScreenLoad should have had properties declared for it. Because it didn't, a faulty call using it was possible. --- src/PosthogAnalytics.ts | 11 ++++++----- src/components/views/auth/Welcome.tsx | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 7e7703a9aa..63bfbda72e 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -32,15 +32,15 @@ import SettingsStore from './settings/SettingsStore'; * - Otherwise, if the existing `analyticsOptIn` flag is `true`, or not present (i.e. prior to * logging in), track anonymously, i.e. redact all matrix identifiers in tracking events. * - If both flags are false, events are not sent. -*/ + */ interface IEvent { - // The event name that will be used by PostHog. - // TODO: standard format (camel case? snake? UpperCase?) + // The event name that will be used by PostHog. Event names should use snake_case. eventName: string; - // The properties of the event that will be stored in PostHog. - properties: {}; + // The properties of the event that will be stored in PostHog. This is just a placeholder, + // extending interfaces must override this with a concrete definition to do type validation. + properties: {} } export enum Anonymity { @@ -73,6 +73,7 @@ interface IPageView extends IAnonymousEvent { export interface IWelcomeScreenLoad extends IAnonymousEvent { eventName: "welcome_screen_load"; + properties: Record; } const hashHex = async (input: string): Promise => { diff --git a/src/components/views/auth/Welcome.tsx b/src/components/views/auth/Welcome.tsx index 4ba603eaf4..75bbe15411 100644 --- a/src/components/views/auth/Welcome.tsx +++ b/src/components/views/auth/Welcome.tsx @@ -76,6 +76,6 @@ export default class Welcome extends React.PureComponent { } componentDidMount() { - getAnalytics().trackAnonymousEvent("welcome_screen_load", { foo: "bar" }); + getAnalytics().trackAnonymousEvent("welcome_screen_load", {}); } } From e5d36e9a81a387022094db844c9a66835b285a2d Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 17:02:26 +0100 Subject: [PATCH 074/131] Use arrow function instead of bind --- src/PosthogAnalytics.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 63bfbda72e..8f338f6012 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -170,7 +170,7 @@ export class PosthogAnalytics { // // To raise this manually, just call .capture("$pageview") or posthog.capture_pageview. capture_pageview: false, - sanitize_properties: this.sanitizeProperties.bind(this), + sanitize_properties: this.sanitizeProperties, respect_dnt: true, }); this.enabled = true; @@ -179,7 +179,7 @@ export class PosthogAnalytics { } } - private sanitizeProperties(properties: posthog.Properties): posthog.Properties { + private sanitizeProperties = (properties: posthog.Properties): posthog.Properties => { // Callback from posthog to sanitize properties before sending them to the server. // // Here we sanitize posthog's built in properties which leak PII e.g. url reporting. @@ -206,7 +206,7 @@ export class PosthogAnalytics { } return properties; - } + }; private static getAnonymityFromSettings(): Anonymity { // determine the current anonymity level based on curernt user settings From 0a951501b2f90c62419fcbd43af6f36616f59f74 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 17:04:18 +0100 Subject: [PATCH 075/131] lint --- src/PosthogAnalytics.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 8f338f6012..6d15ca79ce 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -40,7 +40,7 @@ interface IEvent { // The properties of the event that will be stored in PostHog. This is just a placeholder, // extending interfaces must override this with a concrete definition to do type validation. - properties: {} + properties: {}; } export enum Anonymity { @@ -122,8 +122,8 @@ export async function getRedactedCurrentLocation( } interface PlatformProperties { - appVersion: string, - appPlatform: string + appVersion: string; + appPlatform: string; } export class PosthogAnalytics { From e4722ee4578dc7d06999c68b7e321731c598b1d8 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 17:14:36 +0100 Subject: [PATCH 076/131] Override posthog type definitions to point to a locally fixed type definition file Posthog's type definitions refer to types in transitive dependencies we don't want to include. Clone posthog.d.ts locally, remove the offending types from it, and provide an overriding mapping in tsconfig. If this proves annoying to maintain, posthog.d.ts could just be an empty file. --- package.json | 2 - src/@types/posthog.d.ts | 739 ++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 9 +- yarn.lock | 40 +-- 4 files changed, 748 insertions(+), 42 deletions(-) create mode 100644 src/@types/posthog.d.ts diff --git a/package.json b/package.json index bd989ac9bc..6adb6ce004 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,6 @@ "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz", "@peculiar/webcrypto": "^1.1.4", "@sinonjs/fake-timers": "^7.0.2", - "@sentry/types": "^6.2.2", "@types/classnames": "^2.2.11", "@types/commonmark": "^0.27.4", "@types/counterpart": "^0.18.1", @@ -168,7 +167,6 @@ "matrix-web-i18n": "github:matrix-org/matrix-web-i18n", "react-test-renderer": "^17.0.2", "rimraf": "^3.0.2", - "rrweb": "^0.9.9", "stylelint": "^13.9.0", "stylelint-config-standard": "^20.0.0", "stylelint-scss": "^3.18.0", diff --git a/src/@types/posthog.d.ts b/src/@types/posthog.d.ts new file mode 100644 index 0000000000..1108e2c6df --- /dev/null +++ b/src/@types/posthog.d.ts @@ -0,0 +1,739 @@ +// Type definitions for exported methods + +declare class posthog { + /** + * This function initializes a new instance of the PostHog capturing object. + * All new instances are added to the main posthog object as sub properties (such as + * posthog.library_name) and also returned by this function. To define a + * second instance on the page, you would call: + * + * posthog.init('new token', { your: 'config' }, 'library_name'); + * + * and use it like so: + * + * posthog.library_name.capture(...); + * + * @param {String} token Your PostHog API token + * @param {Object} [config] A dictionary of config options to override. See a list of default config options. + * @param {String} [name] The name for the new posthog instance that you want created + */ + static init(token: string, config?: posthog.Config, name?: string): posthog + + /** + * Clears super properties and generates a new random distinct_id for this instance. + * Useful for clearing data when a user logs out. + */ + static reset(reset_device_id?: boolean): void + + /** + * Capture an event. This is the most important and + * frequently used PostHog function. + * + * ### Usage: + * + * // capture an event named 'Registered' + * posthog.capture('Registered', {'Gender': 'Male', 'Age': 21}); + * + * // capture an event using navigator.sendBeacon + * posthog.capture('Left page', {'duration_seconds': 35}, {transport: 'sendBeacon'}); + * + * @param {String} event_name The name of the event. This can be anything the user does - 'Button Click', 'Sign Up', 'Item Purchased', etc. + * @param {Object} [properties] A set of properties to include with the event you're sending. These describe the user who did the event or details about the event itself. + * @param {Object} [options] Optional configuration for this capture request. + * @param {String} [options.transport] Transport method for network request ('XHR' or 'sendBeacon'). + */ + static capture( + event_name: string, + properties?: posthog.Properties, + options?: { transport: 'XHR' | 'sendBeacon' } + ): posthog.CaptureResult + + /** + * Capture a page view event, which is currently ignored by the server. + * This function is called by default on page load unless the + * capture_pageview configuration variable is false. + * + * @param {String} [page] The url of the page to record. If you don't include this, it defaults to the current url. + * @api private + */ + static capture_pageview(page?: string): void + + /** + * Register a set of super properties, which are included with all + * events. This will overwrite previous super property values. + * + * ### Usage: + * + * // register 'Gender' as a super property + * posthog.register({'Gender': 'Female'}); + * + * // register several super properties when a user signs up + * posthog.register({ + * 'Email': 'jdoe@example.com', + * 'Account Type': 'Free' + * }); + * + * @param {Object} properties An associative array of properties to store about the user + * @param {Number} [days] How many days since the user's last visit to store the super properties + */ + static register(properties: posthog.Properties, days?: number): void + + /** + * Register a set of super properties only once. This will not + * overwrite previous super property values, unlike register(). + * + * ### Usage: + * + * // register a super property for the first time only + * posthog.register_once({ + * 'First Login Date': new Date().toISOString() + * }); + * + * ### Notes: + * + * If default_value is specified, current super properties + * with that value will be overwritten. + * + * @param {Object} properties An associative array of properties to store about the user + * @param {*} [default_value] Value to override if already set in super properties (ex: 'False') Default: 'None' + * @param {Number} [days] How many days since the users last visit to store the super properties + */ + static register_once(properties: posthog.Properties, default_value?: posthog.Property, days?: number): void + + /** + * Delete a super property stored with the current user. + * + * @param {String} property The name of the super property to remove + */ + static unregister(property: string): void + + /** + * Identify a user with a unique ID instead of a PostHog + * randomly generated distinct_id. If the method is never called, + * then unique visitors will be identified by a UUID generated + * the first time they visit the site. + * + * If user properties are passed, they are also sent to posthog. + * + * ### Usage: + * + * posthog.identify('[user unique id]') + * posthog.identify('[user unique id]', { email: 'john@example.com' }) + * posthog.identify('[user unique id]', {}, { referral_code: '12345' }) + * + * ### Notes: + * + * You can call this function to overwrite a previously set + * unique ID for the current user. PostHog cannot translate + * between IDs at this time, so when you change a user's ID + * they will appear to be a new user. + * + * When used alone, posthog.identify will change the user's + * distinct_id to the unique ID provided. When used in tandem + * with posthog.alias, it will allow you to identify based on + * unique ID and map that back to the original, anonymous + * distinct_id given to the user upon her first arrival to your + * site (thus connecting anonymous pre-signup activity to + * post-signup activity). Though the two work together, do not + * call identify() at the same time as alias(). Calling the two + * at the same time can cause a race condition, so it is best + * practice to call identify on the original, anonymous ID + * right after you've aliased it. + * + * @param {String} [unique_id] A string that uniquely identifies a user. If not provided, the distinct_id currently in the persistent store (cookie or localStorage) will be used. + * @param {Object} [userProperties] Optional: An associative array of properties to store about the user + * @param {Object} [userPropertiesToSetOnce] Optional: An associative array of properties to store about the user. If property is previously set, this does not override that value. + */ + static identify( + unique_id?: string, + userPropertiesToSet?: posthog.Properties, + userPropertiesToSetOnce?: posthog.Properties + ): void + + /** + * Create an alias, which PostHog will use to link two distinct_ids going forward (not retroactively). + * Multiple aliases can map to the same original ID, but not vice-versa. Aliases can also be chained - the + * following is a valid scenario: + * + * posthog.alias('new_id', 'existing_id'); + * ... + * posthog.alias('newer_id', 'new_id'); + * + * If the original ID is not passed in, we will use the current distinct_id - probably the auto-generated GUID. + * + * ### Notes: + * + * The best practice is to call alias() when a unique ID is first created for a user + * (e.g., when a user first registers for an account and provides an email address). + * alias() should never be called more than once for a given user, except to + * chain a newer ID to a previously new ID, as described above. + * + * @param {String} alias A unique identifier that you want to use for this user in the future. + * @param {String} [original] The current identifier being used for this user. + */ + static alias(alias: string, original?: string): posthog.CaptureResult | number + + /** + * Update the configuration of a posthog library instance. + * + * The default config is: + * + * { + * // HTTP method for capturing requests + * api_method: 'POST' + * + * // transport for sending requests ('XHR' or 'sendBeacon') + * // NB: sendBeacon should only be used for scenarios such as + * // page unload where a "best-effort" attempt to send is + * // acceptable; the sendBeacon API does not support callbacks + * // or any way to know the result of the request. PostHog + * // capturing via sendBeacon will not support any event- + * // batching or retry mechanisms. + * api_transport: 'XHR' + * + * // Automatically capture clicks, form submissions and change events + * autocapture: true + * + * // Capture rage clicks (beta) - useful for session recording + * rageclick: false + * + * // super properties cookie expiration (in days) + * cookie_expiration: 365 + * + * // super properties span subdomains + * cross_subdomain_cookie: true + * + * // debug mode + * debug: false + * + * // if this is true, the posthog cookie or localStorage entry + * // will be deleted, and no user persistence will take place + * disable_persistence: false + * + * // if this is true, PostHog will automatically determine + * // City, Region and Country data using the IP address of + * //the client + * ip: true + * + * // opt users out of capturing by this PostHog instance by default + * opt_out_capturing_by_default: false + * + * // opt users out of browser data storage by this PostHog instance by default + * opt_out_persistence_by_default: false + * + * // persistence mechanism used by opt-in/opt-out methods - cookie + * // or localStorage - falls back to cookie if localStorage is unavailable + * opt_out_capturing_persistence_type: 'localStorage' + * + * // customize the name of cookie/localStorage set by opt-in/opt-out methods + * opt_out_capturing_cookie_prefix: null + * + * // type of persistent store for super properties (cookie/ + * // localStorage) if set to 'localStorage', any existing + * // posthog cookie value with the same persistence_name + * // will be transferred to localStorage and deleted + * persistence: 'cookie' + * + * // name for super properties persistent store + * persistence_name: '' + * + * // names of properties/superproperties which should never + * // be sent with capture() calls + * property_blacklist: [] + * + * // if this is true, posthog cookies will be marked as + * // secure, meaning they will only be transmitted over https + * secure_cookie: false + * + * // should we capture a page view on page load + * capture_pageview: true + * + * // if you set upgrade to be true, the library will check for + * // a cookie from our old js library and import super + * // properties from it, then the old cookie is deleted + * // The upgrade config option only works in the initialization, + * // so make sure you set it when you create the library. + * upgrade: false + * + * // extra HTTP request headers to set for each API request, in + * // the format {'Header-Name': value} + * xhr_headers: {} + * + * // protocol for fetching in-app message resources, e.g. + * // 'https://' or 'http://'; defaults to '//' (which defers to the + * // current page's protocol) + * inapp_protocol: '//' + * + * // whether to open in-app message link in new tab/window + * inapp_link_new_window: false + * + * // a set of rrweb config options that PostHog users can configure + * // see https://github.com/rrweb-io/rrweb/blob/master/guide.md + * session_recording: { + * blockClass: 'ph-no-capture', + * blockSelector: null, + * ignoreClass: 'ph-ignore-input', + * maskAllInputs: false, + * maskInputOptions: {}, + * maskInputFn: null, + * slimDOMOptions: {}, + * collectFonts: false + * } + * + * // prevent autocapture from capturing any attribute names on elements + * mask_all_element_attributes: false + * + * // prevent autocapture from capturing textContent on all elements + * mask_all_text: false + * + * // will disable requests to the /decide endpoint (please review documentation for details) + * // autocapture, feature flags, compression and session recording will be disabled when set to `true` + * advanced_disable_decide: false + * + * } + * + * + * @param {Object} config A dictionary of new configuration values to update + */ + static set_config(config: posthog.Config): void + + /** + * returns the current config object for the library. + */ + static get_config(prop_name: T): posthog.Config[T] + + /** + * Returns the value of the super property named property_name. If no such + * property is set, get_property() will return the undefined value. + * + * ### Notes: + * + * get_property() can only be called after the PostHog library has finished loading. + * init() has a loaded function available to handle this automatically. For example: + * + * // grab value for 'user_id' after the posthog library has loaded + * posthog.init('YOUR PROJECT TOKEN', { + * loaded: function(posthog) { + * user_id = posthog.get_property('user_id'); + * } + * }); + * + * @param {String} property_name The name of the super property you want to retrieve + */ + static get_property(property_name: string): posthog.Property | undefined + + /** + * Returns the current distinct id of the user. This is either the id automatically + * generated by the library or the id that has been passed by a call to identify(). + * + * ### Notes: + * + * get_distinct_id() can only be called after the PostHog library has finished loading. + * init() has a loaded function available to handle this automatically. For example: + * + * // set distinct_id after the posthog library has loaded + * posthog.init('YOUR PROJECT TOKEN', { + * loaded: function(posthog) { + * distinct_id = posthog.get_distinct_id(); + * } + * }); + */ + static get_distinct_id(): string + + /** + * Opt the user out of data capturing and cookies/localstorage for this PostHog instance + * + * ### Usage + * + * // opt user out + * posthog.opt_out_capturing(); + * + * // opt user out with different cookie configuration from PostHog instance + * posthog.opt_out_capturing({ + * cookie_expiration: 30, + * secure_cookie: true + * }); + * + * @param {Object} [options] A dictionary of config options to override + * @param {boolean} [options.clear_persistence=true] If true, will delete all data stored by the sdk in persistence + * @param {string} [options.persistence_type=localStorage] Persistence mechanism used - cookie or localStorage - falls back to cookie if localStorage is unavailable + * @param {string} [options.cookie_prefix=__ph_opt_in_out] Custom prefix to be used in the cookie/localstorage name + * @param {Number} [options.cookie_expiration] Number of days until the opt-in cookie expires (overrides value specified in this PostHog instance's config) + * @param {boolean} [options.cross_subdomain_cookie] Whether the opt-in cookie is set as cross-subdomain or not (overrides value specified in this PostHog instance's config) + * @param {boolean} [options.secure_cookie] Whether the opt-in cookie is set as secure or not (overrides value specified in this PostHog instance's config) + */ + static opt_out_capturing(options?: posthog.OptInOutCapturingOptions): void + + /** + * Opt the user in to data capturing and cookies/localstorage for this PostHog instance + * + * ### Usage + * + * // opt user in + * posthog.opt_in_capturing(); + * + * // opt user in with specific event name, properties, cookie configuration + * posthog.opt_in_capturing({ + * capture_event_name: 'User opted in', + * capture_event_properties: { + * 'Email': 'jdoe@example.com' + * }, + * cookie_expiration: 30, + * secure_cookie: true + * }); + * + * @param {Object} [options] A dictionary of config options to override + * @param {function} [options.capture] Function used for capturing a PostHog event to record the opt-in action (default is this PostHog instance's capture method) + * @param {string} [options.capture_event_name=$opt_in] Event name to be used for capturing the opt-in action + * @param {Object} [options.capture_properties] Set of properties to be captured along with the opt-in action + * @param {boolean} [options.enable_persistence=true] If true, will re-enable sdk persistence + * @param {string} [options.persistence_type=localStorage] Persistence mechanism used - cookie or localStorage - falls back to cookie if localStorage is unavailable + * @param {string} [options.cookie_prefix=__ph_opt_in_out] Custom prefix to be used in the cookie/localstorage name + * @param {Number} [options.cookie_expiration] Number of days until the opt-in cookie expires (overrides value specified in this PostHog instance's config) + * @param {boolean} [options.cross_subdomain_cookie] Whether the opt-in cookie is set as cross-subdomain or not (overrides value specified in this PostHog instance's config) + * @param {boolean} [options.secure_cookie] Whether the opt-in cookie is set as secure or not (overrides value specified in this PostHog instance's config) + */ + static opt_in_capturing(options?: posthog.OptInOutCapturingOptions): void + + /** + * Check whether the user has opted out of data capturing and cookies/localstorage for this PostHog instance + * + * ### Usage + * + * const has_opted_out = posthog.has_opted_out_capturing(); + * // use has_opted_out value + * + * @param {Object} [options] A dictionary of config options to override + * @param {string} [options.persistence_type=localStorage] Persistence mechanism used - cookie or localStorage - falls back to cookie if localStorage is unavailable + * @param {string} [options.cookie_prefix=__ph_opt_in_out] Custom prefix to be used in the cookie/localstorage name + * @returns {boolean} current opt-out status + */ + static has_opted_out_capturing(options?: posthog.HasOptedInOutCapturingOptions): boolean + + /** + * Check whether the user has opted in to data capturing and cookies/localstorage for this PostHog instance + * + * ### Usage + * + * const has_opted_in = posthog.has_opted_in_capturing(); + * // use has_opted_in value + * + * @param {Object} [options] A dictionary of config options to override + * @param {string} [options.persistence_type=localStorage] Persistence mechanism used - cookie or localStorage - falls back to cookie if localStorage is unavailable + * @param {string} [options.cookie_prefix=__ph_opt_in_out] Custom prefix to be used in the cookie/localstorage name + * @returns {boolean} current opt-in status + */ + static has_opted_in_capturing(options?: posthog.HasOptedInOutCapturingOptions): boolean + + /** + * Clear the user's opt in/out status of data capturing and cookies/localstorage for this PostHog instance + * + * ### Usage + * + * // clear user's opt-in/out status + * posthog.clear_opt_in_out_capturing(); + * + * // clear user's opt-in/out status with specific cookie configuration - should match + * // configuration used when opt_in_capturing/opt_out_capturing methods were called. + * posthog.clear_opt_in_out_capturing({ + * cookie_expiration: 30, + * secure_cookie: true + * }); + * + * @param {Object} [options] A dictionary of config options to override + * @param {boolean} [options.enable_persistence=true] If true, will re-enable sdk persistence + * @param {string} [options.persistence_type=localStorage] Persistence mechanism used - cookie or localStorage - falls back to cookie if localStorage is unavailable + * @param {string} [options.cookie_prefix=__ph_opt_in_out] Custom prefix to be used in the cookie/localstorage name + * @param {Number} [options.cookie_expiration] Number of days until the opt-in cookie expires (overrides value specified in this PostHog instance's config) + * @param {boolean} [options.cross_subdomain_cookie] Whether the opt-in cookie is set as cross-subdomain or not (overrides value specified in this PostHog instance's config) + * @param {boolean} [options.secure_cookie] Whether the opt-in cookie is set as secure or not (overrides value specified in this PostHog instance's config) + */ + static clear_opt_in_out_capturing(options?: posthog.ClearOptInOutCapturingOptions): void + + /* + * See if feature flag is enabled for user. + * + * ### Usage: + * + * if(posthog.isFeatureEnabled('beta-feature')) { // do something } + * + * @param {Object|String} prop Key of the feature flag. + * @param {Object|String} options (optional) If {send_event: false}, we won't send an $feature_flag_call event to PostHog. + */ + static isFeatureEnabled(key: string, options?: posthog.isFeatureEnabledOptions): boolean + + /* + * See if feature flags are available. + * + * ### Usage: + * + * posthog.onFeatureFlags(function(featureFlags) { // do something }) + * + * @param {Function} [callback] The callback function will be called once the feature flags are ready. It'll return a list of feature flags enabled for the user. + */ + static onFeatureFlags(callback: (flags: string[]) => void): false | undefined + + /* + * Reload all feature flags for the user. + * + * ### Usage: + * + * posthog.reloadFeatureFlags() + */ + static reloadFeatureFlags(): void + + static toString(): string + + /* Will log all capture requests to the Javascript console, including event properties for easy debugging */ + static debug(): void + + /* + * Starts session recording and updates disable_session_recording to false. + * Used for manual session recording management. By default, session recording is enabled and + * starts automatically. + * + * ### Usage: + * + * posthog.startSessionRecording() + */ + static startSessionRecording(): void + + /* + * Stops session recording and updates disable_session_recording to true. + * + * ### Usage: + * + * posthog.stopSessionRecording() + */ + static stopSessionRecording(): void + + /* + * Check if session recording is currently running. + * + * ### Usage: + * + * const isSessionRecordingOn = posthog.sessionRecordingStarted() + */ + static sessionRecordingStarted(): boolean +} + +declare namespace posthog { + /* eslint-disable @typescript-eslint/no-explicit-any */ + type Property = any; + type Properties = Record; + type CaptureResult = { event: string; properties: Properties } | undefined; + type CaptureCallback = (response: any, data: any) => void; + /* eslint-enable @typescript-eslint/no-explicit-any */ + + interface Config { + api_host?: string + api_method?: string + api_transport?: string + autocapture?: boolean + rageclick?: boolean + cdn?: string + cross_subdomain_cookie?: boolean + persistence?: 'localStorage' | 'cookie' | 'memory' + persistence_name?: string + cookie_name?: string + loaded?: (posthog_instance: typeof posthog) => void + store_google?: boolean + save_referrer?: boolean + test?: boolean + verbose?: boolean + img?: boolean + capture_pageview?: boolean + debug?: boolean + cookie_expiration?: number + upgrade?: boolean + disable_session_recording?: boolean + disable_persistence?: boolean + disable_cookie?: boolean + secure_cookie?: boolean + ip?: boolean + opt_out_capturing_by_default?: boolean + opt_out_persistence_by_default?: boolean + opt_out_capturing_persistence_type?: 'localStorage' | 'cookie' + opt_out_capturing_cookie_prefix?: string | null + respect_dnt?: boolean + property_blacklist?: string[] + xhr_headers?: { [header_name: string]: string } + inapp_protocol?: string + inapp_link_new_window?: boolean + request_batching?: boolean + sanitize_properties?: (properties: posthog.Properties, event_name: string) => posthog.Properties + properties_string_max_length?: number + mask_all_element_attributes?: boolean + mask_all_text?: boolean + advanced_disable_decide?: boolean + } + + interface OptInOutCapturingOptions { + clear_persistence: boolean + persistence_type: string + cookie_prefix: string + cookie_expiration: number + cross_subdomain_cookie: boolean + secure_cookie: boolean + } + + interface HasOptedInOutCapturingOptions { + persistence_type: string + cookie_prefix: string + } + + interface ClearOptInOutCapturingOptions { + enable_persistence: boolean + persistence_type: string + cookie_prefix: string + cookie_expiration: number + cross_subdomain_cookie: boolean + secure_cookie: boolean + } + + interface isFeatureEnabledOptions { + send_event: boolean + } + + export class persistence { + static properties(): posthog.Properties + + static load(): void + + static save(): void + + static remove(): void + + static clear(): void + + /** + * @param {Object} props + * @param {*=} default_value + * @param {number=} days + */ + static register_once(props: Properties, default_value?: Property, days?: number): boolean + + /** + * @param {Object} props + * @param {number=} days + */ + static register(props: posthog.Properties, days?: number): boolean + + static unregister(prop: string): void + + static update_campaign_params(): void + + static update_search_keyword(referrer: string): void + + static update_referrer_info(referrer: string): void + + static get_referrer_info(): posthog.Properties + + static safe_merge(props: posthog.Properties): posthog.Properties + + static update_config(config: posthog.Config): void + + static set_disabled(disabled: boolean): void + + static set_cross_subdomain(cross_subdomain: boolean): void + + static get_cross_subdomain(): boolean + + static set_secure(secure: boolean): void + + static set_event_timer(event_name: string, timestamp: Date): void + + static remove_event_timer(event_name: string): Date | undefined + } + + export class people { + /* + * Set properties on a user record. + * + * ### Usage: + * + * posthog.people.set('gender', 'm'); + * + * // or set multiple properties at once + * posthog.people.set({ + * 'Company': 'Acme', + * 'Plan': 'Premium', + * 'Upgrade date': new Date() + * }); + * // properties can be strings, integers, dates, or lists + * + * @param {Object|String} prop If a string, this is the name of the property. If an object, this is an associative array of names and values. + * @param {*} [to] A value to set on the given property name + * @param {Function} [callback] If provided, the callback will be called after capturing the event. + */ + static set( + prop: posthog.Properties | string, + to?: posthog.Property, + callback?: posthog.CaptureCallback + ): posthog.Properties + + /* + * Set properties on a user record, only if they do not yet exist. + * This will not overwrite previous people property values, unlike + * people.set(). + * + * ### Usage: + * + * posthog.people.set_once('First Login Date', new Date()); + * + * // or set multiple properties at once + * posthog.people.set_once({ + * 'First Login Date': new Date(), + * 'Starting Plan': 'Premium' + * }); + * + * // properties can be strings, integers or dates + * + * @param {Object|String} prop If a string, this is the name of the property. If an object, this is an associative array of names and values. + * @param {*} [to] A value to set on the given property name + * @param {Function} [callback] If provided, the callback will be called after capturing the event. + */ + static set_once( + prop: posthog.Properties | string, + to?: posthog.Property, + callback?: posthog.CaptureCallback + ): posthog.Properties + + static toString(): string + } + + export class featureFlags { + static getFlags(): string[] + + static reloadFeatureFlags(): void + + /* + * See if feature flag is enabled for user. + * + * ### Usage: + * + * if(posthog.isFeatureEnabled('beta-feature')) { // do something } + * + * @param {Object|String} prop Key of the feature flag. + * @param {Object|String} options (optional) If {send_event: false}, we won't send an $feature_flag_call event to PostHog. + */ + static isFeatureEnabled(key: string, options?: { send_event?: boolean }): boolean + + /* + * See if feature flags are available. + * + * ### Usage: + * + * posthog.onFeatureFlags(function(featureFlags) { // do something }) + * + * @param {Function} [callback] The callback function will be called once the feature flags are ready. It'll return a list of feature flags enabled for the user. + */ + static onFeatureFlags(callback: (flags: string[]) => void): false | undefined + } + + export class feature_flags extends featureFlags {} +} + +export type PostHog = typeof posthog; + +export default posthog; diff --git a/tsconfig.json b/tsconfig.json index b139e8e8d1..b982d40b07 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,10 +22,15 @@ "es2019", "dom", "dom.iterable" - ] + ], + "paths": { + "posthog-js": [ + "./src/@types/posthog.d.ts" + ] + } }, "include": [ "./src/**/*.ts", "./src/**/*.tsx" - ] + ], } diff --git a/yarn.lock b/yarn.lock index 78f4838a09..633bf99ee6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1352,11 +1352,6 @@ tslib "^2.2.0" webcrypto-core "^1.2.0" -"@sentry/types@^6.2.2": - version "6.10.0" - resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.10.0.tgz#6b1f44e5ed4dbc2710bead24d1b32fb08daf04e1" - integrity sha512-M7s0JFgG7/6/yNVYoPUbxzaXDhnzyIQYRRJJKRaTD77YO4MHvi4Ke8alBWqD5fer0cPIfcSkBqa9BLdqRqcMWw== - "@sinonjs/commons@^1.7.0": version "1.8.3" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" @@ -1453,11 +1448,6 @@ resolved "https://registry.yarnpkg.com/@types/counterpart/-/counterpart-0.18.1.tgz#b1b784d9e54d9879f0a8cb12f2caedab65430fe8" integrity sha512-PRuFlBBkvdDOtxlIASzTmkEFar+S66Ek48NVVTWMUjtJAdn5vyMSN8y6IZIoIymGpR36q2nZbIYazBWyFxL+IQ== -"@types/css-font-loading-module@0.0.4": - version "0.0.4" - resolved "https://registry.yarnpkg.com/@types/css-font-loading-module/-/css-font-loading-module-0.0.4.tgz#94a835e27d1af444c65cba88523533c174463d64" - integrity sha512-ENdXf7MW4m9HeDojB2Ukbi7lYMIuQNBHVf98dbzaiG4EEJREBd6oleVAjrLRCrp7dm6CK1mmdmU9tcgF61acbw== - "@types/css-font-loading-module@^0.0.6": version "0.0.6" resolved "https://registry.yarnpkg.com/@types/css-font-loading-module/-/css-font-loading-module-0.0.6.tgz#1ac3417ed31eeb953134d29b56bca921644b87c0" @@ -1800,11 +1790,6 @@ object.fromentries "^2.0.0" prop-types "^15.7.0" -"@xstate/fsm@^1.4.0": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@xstate/fsm/-/fsm-1.6.1.tgz#c92972b835540c4e3c5e14277f40dbcbdaee9571" - integrity sha512-xYKDNuPR36/fUK+jmhM+oauBmbdUAfuJKnDjg3/7NbN+Pj03TX7e94LXnzkwGgAR+U/HWoMqM5UPTuGIYfIx9g== - abab@^2.0.3, abab@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a" @@ -3616,7 +3601,7 @@ fbjs@^0.8.4: setimmediate "^1.0.5" ua-parser-js "^0.7.18" -fflate@^0.4.1, fflate@^0.4.4: +fflate@^0.4.1: version "0.4.8" resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae" integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA== @@ -5654,11 +5639,6 @@ minimist@>=1.2.2, minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== -mitt@^1.1.3: - version "1.2.0" - resolved "https://registry.yarnpkg.com/mitt/-/mitt-1.2.0.tgz#cb24e6569c806e31bd4e3995787fe38a04fdf90d" - integrity sha512-r6lj77KlwqLhIUku9UWYes7KJtsczvolZkzp8hbaDPPaE24OmWl5s539Mytlj22siEQKosZ26qCBgda2PKwoJw== - mixin-deep@^1.2.0: version "1.3.2" resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" @@ -6274,7 +6254,7 @@ postcss@^8.0.2: nanoid "^3.1.23" source-map-js "^0.6.2" -posthog-js@^1.12.1: +posthog-js@1.12.1: version "1.12.1" resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.12.1.tgz#97834ee2574f34ffb5db2f5b07452c847e3c4d27" integrity sha512-Y3lzcWkS8xFY6Ryj3I4ees7qWP2WGkLw0Arcbk5xaT0+5YlA6UC2jlL/+fN9bz/Bl62EoN3BML901Cuot/QNjg== @@ -6861,22 +6841,6 @@ rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" -rrweb-snapshot@^1.0.3: - version "1.1.7" - resolved "https://registry.yarnpkg.com/rrweb-snapshot/-/rrweb-snapshot-1.1.7.tgz#92a3b47b1112a1b566c2fae2edb02fa48a6f6653" - integrity sha512-+f2kCCvIQ1hbEeCWnV7mPVPDEdWEExqwcYqMd/r1nfK52QE7qU52jefUOyTe85Vy67rZGqWnfK/B25e/OTSgYg== - -rrweb@^0.9.9: - version "0.9.14" - resolved "https://registry.yarnpkg.com/rrweb/-/rrweb-0.9.14.tgz#09bec604fc44c74801e4fe910606e5a6cde008ec" - integrity sha512-nm2rrVNoyWFPrbGQmcvTTlA7XjbbgPIgO7qsW0Zyr5iOURIFJDGPHFmOVLRyLpWiriVtEoXh6a+x+D1sj+qwWg== - dependencies: - "@types/css-font-loading-module" "0.0.4" - "@xstate/fsm" "^1.4.0" - fflate "^0.4.4" - mitt "^1.1.3" - rrweb-snapshot "^1.0.3" - rst-selector-parser@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz#81b230ea2fcc6066c89e3472de794285d9b03d91" From 7b4a7711b2c05b68fcabdae88b3674046c55036a Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 17:20:22 +0100 Subject: [PATCH 077/131] Declare return types for all public methods, even void ones --- src/PosthogAnalytics.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 6d15ca79ce..d5bb12621d 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -269,7 +269,7 @@ export class PosthogAnalytics { return this.enabled; } - public setAnonymity(anonymity: Anonymity) { + public setAnonymity(anonymity: Anonymity): void { // Update this.anonymity. // This is public for testing purposes, typically you want to call updateAnonymityFromSettings // to ensure this value is in step with the user's settings. @@ -283,17 +283,17 @@ export class PosthogAnalytics { this.anonymity = anonymity; } - public async identifyUser(userId: string) { + public async identifyUser(userId: string): Promise { if (this.anonymity == Anonymity.Pseudonymous) { this.posthog.identify(await hashHex(userId)); } } - public getAnonymity() { + public getAnonymity(): Anonymity { return this.anonymity; } - public logout() { + public logout(): void { if (this.enabled) { this.posthog.reset(); } @@ -311,7 +311,7 @@ export class PosthogAnalytics { public async trackAnonymousEvent( eventName: E["eventName"], properties: E["properties"], - ) { + ): Promise { if (this.anonymity == Anonymity.Disabled) return; await this.capture(eventName, properties); } @@ -320,7 +320,7 @@ export class PosthogAnalytics { eventName: E["eventName"], roomId: string, properties: Omit, - ) { + ): Promise { const updatedProperties = { ...properties, hashedRoomId: roomId ? await hashHex(roomId) : null, @@ -328,7 +328,7 @@ export class PosthogAnalytics { await this.trackPseudonymousEvent(eventName, updatedProperties); } - public async trackPageView(durationMs: number) { + public async trackPageView(durationMs: number): Promise { const hash = window.location.hash; let screen = null; @@ -343,7 +343,7 @@ export class PosthogAnalytics { }); } - public async updatePlatformSuperProperties() { + public async updatePlatformSuperProperties(): Promise { // Update super properties in posthog with our platform (app version, platform). // These properties will be subsequently passed in every event. // @@ -353,7 +353,7 @@ export class PosthogAnalytics { this.registerSuperProperties(this.platformSuperProperties); } - public async updateAnonymityFromSettings(userId?: string) { + public async updateAnonymityFromSettings(userId?: string): Promise { // Update this.anonymity based on the user's analytics opt-in settings // Identify the user (via hashed user ID) to posthog if anonymity is pseudonmyous this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings()); From d401789f9ec3e284053bd5d8b00bf0723582c469 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 17:26:24 +0100 Subject: [PATCH 078/131] Ignore eslint conventions in disastrous posthog type definitions --- src/@types/posthog.d.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/@types/posthog.d.ts b/src/@types/posthog.d.ts index 1108e2c6df..1ca475cd3b 100644 --- a/src/@types/posthog.d.ts +++ b/src/@types/posthog.d.ts @@ -1,3 +1,12 @@ +// A clone of the type definitions from posthog-js, stripped of references to transitive +// dependencies which we don't actually use, so that we don't need to install them. +// +// Original file lives in node_modules/posthog/dist/module.d.ts + +/* eslint-disable @typescript-eslint/member-delimiter-style */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable camelcase */ + // Type definitions for exported methods declare class posthog { From cdf0d98c3fca269a0be32fb6caaf12846dc6bd70 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 28 Jul 2021 17:39:02 +0100 Subject: [PATCH 079/131] Fix IconizedContextMenuCheckbox layout --- .../views/context_menus/_IconizedContextMenu.scss | 13 +++++++++---- .../views/context_menus/IconizedContextMenu.tsx | 5 ++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/res/css/views/context_menus/_IconizedContextMenu.scss b/res/css/views/context_menus/_IconizedContextMenu.scss index 204435995f..f83699b505 100644 --- a/res/css/views/context_menus/_IconizedContextMenu.scss +++ b/res/css/views/context_menus/_IconizedContextMenu.scss @@ -145,12 +145,17 @@ limitations under the License. } } - .mx_IconizedContextMenu_checked { + .mx_IconizedContextMenu_checked, + .mx_IconizedContextMenu_unchecked { margin-left: 16px; margin-right: -5px; + } - &::before { - mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg'); - } + .mx_IconizedContextMenu_checked::before { + mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg'); + } + + .mx_IconizedContextMenu_unchecked::before { + content: unset; } } diff --git a/src/components/views/context_menus/IconizedContextMenu.tsx b/src/components/views/context_menus/IconizedContextMenu.tsx index 1d822fd246..7ad07f0466 100644 --- a/src/components/views/context_menus/IconizedContextMenu.tsx +++ b/src/components/views/context_menus/IconizedContextMenu.tsx @@ -86,7 +86,10 @@ export const IconizedContextMenuCheckbox: React.FC = ({ > { label } - { active && } + ; }; From b3a28bde8966e3b07106445de3d89312c5186f6d Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 28 Jul 2021 17:39:56 +0100 Subject: [PATCH 080/131] Factor out useEventEmitterState hook --- src/components/views/spaces/SpacePanel.tsx | 15 +++++++++------ src/hooks/useEventEmitter.ts | 13 ++++++++++++- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index 8223d84dbb..3bb8d8e3d2 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -117,12 +117,15 @@ const SpaceButton: React.FC = ({ }; const useSpaces = (): [Room[], Room[], Room | null] => { - const [invites, setInvites] = useState(SpaceStore.instance.invitedSpaces); - useEventEmitter(SpaceStore.instance, UPDATE_INVITED_SPACES, setInvites); - const [spaces, setSpaces] = useState(SpaceStore.instance.spacePanelSpaces); - useEventEmitter(SpaceStore.instance, UPDATE_TOP_LEVEL_SPACES, setSpaces); - const [activeSpace, setActiveSpace] = useState(SpaceStore.instance.activeSpace); - useEventEmitter(SpaceStore.instance, UPDATE_SELECTED_SPACE, setActiveSpace); + const invites = useEventEmitterState(SpaceStore.instance, UPDATE_INVITED_SPACES, () => { + return SpaceStore.instance.invitedSpaces; + }); + const spaces = useEventEmitterState(SpaceStore.instance, UPDATE_TOP_LEVEL_SPACES, () => { + return SpaceStore.instance.spacePanelSpaces; + }); + const activeSpace = useEventEmitterState(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => { + return SpaceStore.instance.activeSpace; + }); return [invites, spaces, activeSpace]; }; diff --git a/src/hooks/useEventEmitter.ts b/src/hooks/useEventEmitter.ts index a81bba5699..74b23f0198 100644 --- a/src/hooks/useEventEmitter.ts +++ b/src/hooks/useEventEmitter.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { useRef, useEffect } from "react"; +import { useRef, useEffect, useState, useCallback } from "react"; import type { EventEmitter } from "events"; type Handler = (...args: any[]) => void; @@ -48,3 +48,14 @@ export const useEventEmitter = (emitter: EventEmitter, eventName: string | symbo [eventName, emitter], // Re-run if eventName or emitter changes ); }; + +type Mapper = (...args: any[]) => T; + +export const useEventEmitterState = (emitter: EventEmitter, eventName: string | symbol, fn: Mapper): T => { + const [value, setValue] = useState(fn()); + const handler = useCallback((...args: any[]) => { + setValue(fn(...args)); + }, [fn]); + useEventEmitter(emitter, eventName, handler); + return value; +}; From 67ef263940b990959d435deec37b60415d9596e4 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 28 Jul 2021 17:40:33 +0100 Subject: [PATCH 081/131] Refactor SpaceButton to be more reusable and add context menu to Home button --- res/css/structures/_SpacePanel.scss | 2 +- src/components/structures/ContextMenu.tsx | 10 +- .../views/context_menus/SpaceContextMenu.tsx | 201 ++++++++++ src/components/views/spaces/SpacePanel.tsx | 242 ++++++------ .../views/spaces/SpaceTreeLevel.tsx | 366 ++++++------------ src/i18n/strings/en_EN.json | 21 +- 6 files changed, 444 insertions(+), 398 deletions(-) create mode 100644 src/components/views/context_menus/SpaceContextMenu.tsx diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index e64057d16c..9d9c3ff8ab 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -297,7 +297,7 @@ $activeBorderColor: $secondary-fg-color; .mx_SpaceButton:hover, .mx_SpaceButton:focus-within, .mx_SpaceButton_hasMenuOpen { - &:not(.mx_SpaceButton_home):not(.mx_SpaceButton_invite) { + &:not(.mx_SpaceButton_invite) { // Hide the badge container on hover because it'll be a menu button .mx_SpacePanel_badgeContainer { width: 0; diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 407dc6f04c..0822d3768b 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -16,7 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { CSSProperties, RefObject, useRef, useState } from "react"; +import React, { CSSProperties, RefObject, SyntheticEvent, useRef, useState } from "react"; import ReactDOM from "react-dom"; import classNames from "classnames"; @@ -461,10 +461,14 @@ type ContextMenuTuple = [boolean, RefObject, () => void, () => void, (val: export const useContextMenu = (): ContextMenuTuple => { const button = useRef(null); const [isOpen, setIsOpen] = useState(false); - const open = () => { + const open = (ev?: SyntheticEvent) => { + ev?.preventDefault(); + ev?.stopPropagation(); setIsOpen(true); }; - const close = () => { + const close = (ev?: SyntheticEvent) => { + ev?.preventDefault(); + ev?.stopPropagation(); setIsOpen(false); }; diff --git a/src/components/views/context_menus/SpaceContextMenu.tsx b/src/components/views/context_menus/SpaceContextMenu.tsx new file mode 100644 index 0000000000..1555870f26 --- /dev/null +++ b/src/components/views/context_menus/SpaceContextMenu.tsx @@ -0,0 +1,201 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { useContext } from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { EventType } from "matrix-js-sdk/src/@types/event"; + +import { + IProps as IContextMenuProps, +} from "../../structures/ContextMenu"; +import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu"; +import { _t } from "../../../languageHandler"; +import { + shouldShowSpaceSettings, + showAddExistingRooms, + showCreateNewRoom, + showSpaceInvite, + showSpaceSettings, +} from "../../../utils/space"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { ButtonEvent } from "../elements/AccessibleButton"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import RoomViewStore from "../../../stores/RoomViewStore"; +import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload"; +import { Action } from "../../../dispatcher/actions"; +import { RightPanelPhases } from "../../../stores/RightPanelStorePhases"; + +interface IProps extends IContextMenuProps { + space: Room; +} + +const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => { + const cli = useContext(MatrixClientContext); + const userId = cli.getUserId(); + + let inviteOption; + if (space.getJoinRule() === "public" || space.canInvite(userId)) { + const onInviteClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showSpaceInvite(space); + onFinished(); + }; + + inviteOption = ( + + ); + } + + let settingsOption; + let leaveSection; + if (shouldShowSpaceSettings(space)) { + const onSettingsClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showSpaceSettings(space); + onFinished(); + }; + + settingsOption = ( + + ); + } else { + const onLeaveClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + defaultDispatcher.dispatch({ + action: "leave_room", + room_id: space.roomId, + }); + onFinished(); + }; + + leaveSection = + + ; + } + + const canAddRooms = space.currentState.maySendStateEvent(EventType.SpaceChild, userId); + + let newRoomSection; + if (space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) { + const onNewRoomClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showCreateNewRoom(space); + onFinished(); + }; + + const onAddExistingRoomClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showAddExistingRooms(space); + onFinished(); + }; + + newRoomSection = + + + ; + } + + const onMembersClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + if (!RoomViewStore.getRoomId()) { + defaultDispatcher.dispatch({ + action: "view_room", + room_id: space.roomId, + }, true); + } + + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.SpaceMemberList, + refireParams: { space: space }, + }); + onFinished(); + }; + + const onExploreRoomsClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + defaultDispatcher.dispatch({ + action: "view_room", + room_id: space.roomId, + }); + onFinished(); + }; + + return +
+ { space.name } +
+ + { inviteOption } + + { settingsOption } + + + { newRoomSection } + { leaveSection } +
; +}; + +export default SpaceContextMenu; + diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index 3bb8d8e3d2..a339cb8132 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -14,107 +14,35 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { Dispatch, ReactNode, SetStateAction, useEffect, useState } from "react"; -import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd"; +import React, { ComponentProps, Dispatch, ReactNode, SetStateAction, useEffect, useState } from "react"; +import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd"; import classNames from "classnames"; import { Room } from "matrix-js-sdk/src/models/room"; import { _t } from "../../../languageHandler"; -import RoomAvatar from "../avatars/RoomAvatar"; import { useContextMenu } from "../../structures/ContextMenu"; import SpaceCreateMenu from "./SpaceCreateMenu"; -import { SpaceItem } from "./SpaceTreeLevel"; +import { SpaceButton, SpaceItem } from "./SpaceTreeLevel"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; -import { useEventEmitter } from "../../../hooks/useEventEmitter"; +import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import SpaceStore, { HOME_SPACE, + UPDATE_HOME_BEHAVIOUR, UPDATE_INVITED_SPACES, UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES, } from "../../../stores/SpaceStore"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; -import NotificationBadge from "../rooms/NotificationBadge"; -import { - RovingAccessibleButton, - RovingAccessibleTooltipButton, - RovingTabIndexProvider, -} from "../../../accessibility/RovingTabIndex"; +import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex"; import { Key } from "../../../Keyboard"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; -import { NotificationState } from "../../../stores/notifications/NotificationState"; - -interface IButtonProps { - space?: Room; - className?: string; - selected?: boolean; - tooltip?: string; - notificationState?: NotificationState; - isNarrow?: boolean; - onClick(): void; -} - -const SpaceButton: React.FC = ({ - space, - className, - selected, - onClick, - tooltip, - notificationState, - isNarrow, - children, -}) => { - const classes = classNames("mx_SpaceButton", className, { - mx_SpaceButton_active: selected, - mx_SpaceButton_narrow: isNarrow, - }); - - let avatar =
; - if (space) { - avatar = ; - } - - let notifBadge; - if (notificationState) { - notifBadge =
- SpaceStore.instance.setActiveRoomInSpace(space)} - forceCount={false} - notification={notificationState} - /> -
; - } - - let button; - if (isNarrow) { - button = ( - -
- { avatar } - { notifBadge } - { children } -
-
- ); - } else { - button = ( - -
- { avatar } - { tooltip } - { notifBadge } - { children } -
-
- ); - } - - return
  • - { button } -
  • ; -}; +import SpaceContextMenu from "../context_menus/SpaceContextMenu"; +import IconizedContextMenu, { + IconizedContextMenuCheckbox, + IconizedContextMenuOptionList, +} from "../context_menus/IconizedContextMenu"; +import SettingsStore from "../../../settings/SettingsStore"; +import { SettingLevel } from "../../../settings/SettingLevel"; const useSpaces = (): [Room[], Room[], Room | null] => { const invites = useEventEmitterState(SpaceStore.instance, UPDATE_INVITED_SPACES, () => { @@ -135,30 +63,108 @@ interface IInnerSpacePanelProps { setPanelCollapsed: Dispatch>; } +const HomeButtonContextMenu = ({ onFinished, ...props }: ComponentProps) => { + const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => { + return SpaceStore.instance.allRoomsInHome; + }); + + return +
    + { _t("Home") } +
    + + { + SettingsStore.setValue("Spaces.all_rooms_in_home", null, SettingLevel.ACCOUNT, !allRoomsInHome); + }} + /> + +
    ; +}; + +interface IHomeButtonProps { + selected: boolean; + isPanelCollapsed: boolean; +} + +const HomeButton = ({ selected, isPanelCollapsed }: IHomeButtonProps) => { + const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => { + return SpaceStore.instance.allRoomsInHome; + }); + + return
  • + SpaceStore.instance.setActiveSpace(null)} + selected={selected} + label={allRoomsInHome ? _t("All rooms") : _t("Home")} + notificationState={allRoomsInHome + ? RoomNotificationStateStore.instance.globalState + : SpaceStore.instance.getNotificationState(HOME_SPACE)} + isNarrow={isPanelCollapsed} + ContextMenuComponent={HomeButtonContextMenu} + contextMenuTooltip={_t("Options")} + /> +
  • ; +}; + +const CreateSpaceButton = ({ + isPanelCollapsed, + setPanelCollapsed, +}: Pick) => { + // We don't need the handle as we position the menu in a constant location + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); + + useEffect(() => { + if (!isPanelCollapsed && menuDisplayed) { + closeMenu(); + } + }, [isPanelCollapsed]); // eslint-disable-line react-hooks/exhaustive-deps + + let contextMenu = null; + if (menuDisplayed) { + contextMenu = ; + } + + const onNewClick = menuDisplayed ? closeMenu : () => { + if (!isPanelCollapsed) setPanelCollapsed(true); + openMenu(); + }; + + return
  • + + + { contextMenu } +
  • ; +}; + // Optimisation based on https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/api/droppable.md#recommended-droppable--performance-optimisation const InnerSpacePanel = React.memo(({ children, isPanelCollapsed, setPanelCollapsed }) => { const [invites, spaces, activeSpace] = useSpaces(); const activeSpaces = activeSpace ? [activeSpace] : []; - let homeTooltip: string; - let homeNotificationState: NotificationState; - if (SpaceStore.instance.allRoomsInHome) { - homeTooltip = _t("All rooms"); - homeNotificationState = RoomNotificationStateStore.instance.globalState; - } else { - homeTooltip = _t("Home"); - homeNotificationState = SpaceStore.instance.getNotificationState(HOME_SPACE); - } - return
    - SpaceStore.instance.setActiveSpace(null)} - selected={!activeSpace} - tooltip={homeTooltip} - notificationState={homeNotificationState} - isNarrow={isPanelCollapsed} - /> + { invites.map(s => ( (({ children, isPanelCo )) } { children } +
    ; }); const SpacePanel = () => { - // We don't need the handle as we position the menu in a constant location - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); const [isPanelCollapsed, setPanelCollapsed] = useState(true); - useEffect(() => { - if (!isPanelCollapsed && menuDisplayed) { - closeMenu(); - } - }, [isPanelCollapsed]); // eslint-disable-line react-hooks/exhaustive-deps - - let contextMenu = null; - if (menuDisplayed) { - contextMenu = ; - } - const onKeyDown = (ev: React.KeyboardEvent) => { let handled = true; @@ -269,11 +262,6 @@ const SpacePanel = () => { } }; - const onNewClick = menuDisplayed ? closeMenu : () => { - if (!isPanelCollapsed) setPanelCollapsed(true); - openMenu(); - }; - return ( { if (!result.destination) return; // dropped outside the list @@ -301,15 +289,6 @@ const SpacePanel = () => { > { provided.placeholder } - - ) } @@ -318,7 +297,6 @@ const SpacePanel = () => { onClick={() => setPanelCollapsed(!isPanelCollapsed)} title={isPanelCollapsed ? _t("Expand space panel") : _t("Collapse space panel")} /> - { contextMenu } ) } diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index 90584a5361..bb2184853e 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -14,7 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef, InputHTMLAttributes, LegacyRef } from "react"; +import React, { + createRef, + MouseEvent, + InputHTMLAttributes, + LegacyRef, + ComponentProps, + ComponentType, +} from "react"; import classNames from "classnames"; import { Room } from "matrix-js-sdk/src/models/room"; @@ -23,31 +30,104 @@ import SpaceStore from "../../../stores/SpaceStore"; import SpaceTreeLevelLayoutStore from "../../../stores/SpaceTreeLevelLayoutStore"; import NotificationBadge from "../rooms/NotificationBadge"; import { RovingAccessibleTooltipButton } from "../../../accessibility/roving/RovingAccessibleTooltipButton"; -import IconizedContextMenu, { - IconizedContextMenuOption, - IconizedContextMenuOptionList, -} from "../context_menus/IconizedContextMenu"; import { _t } from "../../../languageHandler"; import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton"; -import { toRightOf } from "../../structures/ContextMenu"; -import { - shouldShowSpaceSettings, - showAddExistingRooms, - showCreateNewRoom, - showSpaceInvite, - showSpaceSettings, -} from "../../../utils/space"; +import { toRightOf, useContextMenu } from "../../structures/ContextMenu"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; -import defaultDispatcher from "../../../dispatcher/dispatcher"; -import { Action } from "../../../dispatcher/actions"; -import RoomViewStore from "../../../stores/RoomViewStore"; -import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload"; -import { RightPanelPhases } from "../../../stores/RightPanelStorePhases"; -import { EventType } from "matrix-js-sdk/src/@types/event"; +import AccessibleButton from "../elements/AccessibleButton"; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; import { NotificationColor } from "../../../stores/notifications/NotificationColor"; import { getKeyBindingsManager, RoomListAction } from "../../../KeyBindingsManager"; +import { NotificationState } from "../../../stores/notifications/NotificationState"; +import SpaceContextMenu from "../context_menus/SpaceContextMenu"; + +interface IButtonProps extends Omit, "title"> { + space?: Room; + className?: string; + selected?: boolean; + label: string; + contextMenuTooltip?: string; + notificationState?: NotificationState; + isNarrow?: boolean; + avatarSize?: number; + ContextMenuComponent?: ComponentType>; + onClick(ev: MouseEvent): void; +} + +export const SpaceButton: React.FC = ({ + space, + className, + selected, + onClick, + label, + contextMenuTooltip, + notificationState, + avatarSize, + isNarrow, + children, + ContextMenuComponent, + ...props +}) => { + const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); + + let avatar =
    ; + if (space) { + avatar = ; + } + + let notifBadge; + if (notificationState) { + notifBadge =
    + SpaceStore.instance.setActiveRoomInSpace(space || null)} + forceCount={false} + notification={notificationState} + /> +
    ; + } + + let contextMenu: JSX.Element; + if (menuDisplayed && ContextMenuComponent) { + contextMenu = ; + } + + return ( + + { children } +
    + { avatar } + { !isNarrow && { label } } + { notifBadge } + + { ContextMenuComponent && } + + { contextMenu } +
    +
    + ); +}; interface IItemProps extends InputHTMLAttributes { space?: Room; @@ -61,7 +141,6 @@ interface IItemProps extends InputHTMLAttributes { interface IItemState { collapsed: boolean; - contextMenuPosition: Pick; childSpaces: Room[]; } @@ -81,7 +160,6 @@ export class SpaceItem extends React.PureComponent { this.state = { collapsed: collapsed, - contextMenuPosition: null, childSpaces: this.childSpaces, }; @@ -124,19 +202,6 @@ export class SpaceItem extends React.PureComponent { evt.stopPropagation(); }; - private onContextMenu = (ev: React.MouseEvent) => { - if (this.props.space.getMyMembership() !== "join") return; - ev.preventDefault(); - ev.stopPropagation(); - this.setState({ - contextMenuPosition: { - right: ev.clientX, - top: ev.clientY, - height: 0, - }, - }); - }; - private onKeyDown = (ev: React.KeyboardEvent) => { let handled = true; const action = getKeyBindingsManager().getRoomListAction(ev); @@ -180,188 +245,6 @@ export class SpaceItem extends React.PureComponent { SpaceStore.instance.setActiveSpace(this.props.space); }; - private onMenuOpenClick = (ev: React.MouseEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - const target = ev.target as HTMLButtonElement; - this.setState({ contextMenuPosition: target.getBoundingClientRect() }); - }; - - private onMenuClose = () => { - this.setState({ contextMenuPosition: null }); - }; - - private onInviteClick = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - showSpaceInvite(this.props.space); - this.setState({ contextMenuPosition: null }); // also close the menu - }; - - private onSettingsClick = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - showSpaceSettings(this.props.space); - this.setState({ contextMenuPosition: null }); // also close the menu - }; - - private onLeaveClick = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - defaultDispatcher.dispatch({ - action: "leave_room", - room_id: this.props.space.roomId, - }); - this.setState({ contextMenuPosition: null }); // also close the menu - }; - - private onNewRoomClick = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - showCreateNewRoom(this.props.space); - this.setState({ contextMenuPosition: null }); // also close the menu - }; - - private onAddExistingRoomClick = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - showAddExistingRooms(this.props.space); - this.setState({ contextMenuPosition: null }); // also close the menu - }; - - private onMembersClick = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - if (!RoomViewStore.getRoomId()) { - defaultDispatcher.dispatch({ - action: "view_room", - room_id: this.props.space.roomId, - }, true); - } - - defaultDispatcher.dispatch({ - action: Action.SetRightPanelPhase, - phase: RightPanelPhases.SpaceMemberList, - refireParams: { space: this.props.space }, - }); - this.setState({ contextMenuPosition: null }); // also close the menu - }; - - private onExploreRoomsClick = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - defaultDispatcher.dispatch({ - action: "view_room", - room_id: this.props.space.roomId, - }); - this.setState({ contextMenuPosition: null }); // also close the menu - }; - - private renderContextMenu(): React.ReactElement { - if (this.props.space.getMyMembership() !== "join") return null; - - let contextMenu = null; - if (this.state.contextMenuPosition) { - const userId = this.context.getUserId(); - - let inviteOption; - if (this.props.space.getJoinRule() === "public" || this.props.space.canInvite(userId)) { - inviteOption = ( - - ); - } - - let settingsOption; - let leaveSection; - if (shouldShowSpaceSettings(this.props.space)) { - settingsOption = ( - - ); - } else { - leaveSection = - - ; - } - - const canAddRooms = this.props.space.currentState.maySendStateEvent(EventType.SpaceChild, userId); - - let newRoomSection; - if (this.props.space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) { - newRoomSection = - - - ; - } - - contextMenu = -
    - { this.props.space.name } -
    - - { inviteOption } - - { settingsOption } - - - { newRoomSection } - { leaveSection } -
    ; - } - - return ( - - - { contextMenu } - - ); - } - render() { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { space, activeSpaces, isNested, isPanelCollapsed, onExpand, parents, innerRef, @@ -369,7 +252,6 @@ export class SpaceItem extends React.PureComponent { const collapsed = this.isCollapsed; - const isActive = activeSpaces.includes(space); const itemClasses = classNames(this.props.className, { "mx_SpaceItem": true, "mx_SpaceItem_narrow": isPanelCollapsed, @@ -378,12 +260,7 @@ export class SpaceItem extends React.PureComponent { }); const isInvite = space.getMyMembership() === "invite"; - const classes = classNames("mx_SpaceButton", { - mx_SpaceButton_active: isActive, - mx_SpaceButton_hasMenuOpen: !!this.state.contextMenuPosition, - mx_SpaceButton_narrow: isPanelCollapsed, - mx_SpaceButton_invite: isInvite, - }); + const notificationState = isInvite ? StaticNotificationState.forSymbol("!", NotificationColor.Red) : SpaceStore.instance.getNotificationState(space.roomId); @@ -398,19 +275,6 @@ export class SpaceItem extends React.PureComponent { />; } - let notifBadge; - if (notificationState) { - notifBadge =
    - SpaceStore.instance.setActiveRoomInSpace(space)} - forceCount={false} - notification={notificationState} - /> -
    ; - } - - const avatarSize = isNested ? 24 : 32; - const toggleCollapseButton = this.state.childSpaces?.length ? { return (
  • - { toggleCollapseButton } -
    - - { !isPanelCollapsed && { space.name } } - { notifBadge } - { this.renderContextMenu() } -
    -
    + { childItems }
  • diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 4a728b72b6..fc6a58708d 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1019,8 +1019,10 @@ "Address": "Address", "Creating...": "Creating...", "Create": "Create", - "All rooms": "All rooms", "Home": "Home", + "Show all rooms in home": "Show all rooms in home", + "All rooms": "All rooms", + "Options": "Options", "Expand space panel": "Expand space panel", "Collapse space panel": "Collapse space panel", "Click to copy": "Click to copy", @@ -1050,16 +1052,9 @@ "Preview Space": "Preview Space", "Allow people to preview your space before they join.": "Allow people to preview your space before they join.", "Recommended for public spaces.": "Recommended for public spaces.", - "Settings": "Settings", - "Leave space": "Leave space", - "Create new room": "Create new room", - "Add existing room": "Add existing room", - "Members": "Members", - "Manage & explore rooms": "Manage & explore rooms", - "Explore rooms": "Explore rooms", - "Space options": "Space options", "Expand": "Expand", "Collapse": "Collapse", + "Space options": "Space options", "Remove": "Remove", "This bridge was provisioned by .": "This bridge was provisioned by .", "This bridge is managed by .": "This bridge is managed by .", @@ -1583,8 +1578,11 @@ "Start chat": "Start chat", "Rooms": "Rooms", "Add room": "Add room", + "Create new room": "Create new room", "You do not have permissions to create new rooms in this space": "You do not have permissions to create new rooms in this space", + "Add existing room": "Add existing room", "You do not have permissions to add rooms to this space": "You do not have permissions to add rooms to this space", + "Explore rooms": "Explore rooms", "Explore community rooms": "Explore community rooms", "Explore public rooms": "Explore public rooms", "Low priority": "Low priority", @@ -1662,6 +1660,7 @@ "Low Priority": "Low Priority", "Invite People": "Invite People", "Copy Room Link": "Copy Room Link", + "Settings": "Settings", "Leave Room": "Leave Room", "Room options": "Room options", "%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.", @@ -1755,13 +1754,13 @@ "The homeserver the user you’re verifying is connected to": "The homeserver the user you’re verifying is connected to", "Yours, or the other users’ internet connection": "Yours, or the other users’ internet connection", "Yours, or the other users’ session": "Yours, or the other users’ session", + "Members": "Members", "Nothing pinned, yet": "Nothing pinned, yet", "If you have permissions, open the menu on any message and select Pin to stick them here.": "If you have permissions, open the menu on any message and select Pin to stick them here.", "Pinned messages": "Pinned messages", "Room Info": "Room Info", "You can only pin up to %(count)s widgets|other": "You can only pin up to %(count)s widgets", "Unpin a widget to view it in this panel": "Unpin a widget to view it in this panel", - "Options": "Options", "Set my room layout for everyone": "Set my room layout for everyone", "Widgets": "Widgets", "Edit widgets, bridges & bots": "Edit widgets, bridges & bots", @@ -2563,6 +2562,8 @@ "Source URL": "Source URL", "Collapse reply thread": "Collapse reply thread", "Report": "Report", + "Leave space": "Leave space", + "Manage & explore rooms": "Manage & explore rooms", "Clear status": "Clear status", "Update status": "Update status", "Set status": "Set status", From 07eaee25d29542a4fce9d02a976751ffc3e9c9a3 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 17:54:35 +0100 Subject: [PATCH 082/131] Default properties to {} to avoid passing it superfluously for events with no properties --- src/PosthogAnalytics.ts | 4 ++-- src/components/views/auth/Welcome.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index d5bb12621d..c8e156f2fa 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -302,7 +302,7 @@ export class PosthogAnalytics { public async trackPseudonymousEvent( eventName: E["eventName"], - properties: E["properties"], + properties: E["properties"] = {}, ) { if (this.anonymity == Anonymity.Anonymous || this.anonymity == Anonymity.Disabled) return; await this.capture(eventName, properties); @@ -310,7 +310,7 @@ export class PosthogAnalytics { public async trackAnonymousEvent( eventName: E["eventName"], - properties: E["properties"], + properties: E["properties"] = {}, ): Promise { if (this.anonymity == Anonymity.Disabled) return; await this.capture(eventName, properties); diff --git a/src/components/views/auth/Welcome.tsx b/src/components/views/auth/Welcome.tsx index 75bbe15411..7c405b0835 100644 --- a/src/components/views/auth/Welcome.tsx +++ b/src/components/views/auth/Welcome.tsx @@ -76,6 +76,6 @@ export default class Welcome extends React.PureComponent { } componentDidMount() { - getAnalytics().trackAnonymousEvent("welcome_screen_load", {}); + getAnalytics().trackAnonymousEvent("welcome_screen_load"); } } From 9fb1c8e4cd74c618594bf6e84906961cd4e27b74 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 28 Jul 2021 19:33:07 +0100 Subject: [PATCH 083/131] Iterate PR --- res/css/structures/_SpacePanel.scss | 8 ++++++++ src/components/views/spaces/SpacePanel.tsx | 4 ++-- src/i18n/strings/en_EN.json | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index 9d9c3ff8ab..1dea6332f5 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -368,6 +368,14 @@ $activeBorderColor: $secondary-fg-color; .mx_SpacePanel_iconExplore::before { mask-image: url('$(res)/img/element-icons/roomlist/browse.svg'); } + + .mx_SpacePanel_noIcon { + display: none; + + & + .mx_IconizedContextMenu_label { + padding-left: 5px !important; // override default iconized label style to align with header + } + } } diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index a339cb8132..bbe27ced75 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -79,8 +79,8 @@ const HomeButtonContextMenu = ({ onFinished, ...props }: ComponentProps { SettingsStore.setValue("Spaces.all_rooms_in_home", null, SettingLevel.ACCOUNT, !allRoomsInHome); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index ae14c6ed9d..b0752ab0bd 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1023,7 +1023,7 @@ "Creating...": "Creating...", "Create": "Create", "Home": "Home", - "Show all rooms in home": "Show all rooms in home", + "Show all rooms": "Show all rooms", "All rooms": "All rooms", "Options": "Options", "Expand space panel": "Expand space panel", From ae647658706a87c352b7e120423ebc87f33d8dba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Thu, 29 Jul 2021 08:45:32 +0200 Subject: [PATCH 084/131] playMedia only if necessary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/components/views/voip/VideoFeed.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/views/voip/VideoFeed.tsx b/src/components/views/voip/VideoFeed.tsx index 9975f70d62..af2fd92016 100644 --- a/src/components/views/voip/VideoFeed.tsx +++ b/src/components/views/voip/VideoFeed.tsx @@ -22,6 +22,7 @@ import { CallFeed, CallFeedEvent } from 'matrix-js-sdk/src/webrtc/callFeed'; import { logger } from 'matrix-js-sdk/src/logger'; import MemberAvatar from "../avatars/MemberAvatar"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { objectHasDiff } from '../../../utils/objects'; interface IProps { call: MatrixCall; @@ -46,7 +47,6 @@ interface IState { videoMuted: boolean; } -// TODO: We shouldn't be calling playMedia() all the time @replaceableComponent("views.voip.VideoFeed") export default class VideoFeed extends React.PureComponent { private element: HTMLVideoElement; @@ -69,8 +69,10 @@ export default class VideoFeed extends React.PureComponent { this.updateFeed(this.props.feed, null); } - componentDidUpdate(prevProps: IProps) { + componentDidUpdate(prevProps: IProps, prevState: IState) { this.updateFeed(prevProps.feed, this.props.feed); + // If the mutes state has changed, we try to playMedia() + if (prevState.videoMuted !== this.state.videoMuted) this.playMedia(); } static getDerivedStateFromProps(props: IProps) { @@ -142,7 +144,7 @@ export default class VideoFeed extends React.PureComponent { } private onNewStream = async () => { - await this.setState({ + this.setState({ audioMuted: this.props.feed.isAudioMuted(), videoMuted: this.props.feed.isVideoMuted(), }); @@ -150,11 +152,10 @@ export default class VideoFeed extends React.PureComponent { }; private onMuteStateChanged = async () => { - await this.setState({ + this.setState({ audioMuted: this.props.feed.isAudioMuted(), videoMuted: this.props.feed.isVideoMuted(), }); - this.playMedia(); }; private onResize = (e) => { From 152168ef2ddc99a598fcebbbd517560e053fa850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Thu, 29 Jul 2021 10:20:59 +0200 Subject: [PATCH 085/131] Add mic mute icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- res/img/voip/mic-muted.svg | 5 +++++ res/img/voip/mic-unmuted.svg | 4 ++++ 2 files changed, 9 insertions(+) create mode 100644 res/img/voip/mic-muted.svg create mode 100644 res/img/voip/mic-unmuted.svg diff --git a/res/img/voip/mic-muted.svg b/res/img/voip/mic-muted.svg new file mode 100644 index 0000000000..0cb7ad1c9e --- /dev/null +++ b/res/img/voip/mic-muted.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/res/img/voip/mic-unmuted.svg b/res/img/voip/mic-unmuted.svg new file mode 100644 index 0000000000..8334cafa0a --- /dev/null +++ b/res/img/voip/mic-unmuted.svg @@ -0,0 +1,4 @@ + + + + From 7f6cf29766bc81d0dda92e65de9ecdc7a16c3c4e Mon Sep 17 00:00:00 2001 From: Dariusz Niemczyk Date: Thu, 29 Jul 2021 12:39:32 +0200 Subject: [PATCH 086/131] Fix grecaptcha regression --- src/components/views/auth/CaptchaForm.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/views/auth/CaptchaForm.tsx b/src/components/views/auth/CaptchaForm.tsx index b1c09f2b22..97f45167a8 100644 --- a/src/components/views/auth/CaptchaForm.tsx +++ b/src/components/views/auth/CaptchaForm.tsx @@ -103,8 +103,8 @@ export default class CaptchaForm extends React.Component Date: Thu, 29 Jul 2021 15:05:26 +0200 Subject: [PATCH 087/131] Use mic mute icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- res/css/views/voip/_CallView.scss | 10 +++- res/css/views/voip/_CallViewSidebar.scss | 11 +++++ res/css/views/voip/_VideoFeed.scss | 48 +++++++++++++++--- src/components/views/voip/VideoFeed.tsx | 63 ++++++++++++++++-------- 4 files changed, 103 insertions(+), 29 deletions(-) diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index 104e2993d8..eff865f20c 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -76,16 +76,22 @@ limitations under the License. &.mx_VideoFeed_voice { // We don't want to collide with the call controls that have 52px of height - padding-bottom: 52px; + margin-bottom: 52px; background-color: $inverted-bg-color; display: flex; justify-content: center; align-items: center; } - &.mx_VideoFeed_video { + .mx_VideoFeed_video { + height: 100%; background-color: #000; } + + .mx_VideoFeed_mic { + left: 10px; + bottom: 10px; + } } } diff --git a/res/css/views/voip/_CallViewSidebar.scss b/res/css/views/voip/_CallViewSidebar.scss index 79bf3cbf09..892a137a32 100644 --- a/res/css/views/voip/_CallViewSidebar.scss +++ b/res/css/views/voip/_CallViewSidebar.scss @@ -35,12 +35,23 @@ limitations under the License. width: 100%; &.mx_VideoFeed_voice { + border-radius: 4px; + display: flex; align-items: center; justify-content: center; aspect-ratio: 16 / 9; } + + .mx_VideoFeed_video { + border-radius: 4px; + } + + .mx_VideoFeed_mic { + left: 6px; + bottom: 6px; + } } &.mx_CallViewSidebar_pipMode { diff --git a/res/css/views/voip/_VideoFeed.scss b/res/css/views/voip/_VideoFeed.scss index 07a4a0e530..3a0f62636e 100644 --- a/res/css/views/voip/_VideoFeed.scss +++ b/res/css/views/voip/_VideoFeed.scss @@ -15,18 +15,52 @@ limitations under the License. */ .mx_VideoFeed { - border-radius: 4px; - + overflow: hidden; + position: relative; &.mx_VideoFeed_voice { background-color: $inverted-bg-color; } - &.mx_VideoFeed_video { + .mx_VideoFeed_video { + width: 100%; background-color: transparent; + + &.mx_VideoFeed_video_mirror { + transform: scale(-1, 1); + } + } + + .mx_VideoFeed_mic { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + + width: 24px; + height: 24px; + + background-color: rgba(0, 0, 0, 0.5); // Same on both themes + border-radius: 100%; + + &::before { + position: absolute; + content: ""; + width: 16px; + height: 16px; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + background-color: white; // Same on both themes + border-radius: 7px; + } + + &.mx_VideoFeed_mic_muted::before { + mask-image: url('$(res)/img/voip/mic-muted.svg'); + } + + &.mx_VideoFeed_mic_unmuted::before { + mask-image: url('$(res)/img/voip/mic-unmuted.svg'); + } } } - -.mx_VideoFeed_mirror { - transform: scale(-1, 1); -} diff --git a/src/components/views/voip/VideoFeed.tsx b/src/components/views/voip/VideoFeed.tsx index af2fd92016..09d0c97a0d 100644 --- a/src/components/views/voip/VideoFeed.tsx +++ b/src/components/views/voip/VideoFeed.tsx @@ -22,7 +22,7 @@ import { CallFeed, CallFeedEvent } from 'matrix-js-sdk/src/webrtc/callFeed'; import { logger } from 'matrix-js-sdk/src/logger'; import MemberAvatar from "../avatars/MemberAvatar"; import { replaceableComponent } from "../../../utils/replaceableComponent"; -import { objectHasDiff } from '../../../utils/objects'; +import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes'; interface IProps { call: MatrixCall; @@ -165,39 +165,62 @@ export default class VideoFeed extends React.PureComponent { }; render() { - const videoClasses = { - mx_VideoFeed: true, + const { pipMode, primary, feed } = this.props; + + const wrapperClasses = classnames("mx_VideoFeed", { mx_VideoFeed_voice: this.state.videoMuted, - mx_VideoFeed_video: !this.state.videoMuted, - mx_VideoFeed_mirror: ( - this.props.feed.isLocal() && - SettingsStore.getValue('VideoView.flipVideoHorizontally') - ), - }; + }); + const micIconClasses = classnames("mx_VideoFeed_mic", { + mx_VideoFeed_mic_muted: this.state.audioMuted, + mx_VideoFeed_mic_unmuted: !this.state.audioMuted, + }); - const { pipMode, primary } = this.props; + let micIcon; + if ( + feed.purpose !== SDPStreamMetadataPurpose.Screenshare && + !pipMode && + !feed.isLocal() + ) { + micIcon = ( +
    + ); + } + let content; if (this.state.videoMuted) { const member = this.props.feed.getMember(); + let avatarSize; if (pipMode && primary) avatarSize = 76; else if (pipMode && !primary) avatarSize = 16; else if (!pipMode && primary) avatarSize = 160; else; // TBD - return ( -
    - -
    + content =( + ); } else { - return ( -