Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
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 @@ export class PosthogAnalytics {
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 @@ export class PosthogAnalytics {
this.setProperty("WebLayout", layout);
};

private readonly onUrlPreviewSettingUpdated = (value: boolean): void => {
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);
});
});
1 change: 1 addition & 0 deletions pnpm-lock.yaml

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

Loading