Files
Zero/apps/mail/hooks/use-mail-navigation.ts
Adam 277f476575 cleanup on isle zero (#1699)
Ran oxc (https://oxc.rs/docs/guide/usage/linter.html#vscode-extension) and fixed all the issues that came up, set it up to run as a PR check and added steps to the README.md asking users to use it.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Introduced JavaScript linting using oxlint in development guidelines and CI workflow for improved code quality.
  * Added oxlint configuration and dependencies to the project.

* **Bug Fixes**
  * Improved error logging in various components and utilities for better debugging.
  * Enhanced React list rendering by updating keys to use unique values instead of array indices, reducing rendering issues.
  * Replaced browser alerts with toast notifications for a smoother user experience.

* **Refactor**
  * Simplified component logic and state management by removing unused code, imports, props, and components across multiple files.
  * Updated function and component signatures for clarity and maintainability.
  * Improved efficiency of certain operations by switching from arrays to sets for membership checks.

* **Chores**
  * Cleaned up and reorganized import statements throughout the codebase.
  * Removed deprecated files, components, and middleware to streamline the codebase.

* **Documentation**
  * Updated contribution guidelines to include linting requirements for code submissions.

* **Style**
  * Minor formatting and readability improvements in JSX and code structure.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 10:59:40 -07:00

291 lines
8.5 KiB
TypeScript

import { useCallback, useEffect, useRef } from 'react';
import { useOptimisticActions } from './use-optimistic-actions';
import { useMail } from '@/components/mail/use-mail';
import { useHotkeys } from 'react-hotkeys-hook';
import { atom, useAtom } from 'jotai';
import { useQueryState } from 'nuqs';
export const focusedIndexAtom = atom<number | null>(null);
export const mailNavigationCommandAtom = atom<null | 'next' | 'previous'>(null);
export interface UseMailNavigationProps {
items: { id: string }[];
containerRef: React.RefObject<HTMLDivElement | null>;
onNavigate: (threadId: string | null) => void;
}
export function useMailNavigation({ items, containerRef, onNavigate }: UseMailNavigationProps) {
const [, setMail] = useMail();
const [focusedIndex, setFocusedIndex] = useAtom(focusedIndexAtom);
const [command, setCommand] = useAtom(mailNavigationCommandAtom);
const { optimisticMarkAsRead } = useOptimisticActions();
const itemsRef = useRef(items);
itemsRef.current = items;
const onNavigateRef = useRef(onNavigate);
onNavigateRef.current = onNavigate;
const [threadId] = useQueryState('threadId');
const [isCommandPaletteOpen] = useQueryState('isCommandPaletteOpen');
const hoveredMailRef = useRef<string | null>(null);
const keyboardActiveRef = useRef(false);
const lastMoveTime = useRef(0);
useEffect(() => {
if (!keyboardActiveRef.current) {
// setFocusedIndex(null);
}
}, [items, setFocusedIndex]);
const resetNavigation = useCallback(() => {
setFocusedIndex(null);
onNavigateRef.current(null);
keyboardActiveRef.current = false;
}, [setFocusedIndex, onNavigateRef]);
const getThreadElement = useCallback(
(index: number | null) => {
if (index === null || !containerRef.current) return null;
return containerRef.current.querySelector(
`[data-thread-id="${itemsRef.current[index]?.id}"]`,
) as HTMLElement | null;
},
[containerRef],
);
const scrollIntoView = useCallback(
(index: number, behavior: ScrollBehavior = 'smooth') => {
const threadElement = getThreadElement(index);
if (!threadElement || !containerRef.current) return;
const container = containerRef.current;
const containerRect = container.getBoundingClientRect();
const threadRect = threadElement.getBoundingClientRect();
if (threadRect.top < containerRect.top || threadRect.bottom > containerRect.bottom) {
threadElement.scrollIntoView({
block: 'nearest',
behavior,
});
}
},
[containerRef, getThreadElement],
);
const navigateToThread = useCallback(
(index: number) => {
if (index === null || !itemsRef.current[index]) return;
const message = itemsRef.current[index];
const threadId = message.id;
if (threadId) {
onNavigateRef.current(threadId);
optimisticMarkAsRead([threadId], true);
}
setMail((prev) => ({
...prev,
bulkSelected: [],
}));
},
[setMail, threadId],
);
const navigateNext = useCallback(() => {
setFocusedIndex((prevIndex) => {
if (prevIndex === null) {
if (itemsRef.current.length > 0) {
const firstItem = itemsRef.current[0];
if (firstItem) {
onNavigateRef.current(firstItem.id);
}
scrollIntoView(0, 'auto');
return 0;
}
onNavigateRef.current(null);
return null;
}
if (prevIndex < itemsRef.current.length - 1) {
const newIndex = prevIndex;
const nextItem = itemsRef.current[prevIndex + 1];
if (nextItem) {
onNavigateRef.current(nextItem.id);
}
scrollIntoView(newIndex, 'auto');
return newIndex;
} else {
const newIndex = itemsRef.current.length > 1 ? prevIndex - 1 : null;
if (newIndex !== null) {
const nextItem = itemsRef.current[newIndex];
if (nextItem) {
onNavigateRef.current(nextItem.id);
}
scrollIntoView(newIndex, 'auto');
return newIndex;
} else {
onNavigateRef.current(null);
return null;
}
}
});
}, [onNavigateRef, scrollIntoView, setFocusedIndex]);
useEffect(() => {
if (command === 'next') {
navigateNext();
setCommand(null);
}
}, [command, navigateNext, setCommand]);
const getHoveredIndex = useCallback(() => {
if (!hoveredMailRef.current) return -1;
return itemsRef.current.findIndex((item) => item.id === hoveredMailRef.current);
}, []);
const moveFocus = useCallback(
(direction: 'up' | 'down') => {
keyboardActiveRef.current = true;
setFocusedIndex((prevIndex) => {
let newIndex: number;
if (prevIndex === null) {
const hoveredIndex = getHoveredIndex();
if (hoveredIndex !== -1) {
newIndex = hoveredIndex;
} else {
newIndex = direction === 'up' ? itemsRef.current.length - 1 : 0;
}
} else {
newIndex =
direction === 'up'
? Math.max(0, prevIndex - 1)
: Math.min(itemsRef.current.length - 1, prevIndex + 1);
}
if (newIndex === prevIndex && prevIndex !== null) return prevIndex;
scrollIntoView(newIndex, 'smooth');
navigateToThread(newIndex);
return newIndex;
});
},
[setFocusedIndex, getHoveredIndex, scrollIntoView, navigateToThread],
);
const handleArrowUp = useCallback(() => {
moveFocus('up');
}, [moveFocus]);
const handleArrowDown = useCallback(() => {
moveFocus('down');
}, [moveFocus]);
const handleEnter = useCallback(() => {
if (focusedIndex === null) return;
const message = itemsRef.current[focusedIndex];
if (message) onNavigateRef.current(message.id);
}, [focusedIndex]);
const handleEscape = useCallback(() => {
setFocusedIndex(null);
onNavigateRef.current(null);
keyboardActiveRef.current = false;
}, [setFocusedIndex, onNavigateRef]);
useHotkeys('ArrowUp', handleArrowUp, { preventDefault: true, enabled: !isCommandPaletteOpen });
useHotkeys('ArrowDown', handleArrowDown, {
preventDefault: true,
enabled: !isCommandPaletteOpen,
});
useHotkeys('j', handleArrowDown, { enabled: !isCommandPaletteOpen });
useHotkeys('k', handleArrowUp, { enabled: !isCommandPaletteOpen });
useHotkeys('Enter', handleEnter, { preventDefault: true, enabled: !isCommandPaletteOpen });
useHotkeys('Escape', handleEscape, { preventDefault: true, enabled: !isCommandPaletteOpen });
const handleMouseEnter = useCallback(
(threadId: string) => {
hoveredMailRef.current = threadId;
if (keyboardActiveRef.current) {
// setFocusedIndex(null);
keyboardActiveRef.current = false;
}
},
[setFocusedIndex],
);
const fastScroll = useCallback(
(direction: 'up' | 'down') => {
setFocusedIndex((prev) => {
const { length } = itemsRef.current;
const newIndex =
direction === 'up'
? prev === null
? length - 1
: Math.max(0, prev - 1)
: prev === null
? 0
: Math.min(length - 1, prev + 1);
if (newIndex !== prev || prev === null) {
scrollIntoView(newIndex, 'auto');
}
return newIndex;
});
},
[scrollIntoView, setFocusedIndex],
);
useEffect(() => {
let isProcessingKey = false;
const MOVE_DELAY = 100;
const handleKeyDown = (event: KeyboardEvent) => {
if (isCommandPaletteOpen) return;
if (!event.repeat) return;
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
event.preventDefault();
const now = Date.now();
if (now - lastMoveTime.current < MOVE_DELAY) return;
if (isProcessingKey) return;
isProcessingKey = true;
lastMoveTime.current = now;
requestAnimationFrame(() => {
if (event.key === 'ArrowUp') {
fastScroll('up');
} else if (event.key === 'ArrowDown') {
fastScroll('down');
}
isProcessingKey = false;
});
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [fastScroll, isCommandPaletteOpen]);
useEffect(() => {
if (isCommandPaletteOpen) {
keyboardActiveRef.current = false;
}
}, [isCommandPaletteOpen]);
return {
focusedIndex,
handleMouseEnter,
keyboardActive: keyboardActiveRef.current,
resetNavigation,
};
}