diff --git a/echo/frontend/src/components/auth/hooks/index.ts b/echo/frontend/src/components/auth/hooks/index.ts index e55a35732..24dfe4160 100644 --- a/echo/frontend/src/components/auth/hooks/index.ts +++ b/echo/frontend/src/components/auth/hooks/index.ts @@ -253,13 +253,18 @@ export const useLogoutMutation = () => { onSettled: () => { queryClient.invalidateQueries({ queryKey: ["auth", "session"] }); }, - onSuccess: (_data, { next, reason, doRedirect }) => { - posthog?.capture("user_logged_out"); - posthog?.reset(); - if (doRedirect) { - navigate(`/login${buildLoginQuery({ next, reason })}`); - } - }, + onSuccess: (_data, { next, reason, doRedirect }) => { + posthog?.capture("user_logged_out"); + posthog?.reset(); + try { + localStorage.removeItem("last_login_time"); + } catch (e) { + console.error("Failed to remove last_login_time from localStorage:", e); + } + if (doRedirect) { + navigate(`/login${buildLoginQuery({ next, reason })}`); + } + }, }); }; diff --git a/echo/frontend/src/components/release/ReleaseVideoModal.test.tsx b/echo/frontend/src/components/release/ReleaseVideoModal.test.tsx index c0fa93399..fcbb17922 100644 --- a/echo/frontend/src/components/release/ReleaseVideoModal.test.tsx +++ b/echo/frontend/src/components/release/ReleaseVideoModal.test.tsx @@ -40,6 +40,14 @@ vi.mock("@/components/layout/TransitionCurtainProvider", () => ({ useTransitionCurtain: () => curtainState, })); +vi.mock("posthog-js", () => ({ + default: { capture: vi.fn() }, +})); + +vi.mock("@/hooks/useLanguage", () => ({ + useLanguage: () => ({ language: "en-US" }), +})); + import { ReleaseVideoModal } from "./ReleaseVideoModal"; import { getReleases } from "./releases"; import { RELEASE_VIDEO_SEEN_KEY } from "./releaseVideo"; diff --git a/echo/frontend/src/components/release/ReleaseVideoModal.tsx b/echo/frontend/src/components/release/ReleaseVideoModal.tsx index 7f979185b..d43c76f24 100644 --- a/echo/frontend/src/components/release/ReleaseVideoModal.tsx +++ b/echo/frontend/src/components/release/ReleaseVideoModal.tsx @@ -1,14 +1,16 @@ import { t } from "@lingui/core/macro"; import { Modal, Stack } from "@mantine/core"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useId, useState } from "react"; +import { useId, useState, useEffect, useRef } 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 { useLanguage } from "@/hooks/useLanguage"; import { API_BASE_URL } from "@/config"; import { usePrefersReducedMotion } from "@/features/sidebar/animations/motion"; import { useV2Me } from "@/hooks/useV2Me"; +import posthog from "posthog-js"; import styles from "./ReleaseVideoModal.module.css"; import { latestRelease, @@ -67,6 +69,8 @@ export const ReleaseVideoModal = ({ const prefersReducedMotion = usePrefersReducedMotion(); const titleId = useId(); + 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 // direction: better a second showing than a dismissal that will not stick. @@ -106,7 +110,146 @@ export const ReleaseVideoModal = ({ release.version, ))); + const iframeRef = useRef(null); + const playerRef = useRef(null); + const playStartTime = useRef(null); + const watchedSeconds = useRef(0); + const hasPlayed = useRef(false); + + const getLastLoginTime = (): number => { + let val = localStorage.getItem("last_login_time"); + if (!val) { + const nowStr = Date.now().toString(); + try { + localStorage.setItem("last_login_time", nowStr); + } catch {} + val = nowStr; + } + return parseInt(val, 10); + }; + + const getSecondsSinceLogin = (): number | null => { + const lastLogin = getLastLoginTime(); + return lastLogin ? Math.floor((Date.now() - lastLogin) / 1000) : null; + }; + + // Capture modal open event + useEffect(() => { + if (opened && release) { + playStartTime.current = null; + watchedSeconds.current = 0; + hasPlayed.current = false; + + posthog?.capture("whats_new_modal_opened", { + language, + seconds_since_login: getSecondsSinceLogin(), + version: release.version, + }); + } + }, [opened, release?.version, language]); + + const embedUrl = release ? youtubeEmbedUrl(release.videoUrl) : null; + const embedUrlWithApi = embedUrl ? `${embedUrl}&enablejsapi=1` : null; + + // Load YouTube API and track play state + useEffect(() => { + if (!opened || !embedUrlWithApi || !release) return; + + if (!(window as any).YT) { + const tag = document.createElement("script"); + tag.src = "https://www.youtube.com/iframe_api"; + const firstScriptTag = document.getElementsByTagName("script")[0]; + firstScriptTag?.parentNode?.insertBefore(tag, firstScriptTag); + } + + let checkInterval: NodeJS.Timeout; + let initialized = false; + + const initPlayer = () => { + const anyWindow = window as any; + if (anyWindow.YT && anyWindow.YT.Player && iframeRef.current && !initialized) { + initialized = true; + playerRef.current = new anyWindow.YT.Player(iframeRef.current, { + events: { + onStateChange: (event: any) => { + const state = event.data; + // 1 is PLAYING + if (state === 1) { + if (!hasPlayed.current) { + hasPlayed.current = true; + posthog?.capture("whats_new_video_started", { + language, + seconds_since_login: getSecondsSinceLogin(), + version: release.version, + }); + } + playStartTime.current = Date.now(); + } else { + // PAUSED (2), ENDED (0), etc. + if (playStartTime.current !== null) { + const elapsed = (Date.now() - playStartTime.current) / 1000; + watchedSeconds.current += elapsed; + playStartTime.current = null; + } + } + }, + }, + }); + clearInterval(checkInterval); + } + }; + + const anyWindow = window as any; + if (anyWindow.YT && anyWindow.YT.Player) { + initPlayer(); + } else { + checkInterval = setInterval(initPlayer, 100); + } + + return () => { + if (checkInterval) clearInterval(checkInterval); + if (playerRef.current && typeof playerRef.current.destroy === "function") { + try { + playerRef.current.destroy(); + } catch {} + } + playerRef.current = null; + playStartTime.current = null; + }; + }, [opened, embedUrlWithApi, release?.version, language]); + const close = () => { + if (playStartTime.current !== null) { + const elapsed = (Date.now() - playStartTime.current) / 1000; + watchedSeconds.current += elapsed; + playStartTime.current = null; + } + + let videoDuration = 0; + try { + if (playerRef.current && typeof playerRef.current.getDuration === "function") { + videoDuration = playerRef.current.getDuration(); + } + } catch (e) { + console.error("Failed to get video duration:", e); + } + + const percentWatched = videoDuration > 0 + ? Math.min(100, Math.round((watchedSeconds.current / videoDuration) * 100)) + : 0; + + if (release) { + posthog?.capture("whats_new_modal_closed", { + language, + seconds_since_login: getSecondsSinceLogin(), + version: release.version, + video_watched_seconds: Math.round(watchedSeconds.current * 10) / 10, + video_duration_seconds: videoDuration, + video_percent_watched: percentWatched, + video_watched: hasPlayed.current, + }); + } + setDismissed(true); onRequestedClose?.(); if (release) markSeen.mutate(release.version); @@ -114,8 +257,6 @@ export const ReleaseVideoModal = ({ if (!release) return null; - const embedUrl = youtubeEmbedUrl(release.videoUrl); - return ( - {embedUrl ? ( + {embedUrlWithApi ? (