diff --git a/echo/frontend/src/components/release/ReleaseVideoModal.test.tsx b/echo/frontend/src/components/release/ReleaseVideoModal.test.tsx index c0fa9339..3794b60b 100644 --- a/echo/frontend/src/components/release/ReleaseVideoModal.test.tsx +++ b/echo/frontend/src/components/release/ReleaseVideoModal.test.tsx @@ -40,6 +40,22 @@ vi.mock("@/components/layout/TransitionCurtainProvider", () => ({ useTransitionCurtain: () => curtainState, })); +// Captures land here instead of a network; each test reads what it needs. +const captured: Array<{ event: string; props: Record }> = []; + +vi.mock("@posthog/react", () => ({ + usePostHog: () => ({ + capture: (event: string, props: Record) => { + captured.push({ event, props }); + }, + }), +})); + +// A non-default language, so the assertions prove the real one is carried. +vi.mock("@/hooks/useLanguage", () => ({ + useLanguage: () => ({ language: "nl-NL" }), +})); + import { ReleaseVideoModal } from "./ReleaseVideoModal"; import { getReleases } from "./releases"; import { RELEASE_VIDEO_SEEN_KEY } from "./releaseVideo"; @@ -73,6 +89,7 @@ beforeEach(() => { curtainState.isActive = false; meState.data = { settings: {} }; meState.isSuccess = true; + captured.length = 0; vi.stubGlobal( "fetch", vi.fn(async () => new Response(null, { status: 200 })), @@ -260,6 +277,171 @@ describe("opening it on demand", () => { }); }); +// What the analytics must answer: did they watch, how far, in which language, +// from which trigger. The player never loads in jsdom, so the widget messages +// are hand-delivered exactly as the embed would post them. +describe("analytics", () => { + const EMBED_ORIGIN = "https://www.youtube-nocookie.com"; + + const capturesOf = (event: string) => + captured.filter((capture) => capture.event === event); + + const frameElement = () => + screen.getByTitle("Release video") as HTMLIFrameElement; + + const deliver = (info: Record) => { + fireEvent( + window, + new MessageEvent("message", { + data: JSON.stringify({ event: "infoDelivery", info }), + origin: EMBED_ORIGIN, + source: frameElement().contentWindow, + }), + ); + }; + + it("records the automatic showing with language, version and trigger", () => { + renderModal(); + const opens = capturesOf("whats_new_modal_opened"); + expect(opens).toHaveLength(1); + expect(opens[0].props).toMatchObject({ + language: "nl-NL", + trigger: "auto", + version: LATEST.version, + }); + expect(typeof opens[0].props.seconds_since_page_load).toBe("number"); + }); + + it("marks a sidebar showing as manual", async () => { + meState.data = { settings: { [RELEASE_VIDEO_SEEN_KEY]: LATEST.version } }; + const Reopen = () => { + const [requested, handlers] = useDisclosure(false); + return ( + <> + + + + ); + }; + render( + + + + + + + , + ); + expect(capturesOf("whats_new_modal_opened")).toHaveLength(0); + + fireEvent.click(screen.getByRole("button", { name: "What's new" })); + await waitFor(() => { + expect(capturesOf("whats_new_modal_opened")).toHaveLength(1); + }); + expect(capturesOf("whats_new_modal_opened")[0].props.trigger).toBe( + "manual", + ); + }); + + it("turns the embed's own messages into started, progress and completed", () => { + renderModal(); + deliver({ currentTime: 0, duration: 100, playerState: 1 }); + deliver({ currentTime: 1 }); + deliver({ currentTime: 2 }); + expect(capturesOf("whats_new_video_started")).toHaveLength(1); + + deliver({ currentTime: 30 }); + const progress = capturesOf("whats_new_video_progress"); + expect(progress).toHaveLength(1); + expect(progress[0].props.milestone_percent).toBe(25); + + deliver({ currentTime: 100, playerState: 0 }); + expect(capturesOf("whats_new_video_progress")).toHaveLength(4); + const completed = capturesOf("whats_new_video_completed"); + expect(completed).toHaveLength(1); + expect(completed[0].props.video_duration_seconds).toBe(100); + }); + + it("ignores messages that are not from the embed", () => { + renderModal(); + fireEvent( + window, + new MessageEvent("message", { + data: JSON.stringify({ + event: "infoDelivery", + info: { playerState: 1 }, + }), + origin: "https://www.youtube.com", + source: frameElement().contentWindow, + }), + ); + fireEvent( + window, + new MessageEvent("message", { + data: JSON.stringify({ + event: "infoDelivery", + info: { playerState: 1 }, + }), + origin: EMBED_ORIGIN, + source: window, + }), + ); + expect(capturesOf("whats_new_video_started")).toHaveLength(0); + }); + + it("reports an unwatched showing when closed without playing", async () => { + renderModal(); + screen.getByLabelText("Close and go to dembrane").click(); + await waitFor(() => { + expect(capturesOf("whats_new_modal_closed")).toHaveLength(1); + }); + expect(capturesOf("whats_new_modal_closed")[0].props).toMatchObject({ + reason: "dismissed", + video_watched: false, + video_watched_seconds: 0, + }); + }); + + it("carries the watch summary on close", async () => { + renderModal(); + deliver({ currentTime: 0, duration: 100, playerState: 1 }); + deliver({ currentTime: 1 }); + deliver({ currentTime: 2 }); + + screen.getByLabelText("Close and go to dembrane").click(); + await waitFor(() => { + expect(capturesOf("whats_new_modal_closed")).toHaveLength(1); + }); + const summary = capturesOf("whats_new_modal_closed")[0].props; + expect(summary).toMatchObject({ + video_duration_seconds: 100, + video_watched: true, + video_watched_seconds: 2, + }); + expect(summary.video_max_percent).toBe(2); + }); + + it("flushes the summary once when the tab goes, not again on close", async () => { + renderModal(); + fireEvent(window, new Event("pagehide")); + expect(capturesOf("whats_new_modal_closed")).toHaveLength(1); + expect(capturesOf("whats_new_modal_closed")[0].props.reason).toBe( + "pagehide", + ); + + screen.getByLabelText("Close and go to dembrane").click(); + await waitFor(() => { + expect(modalIsOpen()).toBe(false); + }); + expect(capturesOf("whats_new_modal_closed")).toHaveLength(1); + }); +}); + describe("typography", () => { it("renders nothing italic", () => { const { baseElement } = renderModal(); diff --git a/echo/frontend/src/components/release/ReleaseVideoModal.tsx b/echo/frontend/src/components/release/ReleaseVideoModal.tsx index 7f979185..528c140b 100644 --- a/echo/frontend/src/components/release/ReleaseVideoModal.tsx +++ b/echo/frontend/src/components/release/ReleaseVideoModal.tsx @@ -1,21 +1,31 @@ import { t } from "@lingui/core/macro"; import { Modal, Stack } from "@mantine/core"; +import { usePostHog } from "@posthog/react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useId, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { useAuthenticated } from "@/components/auth/hooks"; import { useTransitionCurtain } from "@/components/layout/TransitionCurtainProvider"; import { API_BASE_URL } from "@/config"; import { usePrefersReducedMotion } from "@/features/sidebar/animations/motion"; +import { useLanguage } from "@/hooks/useLanguage"; import { useV2Me } from "@/hooks/useV2Me"; import styles from "./ReleaseVideoModal.module.css"; import { latestRelease, + playerBridgeUrl, RELEASE_VIDEO_SEEN_KEY, shouldShowReleaseVideo, + YOUTUBE_EMBED_ORIGIN, youtubeEmbedUrl, } from "./releaseVideo"; +import { + createWatchTracker, + playerInfoFromMessage, + type WatchEvent, + type WatchTracker, +} from "./videoWatchTracker"; /** * The release video modal: one video, one title, one description, shown once @@ -42,9 +52,18 @@ import { * * Dismissing is the whole interaction: click the backdrop, press escape, or hit * the close button, and the newest version is written to app_user.settings. - * Seen means dismissed, not watched, so no YouTube Player API is loaded. After - * that it only comes back when the user asks for it from the sidebar's - * "What's new". + * Seen means dismissed, not watched. After that it only comes back when the + * user asks for it from the sidebar's "What's new". + * + * Engagement is measured, not stored. PostHog events cover the showing + * (whats_new_modal_opened, with an auto/manual trigger), playback + * (whats_new_video_started, whats_new_video_progress at the milestones, + * whats_new_video_completed) and the roll-up (whats_new_modal_closed). + * Playback state comes from the embed's own postMessage stream (enablejsapi=1 + * plus a `listening` handshake), so no YouTube script is loaded, `script-src` + * stays untouched, and the frame stays on the nocookie origin. Milestones go + * out the moment they are crossed, so a tab closed mid-video still leaves a + * record; pagehide flushes the summary for the walk-away case. * * Typography is held to two combinations, both defined in the adjacent * stylesheet: the two titles at one size, the copy below them at the other. @@ -56,6 +75,9 @@ interface ReleaseVideoModalProps { onRequestedClose?: () => void; } +/** The id the widget echoes back in every message; one player, one constant. */ +const PLAYER_BRIDGE_ID = "release-video-modal"; + export const ReleaseVideoModal = ({ requested = false, onRequestedClose, @@ -66,6 +88,8 @@ export const ReleaseVideoModal = ({ const queryClient = useQueryClient(); const prefersReducedMotion = usePrefersReducedMotion(); const titleId = useId(); + const posthog = usePostHog(); + const { language } = useLanguage(); // Closes the modal immediately, without waiting on the network. If the write // fails the modal returns on the next load, which is the recoverable @@ -73,6 +97,7 @@ export const ReleaseVideoModal = ({ const [dismissed, setDismissed] = useState(false); const release = latestRelease(); + const releaseVersion = release?.version; const markSeen = useMutation({ mutationFn: async (version: string) => { @@ -106,7 +131,168 @@ export const ReleaseVideoModal = ({ release.version, ))); + const embedUrl = release ? youtubeEmbedUrl(release.videoUrl) : null; + const embedSrc = embedUrl + ? playerBridgeUrl(embedUrl, window.location.origin) + : null; + + const iframeRef = useRef(null); + const trackerRef = useRef(createWatchTracker()); + const openedAtRef = useRef(0); + const openRecordedRef = useRef(false); + const summarySentRef = useRef(false); + const triggerRef = useRef<"auto" | "manual">("auto"); + + // One record per showing. The guard absorbs StrictMode re-runs and + // dependency refires, so a showing captures exactly once, and reopening + // from the sidebar starts a fresh one. + useEffect(() => { + if (!opened) { + openRecordedRef.current = false; + return; + } + if (openRecordedRef.current || !releaseVersion) return; + openRecordedRef.current = true; + + trackerRef.current = createWatchTracker(); + openedAtRef.current = Date.now(); + summarySentRef.current = false; + triggerRef.current = requested ? "manual" : "auto"; + + posthog?.capture("whats_new_modal_opened", { + language, + // How deep into the visit the modal appeared. Deliberately not a + // time-since-login guess from the client: the person's real login + // and activity history already lives in PostHog, so recency is a + // query-side join against user_logged_in / prior events. + seconds_since_page_load: Math.round(performance.now() / 1000), + trigger: triggerRef.current, + version: releaseVersion, + }); + }, [opened, language, posthog, releaseVersion, requested]); + + // Kept in a ref so the message listener below never holds a stale closure + // and never has to re-subscribe on a render. + const captureWatchEvent = (watchEvent: WatchEvent) => { + const base = { + language, + trigger: triggerRef.current, + version: releaseVersion, + }; + if (watchEvent.type === "started") { + posthog?.capture("whats_new_video_started", { + ...base, + seconds_after_open: Math.round( + (Date.now() - openedAtRef.current) / 1000, + ), + }); + return; + } + if (watchEvent.type === "milestone") { + posthog?.capture("whats_new_video_progress", { + ...base, + milestone_percent: watchEvent.milestone, + }); + return; + } + const snap = trackerRef.current.snapshot(); + posthog?.capture("whats_new_video_completed", { + ...base, + video_duration_seconds: snap.durationSeconds, + video_watched_seconds: snap.watchedSeconds, + }); + }; + const captureWatchEventRef = useRef(captureWatchEvent); + useEffect(() => { + captureWatchEventRef.current = captureWatchEvent; + }); + + const flushSummary = (reason: "dismissed" | "pagehide") => { + if (summarySentRef.current || !openRecordedRef.current) return; + summarySentRef.current = true; + const snap = trackerRef.current.snapshot(); + posthog?.capture("whats_new_modal_closed", { + language, + modal_open_seconds: Math.round((Date.now() - openedAtRef.current) / 1000), + reason, + trigger: triggerRef.current, + version: releaseVersion, + video_duration_seconds: snap.durationSeconds, + video_max_percent: snap.maxPercent, + video_percent_watched: snap.percentWatched, + video_watched: snap.started, + video_watched_seconds: snap.watchedSeconds, + }); + }; + const flushSummaryRef = useRef(flushSummary); + useEffect(() => { + flushSummaryRef.current = flushSummary; + }); + + // The listening handshake wakes the widget: it answers with initialDelivery + // and then streams infoDelivery while playing. The hello is resent on an + // interval until the first message lands, because the frame may still be + // booting when the modal opens. + useEffect(() => { + if (!opened || !embedSrc) return; + + let handshaken = false; + + const postListening = () => { + iframeRef.current?.contentWindow?.postMessage( + JSON.stringify({ + channel: "widget", + event: "listening", + id: PLAYER_BRIDGE_ID, + }), + YOUTUBE_EMBED_ORIGIN, + ); + }; + + const onMessage = (event: MessageEvent) => { + if (event.origin !== YOUTUBE_EMBED_ORIGIN) return; + if ( + !iframeRef.current || + event.source !== iframeRef.current.contentWindow + ) { + return; + } + handshaken = true; + const info = playerInfoFromMessage(event.data); + if (!info) return; + for (const watchEvent of trackerRef.current.handleInfo(info)) { + captureWatchEventRef.current(watchEvent); + } + }; + + window.addEventListener("message", onMessage); + postListening(); + const handshake = window.setInterval(() => { + if (handshaken) { + window.clearInterval(handshake); + return; + } + postListening(); + }, 500); + + return () => { + window.removeEventListener("message", onMessage); + window.clearInterval(handshake); + }; + }, [opened, embedSrc]); + + // Milestones above are durable on their own; this recovers the close + // summary when the tab goes instead of the modal. posthog-js flushes its + // queue with sendBeacon on pagehide, so the capture still makes it out. + useEffect(() => { + if (!opened) return; + const onPageHide = () => flushSummaryRef.current("pagehide"); + window.addEventListener("pagehide", onPageHide); + return () => window.removeEventListener("pagehide", onPageHide); + }, [opened]); + const close = () => { + flushSummary("dismissed"); setDismissed(true); onRequestedClose?.(); if (release) markSeen.mutate(release.version); @@ -114,8 +300,6 @@ export const ReleaseVideoModal = ({ if (!release) return null; - const embedUrl = youtubeEmbedUrl(release.videoUrl); - return ( - {embedUrl ? ( + {embedSrc ? (