import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react"; import { UNFINISHED_NOTE_TOAST_CLASS } from "../../utils/unfinishedNoteNag"; // The unfinished-note reminder is a toast at the bottom of the screen, and the composer is at the bottom of the // conversation, so the reminder covered Send. The toast stays where it is and keeps its own rules; the composer // makes room for it instead, only while it is on screen and only as far as it actually reaches. At phone width the // toast spans the screen and wraps, so the room is measured, never a fixed number. /** Space kept between the composer's controls and the reminder's top edge. */ const GAP = 8; /** How long the reminder must hold still before it is no longer followed: longer than the toast's 400ms slide. */ const SETTLE_MS = 500; interface Edge { top: number; left: number; right: number } /** * How much room the composer needs so its controls end above the reminder. `unliftedBottom` is where the controls * would end with no room made. Nothing is needed when the reminder is absent or beside the composer, and never more * than the reminder's own strip of the screen, so a page that scrolls instead of shrinking cannot grow it for ever. */ export const clearanceFor = ( unliftedBottom: number, composer: { left: number; right: number }, reminder: Edge | undefined, viewportHeight: number, ): number => { if (!reminder) return 0; if (reminder.right <= composer.left || reminder.left >= composer.right) return 0; const strip = Math.max(0, viewportHeight - reminder.top + GAP); return Math.min(strip, Math.max(0, Math.ceil(unliftedBottom + GAP - reminder.top))); }; /** * The same room for a spacer that ends a page rather than sitting under a footer: growing it never moves its own top, * so any shortfall takes the whole strip at once instead of a step that is found short again on every render. */ export const pageEndClearanceFor = (...args: Parameters): number => { const [, , reminder, viewportHeight] = args; return reminder && clearanceFor(...args) > 0 ? Math.max(0, viewportHeight - reminder.top + GAP) : 0; }; /** Whether a change happened in the toasts, rather than anywhere else on the page. */ const TOASTS = "[data-sonner-toaster], [data-sonner-toast]"; const inToasts = (node: EventTarget | Node | null): boolean => node instanceof Element && Boolean(node.closest(TOASTS)); const holdsToasts = (node: Node): boolean => node instanceof Element && (node.matches(TOASTS) || Boolean(node.querySelector(TOASTS))); /** The reminder while it is actually shown: not on its way out and not stacked out of sight. */ const visibleReminder = (): HTMLElement | undefined => Array.from(document.querySelectorAll(`[data-sonner-toast].${UNFINISHED_NOTE_TOAST_CLASS}`)) .find((toast) => toast.dataset.removed !== "true" && toast.dataset.visible !== "false"); /** * The height the composer reserves under its controls. `spacer` sits directly under the controls, so its top is where * they end; `composer` is the footer, whose sides decide whether the reminder is over it at all. */ export const useReminderClearance = ( spacer: RefObject, composer: RefObject, { endsPage = false }: { endsPage?: boolean } = {}, ): number => { const [height, setHeight] = useState(0); const current = useRef(0); current.current = height; const measure = useCallback(() => { const below = spacer.current; const foot = composer.current; if (!below || !foot) return; const toast = visibleReminder(); const next = (endsPage ? pageEndClearanceFor : clearanceFor)( below.getBoundingClientRect().top + current.current, foot.getBoundingClientRect(), toast?.getBoundingClientRect(), window.visualViewport?.height ?? window.innerHeight, ); if (next !== current.current) setHeight(next); }, [spacer, composer, endsPage]); useLayoutEffect(measure); useEffect(() => { // The toast slides in, and moves when the stack changes, by a transform no observer reports. So after any sign // of change it is measured every frame until its edge has held still past the slide. let frame = 0; let lastMove = 0; let lastEdge = ""; const step = () => { measure(); const edge = visibleReminder()?.getBoundingClientRect(); const at = edge ? `${edge.top}|${edge.left}|${edge.right}` : ""; const now = performance.now(); if (at !== lastEdge) { lastEdge = at; lastMove = now; } frame = now - lastMove < SETTLE_MS ? window.requestAnimationFrame(step) : 0; }; const soon = () => { lastMove = performance.now(); if (!frame) frame = window.requestAnimationFrame(step); }; // The reminder arriving, entering, moving in the stack, leaving, wrapping onto more lines, or the window or // keyboard changing size. const appeared = new MutationObserver((records) => { const toastsChanged = (record: MutationRecord) => inToasts(record.target) || Array.from(record.addedNodes).some(holdsToasts) || Array.from(record.removedNodes).some(holdsToasts); if (records.some(toastsChanged)) soon(); }); appeared.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-removed", "data-visible", "data-mounted", "data-index", "data-front", "data-expanded", "style", "class"], }); const moving = (event: Event) => { if (inToasts(event.target)) soon(); }; document.addEventListener("transitionrun", moving, true); const resized = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(soon); if (resized && composer.current) resized.observe(composer.current); const toast = visibleReminder(); if (resized && toast) resized.observe(toast); window.addEventListener("resize", soon); window.visualViewport?.addEventListener("resize", soon); // A change of room re-runs this effect; a reminder still moving is followed on from here. soon(); return () => { if (frame) window.cancelAnimationFrame(frame); appeared.disconnect(); document.removeEventListener("transitionrun", moving, true); resized?.disconnect(); window.removeEventListener("resize", soon); window.visualViewport?.removeEventListener("resize", soon); }; }, [measure, composer, height]); return height; };