| 1 | import { type RefObject, useEffect, useRef, useState } from "react"; |
| 2 | |
| 3 | import { 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 | */ |
| 14 | export 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 | } |