mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(sidebar): co-locate sidebar feature into features/sidebar
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
interface DropdownRect {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
libraryDropdownOpen: boolean;
|
||||
setLibraryDropdownOpen: (open: boolean) => void;
|
||||
dropdownRect: DropdownRect;
|
||||
libraryTriggerRef: React.RefObject<HTMLButtonElement | null>;
|
||||
}
|
||||
|
||||
export function useSidebarLibraryDropdown(): Result {
|
||||
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
|
||||
const [dropdownRect, setDropdownRect] = useState<DropdownRect>({ top: 0, left: 0, width: 0 });
|
||||
const libraryTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const updateDropdownPosition = useCallback(() => {
|
||||
const el = libraryTriggerRef.current;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
setDropdownRect({
|
||||
top: r.bottom + 4,
|
||||
left: r.left,
|
||||
width: r.width,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!libraryDropdownOpen) return;
|
||||
updateDropdownPosition();
|
||||
const onWin = () => updateDropdownPosition();
|
||||
window.addEventListener('resize', onWin);
|
||||
window.addEventListener('scroll', onWin, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onWin);
|
||||
window.removeEventListener('scroll', onWin, true);
|
||||
};
|
||||
}, [libraryDropdownOpen, updateDropdownPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryDropdownOpen) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const t = e.target as Node;
|
||||
if (libraryTriggerRef.current?.contains(t)) return;
|
||||
const panel = document.querySelector('.nav-library-dropdown-panel');
|
||||
if (panel?.contains(t)) return;
|
||||
setLibraryDropdownOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setLibraryDropdownOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [libraryDropdownOpen]);
|
||||
|
||||
return { libraryDropdownOpen, setLibraryDropdownOpen, dropdownRect, libraryTriggerRef };
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { SidebarItemConfig } from '@/features/sidebar/store/sidebarStore';
|
||||
import {
|
||||
applySidebarReorderById,
|
||||
isSidebarNavItemUserHideable,
|
||||
type SidebarNavDropTarget,
|
||||
} from '@/features/sidebar/utils/sidebarNavReorder';
|
||||
import {
|
||||
SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX,
|
||||
SIDEBAR_NAV_LONG_PRESS_MS,
|
||||
isPointerOutsideAsideSidebar,
|
||||
} from '@/features/sidebar/utils/sidebarHelpers';
|
||||
|
||||
interface NavDndState {
|
||||
section: 'library' | 'system';
|
||||
draggedId: string;
|
||||
}
|
||||
|
||||
interface Args {
|
||||
isCollapsed: boolean;
|
||||
sidebarItemsRef: React.MutableRefObject<SidebarItemConfig[]>;
|
||||
setSidebarItems: (items: SidebarItemConfig[]) => void;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
navDnd: NavDndState | null;
|
||||
navDropTarget: SidebarNavDropTarget | null;
|
||||
navDndTrashHint: { x: number; y: number } | null;
|
||||
suppressNavClickRef: React.MutableRefObject<boolean>;
|
||||
handleNavRowPointerDown: (e: React.PointerEvent, section: 'library' | 'system', id: string) => void;
|
||||
navDndRowClass: (section: 'library' | 'system', id: string) => string;
|
||||
}
|
||||
|
||||
export function useSidebarNavDnd({
|
||||
isCollapsed, sidebarItemsRef, setSidebarItems,
|
||||
}: Args): Result {
|
||||
const [navDnd, setNavDnd] = useState<NavDndState | null>(null);
|
||||
const [navDropTarget, setNavDropTarget] = useState<SidebarNavDropTarget | null>(null);
|
||||
const navDropTargetRef = useRef<SidebarNavDropTarget | null>(null);
|
||||
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
navDropTargetRef.current = navDropTarget;
|
||||
/** DOM timers are numeric; avoid NodeJS `Timeout` typing from `setTimeout`. */
|
||||
const longPressTimersRef = useRef<Map<number, number>>(new Map());
|
||||
const suppressNavClickRef = useRef(false);
|
||||
const lastPointerDuringNavDndRef = useRef({ x: 0, y: 0 });
|
||||
const [navDndTrashHint, setNavDndTrashHint] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
useEffect(() => () => {
|
||||
longPressTimersRef.current.forEach(t => window.clearTimeout(t));
|
||||
longPressTimersRef.current.clear();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!navDnd) return;
|
||||
|
||||
const updateDropFromPoint = (clientX: number, clientY: number) => {
|
||||
if (isPointerOutsideAsideSidebar(clientX, clientY)) {
|
||||
navDropTargetRef.current = null;
|
||||
setNavDropTarget(null);
|
||||
return;
|
||||
}
|
||||
const rows = document.querySelectorAll<HTMLElement>('.sidebar [data-sidebar-nav-dnd-row]');
|
||||
let target: SidebarNavDropTarget | null = null;
|
||||
for (const row of rows) {
|
||||
const section = row.dataset.sidebarSection as 'library' | 'system' | undefined;
|
||||
if (section !== navDnd.section) continue;
|
||||
const rect = row.getBoundingClientRect();
|
||||
const id = row.dataset.sidebarId;
|
||||
if (!id) continue;
|
||||
if (clientY < rect.top + rect.height / 2) {
|
||||
target = { id, before: true, section };
|
||||
break;
|
||||
}
|
||||
target = { id, before: false, section };
|
||||
}
|
||||
navDropTargetRef.current = target;
|
||||
setNavDropTarget(target);
|
||||
};
|
||||
|
||||
const endDrag = (apply: boolean) => {
|
||||
window.removeEventListener('pointermove', onMove, { capture: true });
|
||||
window.removeEventListener('pointerup', onUp, true);
|
||||
window.removeEventListener('pointercancel', onUp, true);
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
document.body.style.userSelect = '';
|
||||
setNavDndTrashHint(null);
|
||||
|
||||
const currentDnd = navDnd;
|
||||
const drop = navDropTargetRef.current;
|
||||
setNavDnd(null);
|
||||
setNavDropTarget(null);
|
||||
navDropTargetRef.current = null;
|
||||
|
||||
if (!apply || !currentDnd) return;
|
||||
|
||||
const { x, y } = lastPointerDuringNavDndRef.current;
|
||||
if (isPointerOutsideAsideSidebar(x, y)) {
|
||||
const id = currentDnd.draggedId;
|
||||
if (id && isSidebarNavItemUserHideable(id)) {
|
||||
const nextItems: SidebarItemConfig[] = sidebarItemsRef.current.map(i =>
|
||||
i.id === id ? { ...i, visible: false } : i,
|
||||
);
|
||||
setSidebarItems(nextItems);
|
||||
suppressNavClickRef.current = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const next = applySidebarReorderById(
|
||||
sidebarItemsRef.current,
|
||||
currentDnd.section,
|
||||
currentDnd.draggedId,
|
||||
drop,
|
||||
);
|
||||
if (next) {
|
||||
setSidebarItems(next);
|
||||
suppressNavClickRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onMove = (e: PointerEvent) => {
|
||||
e.preventDefault();
|
||||
lastPointerDuringNavDndRef.current = { x: e.clientX, y: e.clientY };
|
||||
|
||||
const outside = isPointerOutsideAsideSidebar(e.clientX, e.clientY);
|
||||
const draggedId = navDnd.draggedId;
|
||||
const canTrash = Boolean(draggedId && isSidebarNavItemUserHideable(draggedId));
|
||||
if (outside && canTrash) {
|
||||
setNavDndTrashHint({ x: e.clientX, y: e.clientY });
|
||||
} else {
|
||||
setNavDndTrashHint(null);
|
||||
}
|
||||
|
||||
updateDropFromPoint(e.clientX, e.clientY);
|
||||
};
|
||||
|
||||
const onUp = (e: PointerEvent) => {
|
||||
lastPointerDuringNavDndRef.current = { x: e.clientX, y: e.clientY };
|
||||
suppressNavClickRef.current = true;
|
||||
endDrag(true);
|
||||
};
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
endDrag(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.body.style.userSelect = 'none';
|
||||
window.addEventListener('pointermove', onMove, { capture: true, passive: false });
|
||||
window.addEventListener('pointerup', onUp, true);
|
||||
window.addEventListener('pointercancel', onUp, true);
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onMove, { capture: true });
|
||||
window.removeEventListener('pointerup', onUp, true);
|
||||
window.removeEventListener('pointercancel', onUp, true);
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
document.body.style.userSelect = '';
|
||||
setNavDndTrashHint(null);
|
||||
};
|
||||
}, [navDnd, setSidebarItems, sidebarItemsRef]);
|
||||
|
||||
const handleNavRowPointerDown = useCallback(
|
||||
(e: React.PointerEvent, section: 'library' | 'system', id: string) => {
|
||||
if (isCollapsed || navDnd) return;
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
|
||||
const pid = e.pointerId;
|
||||
const sx = e.clientX;
|
||||
const sy = e.clientY;
|
||||
|
||||
let cleaned = false;
|
||||
const cleanupEarly = () => {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
document.removeEventListener('pointermove', onEarlyMove);
|
||||
document.removeEventListener('pointerup', onEarlyUp, true);
|
||||
document.removeEventListener('pointercancel', onEarlyUp, true);
|
||||
};
|
||||
|
||||
const onEarlyMove = (ev: PointerEvent) => {
|
||||
if (ev.pointerId !== pid) return;
|
||||
if (Math.hypot(ev.clientX - sx, ev.clientY - sy) > SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX) {
|
||||
const t = longPressTimersRef.current.get(pid);
|
||||
if (t != null) window.clearTimeout(t);
|
||||
longPressTimersRef.current.delete(pid);
|
||||
cleanupEarly();
|
||||
}
|
||||
};
|
||||
|
||||
const onEarlyUp = (ev: PointerEvent) => {
|
||||
if (ev.pointerId !== pid) return;
|
||||
const t = longPressTimersRef.current.get(pid);
|
||||
if (t != null) window.clearTimeout(t);
|
||||
longPressTimersRef.current.delete(pid);
|
||||
cleanupEarly();
|
||||
};
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
longPressTimersRef.current.delete(pid);
|
||||
cleanupEarly();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
lastPointerDuringNavDndRef.current = { x: sx, y: sy };
|
||||
setNavDnd({ section, draggedId: id });
|
||||
navDropTargetRef.current = { id, before: true, section };
|
||||
setNavDropTarget({ id, before: true, section });
|
||||
try {
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(pid);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, SIDEBAR_NAV_LONG_PRESS_MS) as unknown as number;
|
||||
|
||||
longPressTimersRef.current.set(pid, timer);
|
||||
document.addEventListener('pointermove', onEarlyMove);
|
||||
document.addEventListener('pointerup', onEarlyUp, true);
|
||||
document.addEventListener('pointercancel', onEarlyUp, true);
|
||||
},
|
||||
[isCollapsed, navDnd],
|
||||
);
|
||||
|
||||
const navDndRowClass = useCallback(
|
||||
(section: 'library' | 'system', id: string) => {
|
||||
const dragging = navDnd?.section === section && navDnd.draggedId === id;
|
||||
let drop = '';
|
||||
if (
|
||||
navDnd &&
|
||||
navDropTarget?.section === section &&
|
||||
navDropTarget.id === id &&
|
||||
!(navDnd.section === section && navDnd.draggedId === id)
|
||||
) {
|
||||
drop = navDropTarget.before
|
||||
? 'sidebar-nav-dnd-row--drop-before'
|
||||
: 'sidebar-nav-dnd-row--drop-after';
|
||||
}
|
||||
return `sidebar-nav-dnd-row${dragging ? ' sidebar-nav-dnd-row--dragging' : ''}${drop ? ` ${drop}` : ''}`.trim();
|
||||
},
|
||||
[navDnd, navDropTarget],
|
||||
);
|
||||
|
||||
return {
|
||||
navDnd,
|
||||
navDropTarget,
|
||||
navDndTrashHint,
|
||||
suppressNavClickRef,
|
||||
handleNavRowPointerDown,
|
||||
navDndRowClass,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { getAlbumList } from '@/api/subsonicLibrary';
|
||||
import { isActiveServerReachable } from '@/utils/network/activeServerReachability';
|
||||
import {
|
||||
NEW_RELEASES_RESET_DELAY_MS,
|
||||
NEW_RELEASES_SEEN_MAX_IDS,
|
||||
NEW_RELEASES_UNREAD_POLL_MS,
|
||||
NEW_RELEASES_UNREAD_SAMPLE_SIZE,
|
||||
NEW_RELEASES_UNREAD_STORAGE_PREFIX,
|
||||
mergeSeenNewReleaseIdsCap,
|
||||
} from '@/features/sidebar/utils/sidebarHelpers';
|
||||
|
||||
interface Args {
|
||||
serverId: string;
|
||||
filterId: string;
|
||||
isLoggedIn: boolean;
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
export function useSidebarNewReleasesUnread({ serverId, filterId, isLoggedIn, pathname }: Args): number {
|
||||
const [newReleasesUnreadCount, setNewReleasesUnreadCount] = useState(0);
|
||||
const newReleasesRefreshSeqRef = useRef(0);
|
||||
const newReleasesPageEnteredAtRef = useRef<number | null>(null);
|
||||
const newReleasesResetTimerRef = useRef<number | null>(null);
|
||||
|
||||
const newReleasesSeenStorageKey = useMemo(
|
||||
() => `${NEW_RELEASES_UNREAD_STORAGE_PREFIX}:${serverId || 'no-server'}:${filterId || 'all'}`,
|
||||
[serverId, filterId],
|
||||
);
|
||||
const newReleasesSeenAllScopeStorageKey = useMemo(
|
||||
() => `${NEW_RELEASES_UNREAD_STORAGE_PREFIX}:${serverId || 'no-server'}:all`,
|
||||
[serverId],
|
||||
);
|
||||
|
||||
const readSeenNewReleaseIdsByKey = useCallback((key: string): string[] => {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const readSeenNewReleaseIds = useCallback(
|
||||
() => readSeenNewReleaseIdsByKey(newReleasesSeenStorageKey),
|
||||
[newReleasesSeenStorageKey, readSeenNewReleaseIdsByKey],
|
||||
);
|
||||
|
||||
const writeSeenNewReleaseIdsByKey = useCallback((key: string, ids: string[]) => {
|
||||
const normalized = Array.from(new Set(ids.filter(Boolean))).slice(0, NEW_RELEASES_SEEN_MAX_IDS);
|
||||
localStorage.setItem(key, JSON.stringify(normalized));
|
||||
}, []);
|
||||
|
||||
const writeSeenNewReleaseIds = useCallback(
|
||||
(ids: string[]) => writeSeenNewReleaseIdsByKey(newReleasesSeenStorageKey, ids),
|
||||
[newReleasesSeenStorageKey, writeSeenNewReleaseIdsByKey],
|
||||
);
|
||||
|
||||
const refreshNewReleasesUnread = useCallback(async (markAsSeen = false) => {
|
||||
const seq = ++newReleasesRefreshSeqRef.current;
|
||||
const isCurrent = () => seq === newReleasesRefreshSeqRef.current;
|
||||
|
||||
if (!isLoggedIn || !serverId || !isActiveServerReachable()) {
|
||||
if (isCurrent()) setNewReleasesUnreadCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newest = await getAlbumList('newest', NEW_RELEASES_UNREAD_SAMPLE_SIZE, 0);
|
||||
const newestIds = newest.map(a => a.id).filter(Boolean);
|
||||
let seenIds = readSeenNewReleaseIds();
|
||||
|
||||
// For a concrete library scope, bootstrap from the server-wide "all libraries"
|
||||
// baseline when available, so switching scope doesn't hide existing unread.
|
||||
if (seenIds.length === 0 && filterId !== 'all') {
|
||||
const allScopeSeen = readSeenNewReleaseIdsByKey(newReleasesSeenAllScopeStorageKey);
|
||||
if (allScopeSeen.length > 0) {
|
||||
seenIds = allScopeSeen;
|
||||
writeSeenNewReleaseIdsByKey(newReleasesSeenStorageKey, allScopeSeen);
|
||||
}
|
||||
}
|
||||
|
||||
if (seenIds.length === 0) {
|
||||
// First bootstrap for this server/scope: baseline is "already seen".
|
||||
writeSeenNewReleaseIds(newestIds);
|
||||
if (isCurrent()) setNewReleasesUnreadCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (markAsSeen) {
|
||||
// Prepend the live newest sample so a full `seenIds` list + slice(500)
|
||||
// cannot silently discard freshly "read" albums (fixes badge coming back).
|
||||
writeSeenNewReleaseIds(mergeSeenNewReleaseIdsCap(seenIds, newestIds, NEW_RELEASES_SEEN_MAX_IDS));
|
||||
// Keep server-wide baseline in sync so scope fallback never resurrects
|
||||
// already-viewed items after opening the New Releases page.
|
||||
const allScopeSeen = readSeenNewReleaseIdsByKey(newReleasesSeenAllScopeStorageKey);
|
||||
writeSeenNewReleaseIdsByKey(
|
||||
newReleasesSeenAllScopeStorageKey,
|
||||
mergeSeenNewReleaseIdsCap(allScopeSeen, newestIds, NEW_RELEASES_SEEN_MAX_IDS),
|
||||
);
|
||||
if (isCurrent()) setNewReleasesUnreadCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const seenSet = new Set(seenIds);
|
||||
const unread = newestIds.reduce((count, id) => count + (seenSet.has(id) ? 0 : 1), 0);
|
||||
|
||||
if (isCurrent()) setNewReleasesUnreadCount(unread);
|
||||
} catch {
|
||||
// Keep previous value on transient network/API errors.
|
||||
}
|
||||
}, [
|
||||
filterId,
|
||||
isLoggedIn,
|
||||
newReleasesSeenAllScopeStorageKey,
|
||||
newReleasesSeenStorageKey,
|
||||
readSeenNewReleaseIds,
|
||||
readSeenNewReleaseIdsByKey,
|
||||
serverId,
|
||||
writeSeenNewReleaseIds,
|
||||
writeSeenNewReleaseIdsByKey,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const onNewReleasesPage = pathname.startsWith('/new-releases');
|
||||
if (newReleasesResetTimerRef.current != null) {
|
||||
window.clearTimeout(newReleasesResetTimerRef.current);
|
||||
newReleasesResetTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (onNewReleasesPage) {
|
||||
if (newReleasesPageEnteredAtRef.current == null) {
|
||||
newReleasesPageEnteredAtRef.current = Date.now();
|
||||
}
|
||||
const elapsed = Date.now() - newReleasesPageEnteredAtRef.current;
|
||||
const shouldMarkAsSeen = elapsed >= NEW_RELEASES_RESET_DELAY_MS;
|
||||
void refreshNewReleasesUnread(shouldMarkAsSeen);
|
||||
if (!shouldMarkAsSeen) {
|
||||
const remaining = NEW_RELEASES_RESET_DELAY_MS - elapsed;
|
||||
newReleasesResetTimerRef.current = window.setTimeout(() => {
|
||||
newReleasesResetTimerRef.current = null;
|
||||
void refreshNewReleasesUnread(true);
|
||||
}, remaining);
|
||||
}
|
||||
} else {
|
||||
newReleasesPageEnteredAtRef.current = null;
|
||||
void refreshNewReleasesUnread(false);
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
const activeOnNewReleases = pathname.startsWith('/new-releases');
|
||||
const enteredAt = newReleasesPageEnteredAtRef.current;
|
||||
const delayedSeenReached =
|
||||
activeOnNewReleases &&
|
||||
enteredAt != null &&
|
||||
Date.now() - enteredAt >= NEW_RELEASES_RESET_DELAY_MS;
|
||||
void refreshNewReleasesUnread(delayedSeenReached);
|
||||
}, NEW_RELEASES_UNREAD_POLL_MS);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
if (newReleasesResetTimerRef.current != null) {
|
||||
window.clearTimeout(newReleasesResetTimerRef.current);
|
||||
newReleasesResetTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [pathname, refreshNewReleasesUnread]);
|
||||
|
||||
return newReleasesUnreadCount;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { acquirePerfLivePoll, patchPerfLiveAnalysis } from '@/utils/perf/perfLiveStore';
|
||||
import { setPerfProbeTelemetryActive } from '@/utils/perf/perfTelemetry';
|
||||
import { useAnalysisPerfLast } from '@/utils/perf/analysisPerfStore';
|
||||
import { useAnalysisPerfListener } from '@/hooks/useAnalysisPerfListener';
|
||||
import { useCoverPerfListener, useCoverUiThroughputPoll } from '@/hooks/useCoverPerfListener';
|
||||
import {
|
||||
getPerfProbeFlags,
|
||||
subscribePerfProbeFlags,
|
||||
} from '@/utils/perf/perfFlags';
|
||||
import { hasAnyLiveMetricPollNeed, usePerfLiveOverlayPins } from '@/utils/perf/perfOverlayPins';
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
interface Result {
|
||||
perfProbeOpen: boolean;
|
||||
setPerfProbeOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function useNeedAnalysisTelemetry(perfProbeOpen: boolean, livePins: ReadonlySet<string>): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribePerfProbeFlags,
|
||||
() => {
|
||||
const flags = getPerfProbeFlags();
|
||||
return (
|
||||
perfProbeOpen
|
||||
|| flags.showAnalysisPerfOverlay
|
||||
|| livePins.has('analysis:tpm')
|
||||
|| livePins.has('analysis:last')
|
||||
);
|
||||
},
|
||||
() => perfProbeOpen,
|
||||
);
|
||||
}
|
||||
|
||||
function useNeedCoverTelemetry(perfProbeOpen: boolean, livePins: ReadonlySet<string>): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribePerfProbeFlags,
|
||||
() => (
|
||||
perfProbeOpen
|
||||
|| getPerfProbeFlags().showCoverPerfOverlay
|
||||
|| livePins.has('cover:cpm')
|
||||
|| livePins.has('cover:cpm:ui')
|
||||
),
|
||||
() => perfProbeOpen,
|
||||
);
|
||||
}
|
||||
|
||||
/** Wires Ctrl+Shift+D PsyLab modal and shared live metric polling. */
|
||||
export function useSidebarPerfProbe(): Result {
|
||||
const [perfProbeOpen, setPerfProbeOpen] = useState(false);
|
||||
const livePins = usePerfLiveOverlayPins();
|
||||
const analysisLast = useAnalysisPerfLast();
|
||||
const needAnalysis = useNeedAnalysisTelemetry(perfProbeOpen, livePins);
|
||||
const needCover = useNeedCoverTelemetry(perfProbeOpen, livePins);
|
||||
|
||||
useAnalysisPerfListener(needAnalysis);
|
||||
useCoverPerfListener(needCover);
|
||||
useCoverUiThroughputPoll(needCover);
|
||||
|
||||
useEffect(() => {
|
||||
setPerfProbeTelemetryActive(perfProbeOpen);
|
||||
return () => setPerfProbeTelemetryActive(false);
|
||||
}, [perfProbeOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!perfProbeOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setPerfProbeOpen(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [perfProbeOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const releases: Array<() => void> = [];
|
||||
if (perfProbeOpen) releases.push(acquirePerfLivePoll('modal'));
|
||||
if (hasAnyLiveMetricPollNeed()) releases.push(acquirePerfLivePoll('overlay-pins'));
|
||||
if (releases.length === 0) return;
|
||||
return () => releases.forEach(release => release());
|
||||
}, [perfProbeOpen, livePins.size]);
|
||||
|
||||
useEffect(() => {
|
||||
patchPerfLiveAnalysis({
|
||||
lastTotalMs: analysisLast?.totalMs ?? null,
|
||||
lastFetchMs: analysisLast?.fetchMs ?? null,
|
||||
lastSeedMs: analysisLast?.seedMs ?? null,
|
||||
lastBpmMs: analysisLast?.bpmMs ?? null,
|
||||
});
|
||||
}, [
|
||||
analysisLast?.at,
|
||||
analysisLast?.totalMs,
|
||||
analysisLast?.fetchMs,
|
||||
analysisLast?.seedMs,
|
||||
analysisLast?.bpmMs,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!(e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey)) return;
|
||||
if (e.key.toLowerCase() !== 'd') return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && (
|
||||
target.tagName === 'INPUT'
|
||||
|| target.tagName === 'TEXTAREA'
|
||||
|| target.tagName === 'SELECT'
|
||||
|| target.isContentEditable
|
||||
)) return;
|
||||
e.preventDefault();
|
||||
setPerfProbeOpen(true);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
perfProbeOpen,
|
||||
setPerfProbeOpen,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** Adds .is-scrolling-on-scroll cue to the sidebar viewport for 180ms. */
|
||||
export function useSidebarScrollVisible(sidebarViewportEl: HTMLDivElement | null): boolean {
|
||||
const [isSidebarScrolling, setIsSidebarScrolling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sidebarViewportEl) return;
|
||||
let hideTimer: number | null = null;
|
||||
|
||||
const onScroll = () => {
|
||||
setIsSidebarScrolling(true);
|
||||
if (hideTimer != null) window.clearTimeout(hideTimer);
|
||||
hideTimer = window.setTimeout(() => {
|
||||
setIsSidebarScrolling(false);
|
||||
hideTimer = null;
|
||||
}, 180);
|
||||
};
|
||||
|
||||
sidebarViewportEl.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
sidebarViewportEl.removeEventListener('scroll', onScroll);
|
||||
if (hideTimer != null) window.clearTimeout(hideTimer);
|
||||
};
|
||||
}, [sidebarViewportEl]);
|
||||
|
||||
return isSidebarScrolling;
|
||||
}
|
||||
Reference in New Issue
Block a user