From 357da440c6da32527b6f036fab518f2907fcfa9c Mon Sep 17 00:00:00 2001 From: hrsvrn Date: Thu, 2 Jul 2026 14:31:52 +0530 Subject: [PATCH] feat(email): Superhuman-style shortcuts, smooth scrolling & fast animations List: j/k/arrow cursor with focus ring, Enter/o open, e archive, # trash, u read toggle, c/n compose, / search, g+i / g+d view switch, ? help overlay, layered Escape (suggestions > link bar > composer > thread > search). Thread: r / a / f open reply, reply-all (with fallback), forward. Composer: Cmd/Ctrl+Enter send (commits half-typed recipients), Cmd/Ctrl+ Shift+C/B reveal+focus Cc/Bcc, Esc closes with draft autosave. Perf: content-visibility row virtualization, pointer-events suppression while scrolling, memoized ThreadRow so cursor moves and page appends only re-render affected rows. Animations: 160ms row slide-out on archive/trash/draft-delete with snap-back on failure, fast thread-detail and inline-composer open transitions, 140ms ease-out animated scrolling for cursor-follow and thread-open (respects prefers-reduced-motion). Also fixes stale search-result rows on archive/read actions and Escape closing the whole compose modal while a suggestion menu or link bar was open. Co-Authored-By: Claude Fable 5 --- apps/x/apps/renderer/src/App.css | 92 +++ .../renderer/src/components/email-view.tsx | 777 +++++++++++++++--- 2 files changed, 768 insertions(+), 101 deletions(-) diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 60f24851..c8f700b3 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -224,6 +224,46 @@ flex-direction: column; } +/* Native list virtualization: offscreen rows skip layout and paint entirely. + Applied only to rows without a mounted ThreadDetail (those hold iframes and + composers, which must keep rendering while offscreen). */ +.gmail-row-group-cv { + content-visibility: auto; + contain-intrinsic-size: auto 40px; +} + +/* While the list is scrolling, rows ignore the pointer so hover restyles and + prefetch timers don't compete with frame rendering. */ +.gmail-shell[data-scrolling] .gmail-row-shell { + pointer-events: none; +} + +/* Archived/trashed rows slide out and collapse before removal. Removing the + class (failed action) snaps the row back. */ +.gmail-row-group-leaving { + overflow: hidden; + pointer-events: none; + animation: gmail-row-leave 160ms ease-in forwards; +} + +@keyframes gmail-row-leave { + 0% { + opacity: 1; + transform: translateX(0); + max-height: 48px; + } + 60% { + opacity: 0; + transform: translateX(32px); + max-height: 48px; + } + 100% { + opacity: 0; + transform: translateX(32px); + max-height: 0; + } +} + .gmail-list-header { position: sticky; top: 0; @@ -338,6 +378,20 @@ background: var(--gm-bg-row-selected-hover); } +/* The j/k keyboard cursor. Declared after the hover rules (same specificity) + so the focus ring survives hovering the focused row. */ +.gmail-row-focused, +.gmail-row-focused:hover { + background: var(--gm-bg-row-hover); + box-shadow: inset 0 0 0 1px var(--gm-accent); +} + +.gmail-row-selected.gmail-row-focused, +.gmail-row-selected.gmail-row-focused:hover { + background: var(--gm-bg-row-selected); + box-shadow: inset 2px 0 0 var(--gm-accent), inset 0 0 0 1px var(--gm-accent); +} + .gmail-row-unread { color: var(--gm-text); } @@ -413,12 +467,50 @@ border-top: 1px solid var(--gm-border); border-bottom: 1px solid var(--gm-border); box-shadow: inset 2px 0 0 var(--gm-accent); + /* Replays whenever the detail is shown — hidden details are display: none, + so un-hiding restarts the animation. */ + animation: gmail-detail-open 140ms ease-out; } .gmail-detail-hidden { display: none; } +@keyframes gmail-detail-open { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: none; + } +} + +/* The inline reply/forward composer pops in from below the thread. */ +.gmail-compose-inline { + animation: gmail-compose-open 140ms ease-out; +} + +@keyframes gmail-compose-open { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .gmail-detail-inline, + .gmail-compose-inline, + .gmail-row-group-leaving { + animation: none; + } +} + .gmail-detail-toolbar { display: flex; align-items: center; diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index 113d1afe..fbe6aac1 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -551,6 +551,19 @@ function MessageAttachments({ attachments }: { attachments: NonNullable void autoFocus?: boolean trailing?: React.ReactNode + /** Bump to move focus into this field (e.g. after a Cc/Bcc shortcut). */ + focusSignal?: number }) { const [draft, setDraft] = useState('') const [suggestions, setSuggestions] = useState([]) @@ -745,6 +761,10 @@ function RecipientField({ if (autoFocus) inputRef.current?.focus() }, [autoFocus]) + useEffect(() => { + if (focusSignal) inputRef.current?.focus() + }, [focusSignal]) + const excludeEmails = useMemo( () => value.map((token) => extractAddress(token).toLowerCase()).filter(Boolean), [value], @@ -803,6 +823,12 @@ function RecipientField({ const onKeyDown = (event: React.KeyboardEvent) => { const hasSuggestions = suggestions.length > 0 + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { + // Cmd/Ctrl+Enter is "send" — commit any half-typed address on the way + // and let the event bubble to the composer's send handler. + if (draft.trim()) commit(draft) + return + } if (event.key === 'ArrowDown' && hasSuggestions) { event.preventDefault() setActiveIndex((i) => (i + 1) % suggestions.length) @@ -815,6 +841,7 @@ function RecipientField({ } if (event.key === 'Escape' && hasSuggestions) { event.preventDefault() + event.stopPropagation() setSuggestions([]) return } @@ -846,7 +873,10 @@ function RecipientField({ const showSuggestions = isFocused && suggestions.length > 0 return ( -
+
{label}
{value.map((token, index) => ( @@ -1047,6 +1077,9 @@ const ComposeBox = memo(function ComposeBox({ const [bccList, setBccList] = useState([]) const [showCc, setShowCc] = useState(initialRecipients.cc.length > 0) const [showBcc, setShowBcc] = useState(false) + // Bumped by the Cc/Bcc shortcuts so the freshly revealed field grabs focus. + const [ccFocusSignal, setCcFocusSignal] = useState(0) + const [bccFocusSignal, setBccFocusSignal] = useState(0) const [subject, setSubject] = useState(() => (thread ? composeSubject(mode, thread.subject) : '')) const modeLabel = mode === 'draft' ? 'Draft' : isNew ? 'New message' : mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : 'Reply' @@ -1067,6 +1100,10 @@ const ComposeBox = memo(function ComposeBox({ .join('') }, [mode, thread]) + // Ref so the Tiptap keydown handler (captured once at editor creation) + // always calls the latest send closure; assigned below sendInGmail. + const sendRef = useRef<() => void>(() => {}) + const editor = useEditor({ extensions: [ StarterKit.configure({ link: false }), @@ -1077,6 +1114,13 @@ const ComposeBox = memo(function ComposeBox({ ], editorProps: { attributes: { class: 'compose-content' }, + handleKeyDown: (_view, event) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter' && !event.shiftKey) { + sendRef.current() + return true + } + return false + }, }, content: initialContent, }) @@ -1520,6 +1564,44 @@ const ComposeBox = memo(function ComposeBox({ setSending(false) } } + sendRef.current = () => { void sendInGmail() } + + // Composer-level shortcuts, on the wrapper so they work from any field. + // Inner handlers (recipient menu, link bar, AI Enter) preventDefault first + // and keep priority via the defaultPrevented check. + const onComposerKeyDown = (e: React.KeyboardEvent) => { + if (e.defaultPrevented) return + const mod = e.metaKey || e.ctrlKey + if (mod && e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + e.stopPropagation() + // Deferred one tick: a recipient field may have just committed a + // half-typed address, and send must read the re-rendered list. + window.setTimeout(() => sendRef.current(), 0) + return + } + if (mod && e.shiftKey && e.key.toLowerCase() === 'c') { + e.preventDefault() + e.stopPropagation() + setShowCc(true) + setCcFocusSignal((n) => n + 1) + return + } + if (mod && e.shiftKey && e.key.toLowerCase() === 'b') { + e.preventDefault() + e.stopPropagation() + setShowBcc(true) + setBccFocusSignal((n) => n + 1) + return + } + // The modal variant closes via Radix's own Escape handling. + if (!isModal && e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + if (linkOpen) cancelLink() + else handleClose() + } + } const refineWithCopilot = () => { if (!editor || !thread) return @@ -1578,8 +1660,8 @@ const ComposeBox = memo(function ComposeBox({
} /> - {showCc && } - {showBcc && } + {showCc && } + {showBcc && }
{ - if (event.key === 'Enter') { + // Plain Enter runs the AI writer; Cmd/Ctrl+Enter bubbles to send. + if (event.key === 'Enter' && !event.metaKey && !event.ctrlKey) { event.preventDefault() void runAiBar() } @@ -1753,6 +1836,19 @@ const ComposeBox = memo(function ComposeBox({ showCloseButton={false} aria-describedby={undefined} className="flex h-[min(720px,calc(100vh-4rem))] flex-col gap-0 overflow-hidden p-0 font-sans sm:max-w-[840px]" + onKeyDown={onComposerKeyDown} + onEscapeKeyDown={(event) => { + // Radix's Escape runs document-capture, before the inner fields + // can claim it — let the link bar and an open recipient-suggestion + // menu win; only a bare Escape closes the whole composer. + if (linkOpen) { + event.preventDefault() + cancelLink() + return + } + const target = event.target as HTMLElement | null + if (target?.closest('[data-suggestions-open]')) event.preventDefault() + }} >
{modeLabel} @@ -1774,7 +1870,10 @@ const ComposeBox = memo(function ComposeBox({ } return ( -
+
{modeLabel} +
+ + + +
+
+ {/* Drop the detail as soon as the row starts leaving — the collapse + keyframe assumes row height, and the thread is being removed anyway. */} + {isMounted && !isLeaving && ( +
+ ) +}) + +function ShortcutKey({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +// One shortcut line: `combo` keys render as adjacent chips (a chord or a +// two-key sequence); `alt` is an equivalent alternative shown after "or". +function ShortcutRow({ combo, alt, label }: { combo: string[]; alt?: string[]; label: string }) { + return ( +
+ {label} + + {combo.map((key) => {key})} + {alt && ( + <> + or + {alt.map((key) => {key})} + + )} + +
+ ) +} + +function ShortcutSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+ {children} +
+ ) +} + +// The "?" cheat sheet. Static list — keep in sync with the handlers in +// EmailView, ThreadDetail, and ComposeBox. +function ShortcutsHelpDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { + const mod = isMac ? '⌘' : 'Ctrl' + return ( + + + Keyboard shortcuts +
+ + + + + + + + + + + + + + +
+ + + + + + + + + + + +
+
+
+
+ ) +} + const MAX_KEPT_OPEN = 5 const PAGE_SIZE = 25 +// Duration of the row slide-out on archive/trash — matches gmail-row-leave. +const ROW_LEAVE_MS = 160 +// Sticky .gmail-list-header height — rows scrolled to the top stay clear of it. +const LIST_STICKY_HEADER_PX = 32 + +function listScrollerFor(row: HTMLElement): HTMLElement | null { + const list = row.closest('.gmail-list') + return list instanceof HTMLElement ? list : null +} type InboxSection = 'important' | 'other' interface SectionState { @@ -1967,6 +2304,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = const [openedThreadIds, setOpenedThreadIds] = useState(initialThreadId ? [initialThreadId] : []) useEffect(() => { setSelectedThreadId(initialThreadId ?? null) + setFocusedThreadId(initialThreadId ?? null) if (initialThreadId) { setOpenedThreadIds((prev) => { const without = prev.filter((id) => id !== initialThreadId) @@ -1996,6 +2334,31 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = // Gmail sync uses the native Google OAuth connection. const [emailConnection, setEmailConnection] = useState(null) const [settingsOpen, setSettingsOpen] = useState(false) + // Keyboard navigation: the j/k focus cursor over the visible rows, plus the + // "?" shortcuts overlay. lastFocusedIndexRef remembers the cursor's position + // so it can re-anchor when the focused row disappears (archive/trash/reload). + const [focusedThreadId, setFocusedThreadId] = useState(null) + const [helpOpen, setHelpOpen] = useState(false) + // Set while the visible ThreadDetail's inline composer is open; list + // shortcuts stay inert so typing a reply can't archive threads. + const [activeThreadComposing, setActiveThreadComposing] = useState(false) + const searchInputRef = useRef(null) + const rootRef = useRef(null) + const lastFocusedIndexRef = useRef(0) + const listModeRef = useRef(null) + // Timestamp of a pending "g" for the two-key g→i / g→d sequences. + const gPendingRef = useRef(0) + // Rows currently animating out (archive/trash/draft-delete in flight). + const [leavingThreadIds, setLeavingThreadIds] = useState>(() => new Set()) + const markLeaving = useCallback((rowId: string, leaving: boolean) => { + setLeavingThreadIds((prev) => { + if (prev.has(rowId) === leaving) return prev + const next = new Set(prev) + if (leaving) next.add(rowId) + else next.delete(rowId) + return next + }) + }, []) const loadDrafts = useCallback(async () => { setDraftsLoading(true) @@ -2057,14 +2420,18 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = const deleteDraftAction = useCallback(async (thread: GmailThread) => { const id = thread.draftId if (!id) return + // Slide the row out before dropping it from the list. + markLeaving(id, true) + await new Promise((resolve) => window.setTimeout(resolve, ROW_LEAVE_MS)) setDrafts((prev) => prev.filter((d) => d.draftId !== id)) + markLeaving(id, false) try { await window.ipc.invoke('gmail:deleteDraft', { draftId: id }) } catch (err) { toast(`Could not delete draft: ${err instanceof Error ? err.message : String(err)}`, 'error') void loadDrafts() } - }, [loadDrafts]) + }, [loadDrafts, markLeaving]) // Closing the draft composer may have edited/sent/deleted it — refresh. const closeDraftEditor = useCallback(() => { @@ -2097,30 +2464,6 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = } }, []) - // Gmail-style "n" to start a new message. EmailView only mounts while the - // inbox is open, so this is naturally scoped to that view. Ignored while - // typing in any field or when a dialog (compose/settings) is already up. - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key !== 'n' || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || e.isComposing) return - if (composeOpen || settingsOpen || editingDraft) return - const target = e.target as HTMLElement | null - if ( - target && - (target.tagName === 'INPUT' || - target.tagName === 'TEXTAREA' || - target.tagName === 'SELECT' || - target.isContentEditable) - ) { - return - } - e.preventDefault() - setComposeOpen(true) - } - document.addEventListener('keydown', handleKeyDown) - return () => document.removeEventListener('keydown', handleKeyDown) - }, [composeOpen, settingsOpen, editingDraft]) - useEffect(() => { persistedImportant = important }, [important]) useEffect(() => { persistedOther = other }, [other]) useEffect(() => { persistedDrafts = drafts }, [drafts]) @@ -2137,6 +2480,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = }) setImportant(mapSection) setOther(mapSection) + setSearchResults((prev) => prev.map((t) => (t.threadId === threadId ? updater(t) : t))) }, []) const removeThreadFromState = useCallback((threadId: string) => { @@ -2146,6 +2490,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = }) setImportant(filterSection) setOther(filterSection) + setSearchResults((prev) => prev.filter((t) => t.threadId !== threadId)) setSelectedThreadId((current) => (current === threadId ? null : current)) setOpenedThreadIds((prev) => prev.filter((id) => id !== threadId)) }, []) @@ -2197,8 +2542,14 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = }, [markSectionReadAction]) const archiveThreadAction = useCallback(async (threadId: string) => { + // Start the slide-out right away; the row is removed once both the IPC + // and the animation have finished. A failure clears the flag → snap back. + markLeaving(threadId, true) try { - const result = await window.ipc.invoke('gmail:archiveThread', { threadId }) + const [result] = await Promise.all([ + window.ipc.invoke('gmail:archiveThread', { threadId }), + new Promise((resolve) => window.setTimeout(resolve, ROW_LEAVE_MS)), + ]) if (result.ok) { removeThreadFromState(threadId) } else if (result.error) { @@ -2206,12 +2557,18 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = } } catch (err) { toast(`Archive failed: ${err instanceof Error ? err.message : String(err)}`, 'error') + } finally { + markLeaving(threadId, false) } - }, [removeThreadFromState]) + }, [removeThreadFromState, markLeaving]) const trashThreadAction = useCallback(async (threadId: string) => { + markLeaving(threadId, true) try { - const result = await window.ipc.invoke('gmail:trashThread', { threadId }) + const [result] = await Promise.all([ + window.ipc.invoke('gmail:trashThread', { threadId }), + new Promise((resolve) => window.setTimeout(resolve, ROW_LEAVE_MS)), + ]) if (result.ok) { removeThreadFromState(threadId) } else if (result.error) { @@ -2219,10 +2576,13 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = } } catch (err) { toast(`Delete failed: ${err instanceof Error ? err.message : String(err)}`, 'error') + } finally { + markLeaving(threadId, false) } - }, [removeThreadFromState]) + }, [removeThreadFromState, markLeaving]) const toggleThread = useCallback((thread: GmailThread) => { + setFocusedThreadId(thread.threadId) setSelectedThreadId((current) => { const next = current === thread.threadId ? null : thread.threadId if (next) { @@ -2489,89 +2849,295 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = const visibleOther = useMemo(() => filterThreads(other.threads), [other.threads, filterThreads]) const visibleDrafts = useMemo(() => filterThreads(drafts), [drafts, filterThreads]) + // ── Keyboard shortcuts (Superhuman-style) ─────────────────────────────────── + // EmailView only mounts while the email tab is open, so these are naturally + // scoped to that view. Single-letter keys stay inert while typing in any + // field, while a dialog is up, or while the inline reply composer is open. + + const listMode: 'search' | 'drafts' | 'inbox' = query.trim() ? 'search' : view === 'drafts' ? 'drafts' : 'inbox' + const anyModalOpen = composeOpen || settingsOpen || Boolean(editingDraft) || helpOpen + + // Row identity for the focus cursor — must match each row's data-thread-id. + const rowIdOf = useCallback((thread: GmailThread) => ( + listMode === 'drafts' ? (thread.draftId || thread.threadId) : thread.threadId + ), [listMode]) + + // Flattened, ordered list of the rows currently on screen — the domain of + // the j/k cursor. Mirrors the render branches below: search results, drafts, + // or Important followed by Everything else (which only renders once + // Important is exhausted). + const visibleList = useMemo(() => { + if (query.trim()) return searchResults + if (view === 'drafts') return visibleDrafts + if (important.hasReachedEnd && other.threads.length > 0) return [...visibleImportant, ...visibleOther] + return visibleImportant + }, [query, searchResults, view, visibleDrafts, visibleImportant, visibleOther, important.hasReachedEnd, other.threads.length]) + + // Keep the cursor valid as the list changes: switching between inbox, + // search, and drafts resets it; if the focused row vanished (archived, + // trashed, or replaced by a live reload), re-anchor to the same position. + useEffect(() => { + if (listModeRef.current !== listMode) { + const isFirstRun = listModeRef.current === null + listModeRef.current = listMode + if (!isFirstRun) { + lastFocusedIndexRef.current = 0 + setFocusedThreadId(null) + return + } + } + if (!focusedThreadId || visibleList.length === 0) return + const idx = visibleList.findIndex((t) => rowIdOf(t) === focusedThreadId) + if (idx >= 0) { + lastFocusedIndexRef.current = idx + return + } + const fallback = visibleList[Math.min(lastFocusedIndexRef.current, visibleList.length - 1)] + setFocusedThreadId(fallback ? rowIdOf(fallback) : null) + }, [visibleList, focusedThreadId, listMode, rowIdOf]) + + // ── Smooth list scrolling ────────────────────────────────────────────────── + // Short ease-out scroll instead of native behavior:'smooth' — it finishes in + // ~140ms and retargets cleanly under key-repeat, where the native animation + // lags behind and rubber-bands. + const scrollAnimRef = useRef(null) + const smoothScrollTo = useCallback((container: HTMLElement, top: number) => { + if (scrollAnimRef.current !== null) window.cancelAnimationFrame(scrollAnimRef.current) + const from = container.scrollTop + const max = container.scrollHeight - container.clientHeight + const target = Math.min(Math.max(top, 0), max) + const delta = target - from + if (delta === 0) return + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + container.scrollTop = target + return + } + const start = performance.now() + const duration = 140 + const step = (now: number) => { + const t = Math.min(1, (now - start) / duration) + const eased = 1 - (1 - t) ** 3 + container.scrollTop = from + delta * eased + scrollAnimRef.current = t < 1 ? window.requestAnimationFrame(step) : null + } + scrollAnimRef.current = window.requestAnimationFrame(step) + }, []) + + useEffect(() => () => { + if (scrollAnimRef.current !== null) window.cancelAnimationFrame(scrollAnimRef.current) + }, []) + + const rowElementFor = useCallback((rowId: string) => { + const el = rootRef.current?.querySelector(`[data-thread-id="${CSS.escape(rowId)}"]`) + return el instanceof HTMLElement ? el : null + }, []) + + // Keep the focused row on screen during keyboard navigation — "nearest" + // semantics with the animated scroll, minding the sticky section header. + useEffect(() => { + if (!focusedThreadId) return + const row = rowElementFor(focusedThreadId) + const list = row ? listScrollerFor(row) : null + if (!row || !list) return + const listRect = list.getBoundingClientRect() + const rowRect = row.getBoundingClientRect() + const topEdge = listRect.top + LIST_STICKY_HEADER_PX + if (rowRect.top < topEdge) { + smoothScrollTo(list, list.scrollTop + rowRect.top - topEdge) + } else if (rowRect.bottom > listRect.bottom) { + smoothScrollTo(list, list.scrollTop + rowRect.bottom - listRect.bottom) + } + }, [focusedThreadId, rowElementFor, smoothScrollTo]) + + // Opening a thread glides its row to the top of the list so the expanded + // conversation gets the full viewport. Runs after the focus effect above and + // cancels its animation via the shared ref, so on open this scroll wins. + useEffect(() => { + if (!selectedThreadId) return + const row = rowElementFor(selectedThreadId) + const list = row ? listScrollerFor(row) : null + if (!row || !list) return + const rowTop = list.scrollTop + row.getBoundingClientRect().top - list.getBoundingClientRect().top + smoothScrollTo(list, rowTop - LIST_STICKY_HEADER_PX) + }, [selectedThreadId, rowElementFor, smoothScrollTo]) + + // While the list is scrolling, flag the shell so CSS turns off row pointer + // events (see .gmail-shell[data-scrolling]) — otherwise every row passing + // under the cursor restyles for :hover and schedules a prefetch timer, + // stealing frame time. The attribute is toggled straight on the DOM node so + // scrolling itself never causes a React render. + useEffect(() => { + const root = rootRef.current + if (!root) return + let timer: number | null = null + const onScroll = (event: Event) => { + const target = event.target + if (!(target instanceof Element) || !target.classList.contains('gmail-list')) return + if (timer === null) root.setAttribute('data-scrolling', '') + else window.clearTimeout(timer) + timer = window.setTimeout(() => { + timer = null + root.removeAttribute('data-scrolling') + }, 150) + } + // Capture phase: scroll events don't bubble, so listen above the scroller. + root.addEventListener('scroll', onScroll, { capture: true, passive: true }) + return () => { + root.removeEventListener('scroll', onScroll, { capture: true }) + if (timer !== null) window.clearTimeout(timer) + } + }, []) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.isComposing || e.defaultPrevented) return + // All list keys are unmodified. Shift is allowed through because "#" and + // "?" need it; shifted letters produce uppercase e.key and match nothing. + if (e.ctrlKey || e.metaKey || e.altKey) return + const inEditable = isEditableTarget(e.target) + + if (e.key === 'Escape') { + // The search input, recipient fields, and composers own their Escape; + // this level only closes the open thread or clears the search. + if (inEditable || anyModalOpen || activeThreadComposing) return + if (selectedThreadId) { + e.preventDefault() + setSelectedThreadId(null) + return + } + if (query.trim()) { + e.preventDefault() + setQuery('') + } + return + } + + if (inEditable || anyModalOpen || activeThreadComposing) return + + // Two-key "go to" sequences: g then i (inbox) / g then d (drafts). + if (gPendingRef.current && Date.now() - gPendingRef.current < 1000) { + gPendingRef.current = 0 + if (e.key === 'i' || e.key === 'd') { + e.preventDefault() + setQuery('') + setView(e.key === 'i' ? 'inbox' : 'drafts') + return + } + // Any other key cancels the sequence and is handled normally below. + } + if (e.key === 'g') { + gPendingRef.current = Date.now() + return + } + + const focusedIndex = focusedThreadId + ? visibleList.findIndex((t) => rowIdOf(t) === focusedThreadId) + : -1 + + const moveFocus = (delta: number) => { + if (visibleList.length === 0) return + e.preventDefault() // stop arrow keys from also scrolling the list + const next = visibleList[Math.min(Math.max(focusedIndex + delta, 0), visibleList.length - 1)] + if (next) setFocusedThreadId(rowIdOf(next)) + } + + switch (e.key) { + case 'j': + case 'ArrowDown': + moveFocus(1) + return + case 'k': + case 'ArrowUp': + moveFocus(-1) + return + case 'Enter': + case 'o': { + const focused = focusedIndex >= 0 ? visibleList[focusedIndex] : undefined + if (!focused) return + e.preventDefault() + if (listMode === 'drafts') setEditingDraft(focused) + else toggleThread(focused) + return + } + case 'c': + case 'n': + e.preventDefault() + setComposeOpen(true) + return + case '/': + e.preventDefault() + searchInputRef.current?.focus() + return + case '?': + e.preventDefault() + setHelpOpen(true) + return + case 'e': + case '#': + case 'u': { + if (listMode === 'drafts') return // drafts have no archive/trash/read state + // The open thread takes precedence over the cursor. + const targetId = selectedThreadId ?? (focusedIndex >= 0 ? visibleList[focusedIndex]?.threadId : undefined) + const target = targetId ? visibleList.find((t) => t.threadId === targetId) : undefined + if (!target) return + e.preventDefault() + if (e.key === 'u') void markThreadReadAction(target.threadId, target.unread === true) + else if (e.key === 'e') void archiveThreadAction(target.threadId) + else void trashThreadAction(target.threadId) + return + } + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [ + visibleList, focusedThreadId, selectedThreadId, query, listMode, anyModalOpen, + activeThreadComposing, rowIdOf, toggleThread, markThreadReadAction, + archiveThreadAction, trashThreadAction, + ]) + const hasAny = important.threads.length > 0 || other.threads.length > 0 const initialLoading = !hasAny && refreshing const needsEmailConnect = emailConnection?.connected === false const needsEmailReconnect = emailConnection?.connected === true && !emailConnection.hasRequiredScope + const closeThread = useCallback(() => setSelectedThreadId(null), []) + const renderRow = (thread: GmailThread) => { - const latest = latestMessage(thread) - const isSelected = thread.threadId === selectedThreadId - const isUnread = thread.unread === true const isMounted = openedThreadIds.includes(thread.threadId) - const stop = (e: React.MouseEvent | React.KeyboardEvent) => { - e.stopPropagation() - } return ( -
-
scheduleHoverPrefetch(thread)} - onMouseLeave={cancelHoverPrefetch} - > - -
- - - -
-
- {isMounted && ( - setSelectedThreadId(null)} - hidden={!isSelected} - /> - )} -
+ ) } const renderDraftRow = (thread: GmailThread) => { const stop = (e: React.MouseEvent | React.KeyboardEvent) => { e.stopPropagation() } const recipient = thread.to ? extractName(thread.to) : 'No recipient' + const rowId = thread.draftId || thread.threadId + const isFocused = rowId === focusedThreadId + const isLeaving = leavingThreadIds.has(rowId) return ( -
-
+
+
) }