Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

67 linesCodeBlameRaw
1import { type RefObject, useEffect, useRef, useState } from "react";
2
3import { readNow, showConversation } from "./read-when-shown";
4
5/**
6 * Marks a conversation read up to `newestId` once its messages were seen
7 * here: the page visible, the messages on screen (`target` in view, when
8 * given; `active` false while the surface is closed), each newest id once.
9 * It sends chat's `read`, which moves the read mark for every tab and
10 * device, and fires `g1t:chat-read` so this tab's counts drop at once.
11 * While on screen the conversation counts as shown, so its toasts, sounds
12 * and system notifications stay quiet (lib/read-when-shown.ts).
13 */
14export function useReadWhenShown(
15 slug: string,
16 channelId: string | null,
17 newestId: string | null,
18 options: { target?: RefObject<Element | null>; active?: boolean } = {},
19): void {
20 const { target, active = true } = options;
21 const [inView, setInView] = useState(!target);
22
23 useEffect(() => {
24 const element = target?.current;
25 if (!target || !element) return;
26 if (typeof IntersectionObserver === "undefined") return setInView(true);
27 const observer = new IntersectionObserver(([entry]) => setInView(!!entry?.isIntersecting));
28 observer.observe(element);
29 return () => observer.disconnect();
30 }, [target, active, channelId]);
31
32 const onScreen = active && inView;
33
34 // Shown while on screen: what arrives for it here is not news.
35 useEffect(() => {
36 if (!channelId || !onScreen) return;
37 return showConversation(channelId);
38 }, [channelId, onScreen]);
39
40 const lastRead = useRef<{ channel: string | null; id: string | null }>({ channel: null, id: null });
41 useEffect(() => {
42 if (!channelId || !onScreen) return;
43 if (lastRead.current.channel !== channelId) lastRead.current = { channel: channelId, id: null };
44 const markRead = () => {
45 const id = readNow({ visible: document.visibilityState === "visible", onScreen, newestId, lastRead: lastRead.current.id });
46 if (!id) return;
47 lastRead.current.id = id;
48 void fetch(`/${slug}/-/chat/api`, {
49 method: "POST",
50 headers: { "content-type": "application/json" },
51 body: JSON.stringify({ intent: "read", channel_id: channelId, id }),
52 }).catch(() => {
53 // Not marked: the next time it is shown tries again.
54 if (lastRead.current.id === id) lastRead.current.id = null;
55 });
56 window.dispatchEvent(new CustomEvent("g1t:chat-read", { detail: channelId }));
57 };
58 const timer = setTimeout(markRead, 400);
59 document.addEventListener("visibilitychange", markRead);
60 window.addEventListener("focus", markRead);
61 return () => {
62 clearTimeout(timer);
63 document.removeEventListener("visibilitychange", markRead);
64 window.removeEventListener("focus", markRead);
65 };
66 }, [slug, channelId, newestId, onScreen]);
67}