Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"@fontsource/fira-code": "^5",
"@fontsource/inter": "catalog:",
"@formatjs/intl-segmenter": "^12.0.0",
"@matrix-org/analytics-events": "^0.32.0",
"@matrix-org/analytics-events": "^0.33.2",
"@matrix-org/emojibase-bindings": "^1.5.0",
"@matrix-org/react-sdk-module-api": "^2.4.0",
"@sentry/browser": "^10.0.0",
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/PosthogAnalytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,9 @@
dis.register(this.onAction);
SettingsStore.monitorSetting("layout", null);
SettingsStore.monitorSetting("useCompactLayout", null);
SettingsStore.monitorSetting("urlPreviewsEnabled", null);
this.onLayoutUpdated();
this.onUrlPreviewSettingUpdated(SettingsStore.getValue("urlPreviewsEnabled"));
this.updateCryptoSuperProperty();
}

Expand All @@ -188,11 +190,17 @@
this.setProperty("WebLayout", layout);
};

private onUrlPreviewSettingUpdated = (value: boolean): void => {

Check warning on line 193 in apps/web/src/PosthogAnalytics.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'onUrlPreviewSettingUpdated' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZz8snTpzr0W7c9DTk1u&open=AZz8snTpzr0W7c9DTk1u&pullRequest=32659
this.setProperty("URLPreviewsEnabled", value);
};

private onAction = (payload: ActionPayload): void => {
if (payload.action !== Action.SettingUpdated) return;
const settingsPayload = payload as SettingUpdatedPayload;
if (["layout", "useCompactLayout"].includes(settingsPayload.settingName)) {
this.onLayoutUpdated();
} else if (settingsPayload.settingName === "urlPreviewsEnabled" && !settingsPayload.roomId) {
this.onUrlPreviewSettingUpdated(settingsPayload.newValue as boolean);
}
};

Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/PosthogTrackers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.

Expand All @@ -11,11 +12,14 @@ import { type WebScreen as ScreenEvent } from "@matrix-org/analytics-events/type
import { type Interaction as InteractionEvent } from "@matrix-org/analytics-events/types/typescript/Interaction";
import { type PinUnpinAction } from "@matrix-org/analytics-events/types/typescript/PinUnpinAction";
import { type RoomListSortingAlgorithmChanged } from "@matrix-org/analytics-events/types/typescript/RoomListSortingAlgorithmChanged";
import { type UrlPreviewRendered } from "@matrix-org/analytics-events/types/typescript/UrlPreviewRendered";
import { type UrlPreview } from "@element-hq/web-shared-components";

import PageType from "./PageTypes";
import Views from "./Views";
import { PosthogAnalytics } from "./PosthogAnalytics";
import { SortingAlgorithm } from "./stores/room-list-v3/skip-list/sorters";
import { LruCache } from "./utils/LruCache";

export type ScreenName = ScreenEvent["$current_url"];
export type InteractionName = InteractionEvent["name"];
Expand Down Expand Up @@ -49,6 +53,8 @@ const SortingAlgorithmMap: Record<SortingAlgorithm, RoomListSortingAlgorithmChan
export default class PosthogTrackers {
private static internalInstance: PosthogTrackers;

private readonly previewedEventIds = new LruCache<string, true>(1000);

public static get instance(): PosthogTrackers {
if (!PosthogTrackers.internalInstance) {
PosthogTrackers.internalInstance = new PosthogTrackers();
Expand Down Expand Up @@ -136,6 +142,29 @@ export default class PosthogTrackers {
newAlgorithm: SortingAlgorithmMap[newAlgorithm],
});
}

/**
* Track if an event has had a previewed rendered in the client.
* This function makes a best-effort attempt to prevent double counting.
*
* @param eventId EventID for deduplication.
* @param isEncrypted Whether the event (and effectively the room) was encrypted.
* @param previews The previews generated from the event.
*/
public trackUrlPreview(eventId: string, isEncrypted: boolean, previews: UrlPreview[]): void {
// Discount any previews that we have already tracked.
if (this.previewedEventIds.get(eventId)) {
return;
}
PosthogAnalytics.instance.trackEvent<UrlPreviewRendered>({
eventName: "UrlPreviewRendered",
previewKind: "LegacyCard",
hasThumbnail: previews.some((p) => !!p.image),
previewCount: previews.length,
encryptedRoom: isEncrypted,
});
this.previewedEventIds.set(eventId, true);
}
}

export class PosthogScreenTracker extends PureComponent<{ screenName: ScreenName }> {
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/components/views/messages/TextualBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
useCreateAutoDisposedViewModel,
EventContentBodyView,
LINKIFIED_DATA_ATTRIBUTE,
useViewModel,
} from "@element-hq/web-shared-components";
import { logger as rootLogger } from "matrix-js-sdk/src/logger";

Expand All @@ -39,6 +40,7 @@
import { useMediaVisible } from "../../../hooks/useMediaVisible.ts";
import ImageView from "../elements/ImageView.tsx";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx";
import PosthogTrackers from "../../../PosthogTrackers.ts";

const logger = rootLogger.getChild("TextualBody");

Expand Down Expand Up @@ -427,5 +429,14 @@
})();
}, [vm, props.showUrlPreview, mediaVisible]);

const { previews } = useViewModel(vm);

useEffect(() => {
if (previews.length === 0) {
return;
}
PosthogTrackers.instance.trackUrlPreview(props.mxEvent.getId()!, props.mxEvent.isEncrypted(), previews);

Check warning on line 438 in apps/web/src/components/views/messages/TextualBody.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is unnecessary since the receiver accepts the original type of the expression.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZz8snI2zr0W7c9DTk1s&open=AZz8snI2zr0W7c9DTk1s&pullRequest=32659
}, [props.mxEvent, previews]);

return <InnerTextualBody urlPreviewViewModel={vm} {...props} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ import {
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
import { type IPreviewUrlResponse, type MatrixClient, MatrixError, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { decode } from "html-entities";
import { type UrlPreviewVisibilityChanged } from "@matrix-org/analytics-events/types/typescript/UrlPreviewVisibilityChanged";

import { isPermalinkHost } from "../../utils/permalinks/Permalinks";
import { mediaFromMxc } from "../../customisations/Media";
import PlatformPeg from "../../PlatformPeg";
import { thumbHeight } from "../../ImageUtils";
import SettingsStore from "../../settings/SettingsStore";
import { PosthogAnalytics } from "../../PosthogAnalytics";

const logger = rootLogger.getChild("UrlPreviewGroupViewModel");

Expand Down Expand Up @@ -404,6 +406,13 @@ export class UrlPreviewGroupViewModel
// FIXME: persist this somewhere smarter than local storage
globalThis.localStorage?.setItem(this.storageKey, "1");
this.urlPreviewEnabledByUser = false;
PosthogAnalytics.instance.trackEvent<UrlPreviewVisibilityChanged>({
eventName: "UrlPreviewVisibilityChanged",
previewKind: "LegacyCard",
hasThumbnail: this.snapshot.current.previews.some((p) => !!p.image),
previewCount: this.snapshot.current.previews.length,
visible: this.urlPreviewEnabledByUser,
});
return this.computeSnapshot();
};

Expand Down
52 changes: 48 additions & 4 deletions apps/web/test/unit-tests/PosthogAnalytics-test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.

Expand Down Expand Up @@ -205,6 +206,10 @@ describe("PosthogAnalytics", () => {
analytics.setAnonymity(Anonymity.Pseudonymous);
});

afterEach(() => {
SettingsStore.reset();
});

it("should send layout IRC correctly", async () => {
await SettingsStore.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
defaultDispatcher.dispatch(
Expand All @@ -217,7 +222,7 @@ describe("PosthogAnalytics", () => {
analytics.trackEvent<ITestEvent>({
eventName: "JestTestEvents",
});
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toStrictEqual({
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({
WebLayout: "IRC",
});
});
Expand All @@ -234,7 +239,7 @@ describe("PosthogAnalytics", () => {
analytics.trackEvent<ITestEvent>({
eventName: "JestTestEvents",
});
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toStrictEqual({
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({
WebLayout: "Bubble",
});
});
Expand All @@ -251,7 +256,7 @@ describe("PosthogAnalytics", () => {
analytics.trackEvent<ITestEvent>({
eventName: "JestTestEvents",
});
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toStrictEqual({
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({
WebLayout: "Group",
});
});
Expand All @@ -269,12 +274,51 @@ describe("PosthogAnalytics", () => {
analytics.trackEvent<ITestEvent>({
eventName: "JestTestEvents",
});
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toStrictEqual({
console.log(mocked(fakePosthog).capture.mock.calls[0]);
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({
WebLayout: "Compact",
});
});
});

describe("UrlPreviews", () => {
let analytics: PosthogAnalytics;

beforeEach(() => {
SdkConfig.put({
brand: "Testing",
posthog: {
project_api_key: "foo",
api_host: "bar",
},
});

analytics = new PosthogAnalytics(fakePosthog);
analytics.setAnonymity(Anonymity.Pseudonymous);
});

afterEach(() => {
SdkConfig.reset();
});

it("should set UrlPreviewsEnabled on change", async () => {
defaultDispatcher.dispatch(
{
action: Action.SettingUpdated,
settingName: "urlPreviewsEnabled",
newValue: true,
},
true,
);
analytics.trackEvent<ITestEvent>({
eventName: "JestTestEvents",
});
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({
URLPreviewsEnabled: true,
});
});
});

describe("CryptoSdk", () => {
let analytics: PosthogAnalytics;
const getFakeClient = (): MatrixClient =>
Expand Down
45 changes: 45 additions & 0 deletions apps/web/test/unit-tests/PosthogTrackers-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
Copyright 2026 Element Creations Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import { PosthogAnalytics } from "../../src/PosthogAnalytics";
import PosthogTrackers from "../../src/PosthogTrackers";

describe("PosthogTrackers", () => {
afterEach(() => {
jest.resetAllMocks();
});

it("tracks URL Previews", () => {
jest.spyOn(PosthogAnalytics.instance, "trackEvent");
const tracker = new PosthogTrackers();
tracker.trackUrlPreview("$123456", false, [
{
title: "A preview",
image: {
imageThumb: "abc",
imageFull: "abc",
},
link: "a-link",
},
]);
tracker.trackUrlPreview("$123456", false, [
{
title: "A second preview",
link: "a-link",
},
]);
// Ignores subsequent calls.
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith({
eventName: "UrlPreviewRendered",
previewKind: "LegacyCard",
hasThumbnail: true,
previewCount: 1,
encryptedRoom: false,
});
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledTimes(1);
});
});
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading