Files
kandev/apps/web/components/threads/use-thread-column-activation.ts
T

217 lines
6.8 KiB
TypeScript

import { useCallback, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { nearestTaskId } from "./thread-viewport-geometry";
import { useMobileThreadPosition } from "./use-mobile-thread-position";
type VisibilityEntry = {
element: Element;
isIntersecting: boolean;
};
export type ThreadColumnActivation = {
boardRef: RefObject<HTMLDivElement | null>;
registerColumn: (taskId: string, element: HTMLElement | null) => void;
preloadTaskIds: ReadonlySet<string>;
detailTaskIds: ReadonlySet<string>;
mobileTaskId: string | null;
};
function sameIds(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
if (a.size !== b.size) return false;
for (const id of a) {
if (!b.has(id)) return false;
}
return true;
}
function addAdjacent(ids: Set<string>, orderedIds: readonly string[], id: string): void {
const index = orderedIds.indexOf(id);
if (index < 0) return;
if (index > 0) ids.add(orderedIds[index - 1]);
if (index < orderedIds.length - 1) ids.add(orderedIds[index + 1]);
}
function columnIdForElement(elements: ReadonlyMap<string, HTMLElement>, target: Element) {
for (const [id, element] of elements) {
if (element === target) return id;
}
return null;
}
function measureVisibility(
board: HTMLElement,
elements: ReadonlyMap<string, HTMLElement>,
visibility: Map<string, VisibilityEntry>,
): boolean {
const root = board.getBoundingClientRect();
if (root.width <= 0 || root.height <= 0) return false;
for (const [id, element] of elements) {
const rect = element.getBoundingClientRect();
visibility.set(id, {
element,
isIntersecting:
rect.right > root.left &&
rect.left < root.right &&
rect.bottom > root.top &&
rect.top < root.bottom,
});
}
return true;
}
function resolveVisibleIds(
orderedIds: readonly string[],
visibleIds: ReadonlySet<string>,
observerReady: boolean,
fallbackTaskId: string | null,
): string[] {
if (observerReady) return orderedIds.filter((id) => visibleIds.has(id));
return fallbackTaskId ? [fallbackTaskId] : [];
}
function buildActivationSets({
orderedIds,
visibleIds,
observerReady,
fallbackTaskId,
isMobile,
board,
elements,
}: {
orderedIds: readonly string[];
visibleIds: ReadonlySet<string>;
observerReady: boolean;
fallbackTaskId: string | null;
isMobile: boolean;
board: HTMLElement | null;
elements: ReadonlyMap<string, HTMLElement>;
}) {
const visible = resolveVisibleIds(orderedIds, visibleIds, observerReady, fallbackTaskId);
const detailTaskIds = new Set<string>();
if (isMobile) {
const nearest = nearestTaskId(visible, board, elements);
if (nearest) detailTaskIds.add(nearest);
} else {
visible.forEach((id) => detailTaskIds.add(id));
}
const preloadTaskIds = new Set(visible);
for (const id of visible) addAdjacent(preloadTaskIds, orderedIds, id);
return {
preloadTaskIds: new Set(orderedIds.filter((id) => preloadTaskIds.has(id))),
detailTaskIds,
};
}
/**
* Owns the expensive part of a Threads column's lifecycle. Every task keeps a
* shell, while only visible columns receive session membership data and only
* the active detail window mounts a transcript.
*/
export function useThreadColumnActivation(
orderedIds: readonly string[],
focusedTaskId?: string | null,
layoutKey = "columns",
): ThreadColumnActivation {
const { isMobile } = useResponsiveBreakpoint();
const boardRef = useRef<HTMLDivElement>(null);
const elementsRef = useRef(new Map<string, HTMLElement>());
const visibilityRef = useRef(new Map<string, VisibilityEntry>());
const observerRef = useRef<IntersectionObserver | null>(null);
const [visibleIds, setVisibleIds] = useState<Set<string>>(() => new Set());
const [observerReady, setObserverReady] = useState(false);
const idsKey = orderedIds.join("\u0000");
const hasColumns = orderedIds.length > 0;
const updateVisibleIds = useCallback(() => {
const next = new Set<string>();
for (const [id, entry] of visibilityRef.current) {
if (entry.isIntersecting && elementsRef.current.get(id) === entry.element) next.add(id);
}
setVisibleIds((previous) => (sameIds(previous, next) ? previous : next));
}, []);
const registerColumn = useCallback(
(taskId: string, element: HTMLElement | null) => {
if (element) {
elementsRef.current.set(taskId, element);
observerRef.current?.observe(element);
return;
}
const previous = elementsRef.current.get(taskId);
if (previous) observerRef.current?.unobserve(previous);
elementsRef.current.delete(taskId);
visibilityRef.current.delete(taskId);
updateVisibleIds();
},
[updateVisibleIds],
);
useLayoutEffect(() => {
const board = boardRef.current;
observerRef.current?.disconnect();
observerRef.current = null;
visibilityRef.current.clear();
if (!board || typeof IntersectionObserver === "undefined") {
setVisibleIds(new Set());
setObserverReady(false);
return;
}
setObserverReady(measureVisibility(board, elementsRef.current, visibilityRef.current));
updateVisibleIds();
const observer = new IntersectionObserver(
(entries) => {
if (observerRef.current !== observer) return;
for (const entry of entries) {
const taskId = columnIdForElement(elementsRef.current, entry.target);
if (!taskId) continue;
visibilityRef.current.set(taskId, {
element: entry.target,
isIntersecting: entry.isIntersecting,
});
}
setObserverReady(true);
updateVisibleIds();
},
{ root: board, threshold: [0, 0.5, 1] },
);
observerRef.current = observer;
for (const element of elementsRef.current.values()) observer.observe(element);
return () => {
observer.disconnect();
if (observerRef.current === observer) observerRef.current = null;
};
// Callback refs reconcile membership without dropping surviving visibility.
// Reflow or board replacement rebuilds observation from measured geometry.
}, [hasColumns, layoutKey, isMobile, updateVisibleIds]);
const fallbackTaskId =
focusedTaskId && orderedIds.includes(focusedTaskId) ? focusedTaskId : (orderedIds[0] ?? null);
const mobileTaskId = useMobileThreadPosition({
enabled: isMobile,
boardRef,
elementsRef,
orderedIds,
fallbackTaskId,
});
const sets = useMemo(
() =>
buildActivationSets({
orderedIds,
visibleIds,
observerReady,
fallbackTaskId: isMobile ? mobileTaskId : fallbackTaskId,
isMobile,
board: boardRef.current,
elements: elementsRef.current,
}),
[fallbackTaskId, idsKey, isMobile, mobileTaskId, observerReady, visibleIds],
);
return { boardRef, registerColumn, mobileTaskId, ...sets };
}