Skip to content
Closed
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
19 changes: 12 additions & 7 deletions echo/frontend/src/components/auth/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })}`);
}
},
});
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
152 changes: 147 additions & 5 deletions echo/frontend/src/components/release/ReleaseVideoModal.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -106,16 +110,153 @@ export const ReleaseVideoModal = ({
release.version,
)));

const iframeRef = useRef<HTMLIFrameElement>(null);
const playerRef = useRef<any>(null);
const playStartTime = useRef<number | null>(null);
const watchedSeconds = useRef<number>(0);
const hasPlayed = useRef<boolean>(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);
};

if (!release) return null;

const embedUrl = youtubeEmbedUrl(release.videoUrl);

return (
<Modal.Root
centered
Expand Down Expand Up @@ -146,13 +287,14 @@ export const ReleaseVideoModal = ({
</Modal.Header>
<Modal.Body style={{ padding: "0 2rem 2rem" }}>
<Stack gap="lg">
{embedUrl ? (
{embedUrlWithApi ? (
<div className={styles.videoFrame}>
<iframe
ref={iframeRef}
allow="accelerometer; clipboard-write; encrypted-media; picture-in-picture; web-share"
allowFullScreen
className={styles.video}
src={embedUrl}
src={embedUrlWithApi}
title={t`Release video`}
/>
</div>
Expand Down
9 changes: 7 additions & 2 deletions echo/frontend/src/routes/auth/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,13 @@ export const LoginRoute = () => {
password: data.password,
});

posthog?.identify(data.email);
posthog?.capture("user_logged_in", { email: data.email });
posthog?.identify(data.email);
posthog?.capture("user_logged_in", { email: data.email });
try {
localStorage.setItem("last_login_time", Date.now().toString());
} catch (e) {
console.error("Failed to set last_login_time in localStorage:", e);
}

const isNewUser = searchParams.get("new") === "true";
const next = searchParams.get("next");
Expand Down
15 changes: 10 additions & 5 deletions echo/frontend/src/routes/auth/Register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,16 @@ export const RegisterRoute = () => {
},
{
onSuccess: () => {
posthog?.identify(data.email);
posthog?.capture("user_registered", {
email: data.email,
first_name: data.first_name,
});
posthog?.identify(data.email);
posthog?.capture("user_registered", {
email: data.email,
first_name: data.first_name,
});
try {
localStorage.setItem("last_login_time", Date.now().toString());
} catch (e) {
console.error("Failed to set last_login_time in localStorage:", e);
}
setSubmittedEmail(data.email);
setStep(2);
},
Expand Down
Loading