diff --git a/apps/web/src/PosthogAnalytics.ts b/apps/web/src/PosthogAnalytics.ts index 546674bbcb..eb4b24cfc8 100644 --- a/apps/web/src/PosthogAnalytics.ts +++ b/apps/web/src/PosthogAnalytics.ts @@ -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(); } @@ -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); } }; diff --git a/apps/web/src/PosthogTrackers.ts b/apps/web/src/PosthogTrackers.ts index a3f00e1117..cc531bde9b 100644 --- a/apps/web/src/PosthogTrackers.ts +++ b/apps/web/src/PosthogTrackers.ts @@ -1,4 +1,5 @@ /* +Copyright 2026 Element Creations Ltd. Copyright 2024 New Vector Ltd. Copyright 2022 The Matrix.org Foundation C.I.C. @@ -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"]; @@ -49,6 +53,8 @@ const SortingAlgorithmMap: Record(1000); + public static get instance(): PosthogTrackers { if (!PosthogTrackers.internalInstance) { PosthogTrackers.internalInstance = new PosthogTrackers(); @@ -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({ + 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 }> { diff --git a/apps/web/src/components/views/messages/TextualBody.tsx b/apps/web/src/components/views/messages/TextualBody.tsx index 31fff6b780..7fadf33510 100644 --- a/apps/web/src/components/views/messages/TextualBody.tsx +++ b/apps/web/src/components/views/messages/TextualBody.tsx @@ -14,6 +14,7 @@ import { useCreateAutoDisposedViewModel, EventContentBodyView, LINKIFIED_DATA_ATTRIBUTE, + useViewModel, } from "@element-hq/web-shared-components"; import { logger as rootLogger } from "matrix-js-sdk/src/logger"; @@ -39,6 +40,7 @@ import { UrlPreviewGroupViewModel } from "../../../viewmodels/message-body/UrlPr 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"); @@ -427,5 +429,14 @@ export default function TextualBody(props: IBodyProps): React.ReactElement { })(); }, [vm, props.showUrlPreview, mediaVisible]); + const { previews } = useViewModel(vm); + + useEffect(() => { + if (previews.length === 0) { + return; + } + PosthogTrackers.instance.trackUrlPreview(props.mxEvent.getId()!, props.mxEvent.isEncrypted(), previews); + }, [props.mxEvent, previews]); + return ; } diff --git a/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts b/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts index b67ca74c27..6d42baf2d5 100644 --- a/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts +++ b/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts @@ -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"); @@ -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({ + 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(); }; diff --git a/apps/web/test/unit-tests/PosthogAnalytics-test.ts b/apps/web/test/unit-tests/PosthogAnalytics-test.ts index 91fdd32f2e..08d62e1373 100644 --- a/apps/web/test/unit-tests/PosthogAnalytics-test.ts +++ b/apps/web/test/unit-tests/PosthogAnalytics-test.ts @@ -1,4 +1,5 @@ /* +Copyright 2026 Element Creations Ltd. Copyright 2024 New Vector Ltd. Copyright 2021 The Matrix.org Foundation C.I.C. @@ -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( @@ -217,7 +222,7 @@ describe("PosthogAnalytics", () => { analytics.trackEvent({ eventName: "JestTestEvents", }); - expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toStrictEqual({ + expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({ WebLayout: "IRC", }); }); @@ -234,7 +239,7 @@ describe("PosthogAnalytics", () => { analytics.trackEvent({ eventName: "JestTestEvents", }); - expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toStrictEqual({ + expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({ WebLayout: "Bubble", }); }); @@ -251,7 +256,7 @@ describe("PosthogAnalytics", () => { analytics.trackEvent({ eventName: "JestTestEvents", }); - expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toStrictEqual({ + expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({ WebLayout: "Group", }); }); @@ -269,12 +274,51 @@ describe("PosthogAnalytics", () => { analytics.trackEvent({ 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({ + eventName: "JestTestEvents", + }); + expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({ + URLPreviewsEnabled: true, + }); + }); + }); + describe("CryptoSdk", () => { let analytics: PosthogAnalytics; const getFakeClient = (): MatrixClient => diff --git a/apps/web/test/unit-tests/PosthogTrackers-test.ts b/apps/web/test/unit-tests/PosthogTrackers-test.ts new file mode 100644 index 0000000000..c835ecacd0 --- /dev/null +++ b/apps/web/test/unit-tests/PosthogTrackers-test.ts @@ -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); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c4dabf53e..f90d985d16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5979,6 +5979,7 @@ packages: '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}