"use client"; // ⇧C (MIN-803): go to an item's comment field, ready to type, with the thread // scrolled to its latest message — from an issue card, from a row of a // collection sidebar (objectives, feedback, pull requests, pages, triage), or // on the item's own view. // // The gesture and the field rarely live in the same component: a card opens a // panel the page owns, a sidebar row navigates, a page keeps its thread in an // Activity sheet mounted on demand, and the panel's content renders in a // portal one render after the panel itself. So the gesture leaves a REQUEST // naming its target (`issue:`, `page:`…), and the two halves meet here: // // • a SURFACE (the view showing an item) registers how to bring its thread on // screen — open the Activity sheet, switch to the conversation tab — and is // what ⇧C targets when no row is hovered; // • a COMPOSER registers how to focus itself; it takes the request as soon as // both exist, whichever comes first, and only then consumes it. // // Module state, like lib/keyboard/selection-keys.ts: the decision happens at // keystroke time. A request that no composer takes (a reader who cannot // comment, a navigation that failed) expires, so it cannot fire later when // the same item opens for another reason. import { useEffect, useRef } from "react"; export type CommentTargetKind = "issue" | "objective" | "feedback" | "pr" | "page"; /** The key a request, a surface and a composer agree on. */ export function commentTarget(kind: CommentTargetKind, id: string): string { return `${kind}:${id}`; } /** Long enough for a navigation and the lazy editor; short enough to never surprise. */ export const COMMENT_FOCUS_TTL_MS = 10_000; interface Surface { target: string; open?: () => void; } let pending: { target: string; at: number } | null = null; /** In registration order: the last one is the surface most recently shown. */ const surfaces: Surface[] = []; const composers = new Map void>(); function livePending(): string | null { if (pending && Date.now() - pending.at > COMMENT_FOCUS_TTL_MS) pending = null; return pending?.target ?? null; } function deliver(): void { const target = livePending(); if (!target) return; const focus = composers.get(target); if (!focus) return; pending = null; focus(); } /** Asks the composer of `target` to take the focus, now or once it mounts. */ export function requestCommentFocus(target: string): void { pending = { target, at: Date.now() }; for (const surface of surfaces) { if (surface.target === target) surface.open?.(); } deliver(); } /** * ⇧C with no row under the pointer: the surface most recently shown. Returns * false when no view showing a comment thread is mounted. */ export function focusCurrentCommentSurface(): boolean { const current = surfaces.at(-1); if (!current) return false; requestCommentFocus(current.target); return true; } export function registerCommentSurface(target: string, open?: () => void): () => void { const surface: Surface = { target, open }; surfaces.push(surface); if (livePending() === target) open?.(); return () => { const index = surfaces.indexOf(surface); if (index !== -1) surfaces.splice(index, 1); }; } export function registerCommentComposer(target: string, focus: () => void): () => void { composers.set(target, focus); deliver(); return () => { if (composers.get(target) === focus) composers.delete(target); }; } /** Test seam: forget every request and registration. */ export function resetCommentFocus(): void { pending = null; surfaces.length = 0; composers.clear(); } /** * Declares the view showing `target`'s thread. `open` brings that thread on * screen when it is not (a closed sheet, another tab). `null` while the view * shows nothing or its viewer cannot comment. */ export function useCommentSurface(target: string | null, open?: () => void): void { const openRef = useRef(open); openRef.current = open; useEffect(() => { if (!target) return; return registerCommentSurface(target, () => openRef.current?.()); }, [target]); } /** Declares the comment field of `target`; `focus` runs when ⇧C asks for it. */ export function useCommentComposerFocus(target: string | null, focus: () => void): void { const focusRef = useRef(focus); focusRef.current = focus; useEffect(() => { if (!target) return; return registerCommentComposer(target, () => focusRef.current()); }, [target]); } /** The nearest ancestor of `el` that scrolls vertically. */ function scrollerOf(el: Element): HTMLElement | null { for (let node = el.parentElement; node; node = node.parentElement) { const { overflowY } = getComputedStyle(node); if (overflowY === "auto" || overflowY === "scroll") return node; } return null; } /** * Scrolls the thread of the surface holding `from` (a composer) to its end, * then brings `from` itself into view. The scroller goes to its very bottom, * past the list's own padding, rather than to the last entry's edge. The * thread may still be loading when the panel has just opened, and the area * around it may still change size (the comments prompt button appears in the * footer once the thread is known): it stays pinned to its end for a moment, * until the user scrolls on their own. */ export function revealLatestComment(from: HTMLElement | null): void { const surface = from?.closest("[data-comment-surface]"); if (!from || !surface) return; const resizes = new ResizeObserver(() => reveal()); let watched: HTMLElement | null = null; const reveal = () => { const thread = surface.querySelector("[data-comment-thread]"); const scroller = thread ? scrollerOf(thread) : null; if (scroller) { scroller.scrollTop = scroller.scrollHeight; if (watched !== scroller) { resizes.disconnect(); resizes.observe(scroller); watched = scroller; } } else { thread?.lastElementChild?.scrollIntoView({ block: "end" }); } from.scrollIntoView({ block: "nearest" }); }; reveal(); const observer = new MutationObserver(reveal); observer.observe(surface, { childList: true, subtree: true }); const stop = () => { observer.disconnect(); resizes.disconnect(); clearTimeout(timer); window.removeEventListener("wheel", stop, true); window.removeEventListener("touchmove", stop, true); }; const timer = setTimeout(stop, 1500); window.addEventListener("wheel", stop, { capture: true, passive: true }); window.addEventListener("touchmove", stop, { capture: true, passive: true }); }