-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
feat: implement smooth infinite email deletion experience #1609
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,6 +4,7 @@ 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); | ||||||||||
|
|
@@ -23,7 +24,10 @@ export function useMailNavigation({ items, containerRef, onNavigate }: UseMailNa | |||||||||
| itemsRef.current = items; | ||||||||||
| const onNavigateRef = useRef(onNavigate); | ||||||||||
| onNavigateRef.current = onNavigate; | ||||||||||
| const { open: isCommandPaletteOpen } = useCommandPalette(); | ||||||||||
| const [isCommandPaletteOpen] = useQueryState('isCommandPaletteOpen'); | ||||||||||
|
|
||||||||||
| // Track the previously focused thread ID to detect when it gets deleted | ||||||||||
| const prevFocusedThreadId = useRef<string | null>(null); | ||||||||||
|
|
||||||||||
| const hoveredMailRef = useRef<string | null>(null); | ||||||||||
| const keyboardActiveRef = useRef(false); | ||||||||||
|
|
@@ -39,6 +43,7 @@ export function useMailNavigation({ items, containerRef, onNavigate }: UseMailNa | |||||||||
| setFocusedIndex(null); | ||||||||||
| onNavigateRef.current(null); | ||||||||||
| keyboardActiveRef.current = false; | ||||||||||
| prevFocusedThreadId.current = null; | ||||||||||
| }, [setFocusedIndex, onNavigateRef]); | ||||||||||
|
|
||||||||||
| const getThreadElement = useCallback( | ||||||||||
|
|
@@ -77,6 +82,9 @@ export function useMailNavigation({ items, containerRef, onNavigate }: UseMailNa | |||||||||
| const message = itemsRef.current[index]; | ||||||||||
| const threadId = message.id; | ||||||||||
|
|
||||||||||
| // Update the tracked focused thread ID | ||||||||||
| prevFocusedThreadId.current = threadId; | ||||||||||
|
|
||||||||||
| const currentThreadId = window.location.search.includes('threadId='); | ||||||||||
| if (currentThreadId) { | ||||||||||
| onNavigateRef.current(threadId); | ||||||||||
|
|
@@ -98,36 +106,101 @@ export function useMailNavigation({ items, containerRef, onNavigate }: UseMailNa | |||||||||
| const firstItem = itemsRef.current[0]; | ||||||||||
| if (firstItem) { | ||||||||||
| onNavigateRef.current(firstItem.id); | ||||||||||
| prevFocusedThreadId.current = firstItem.id; | ||||||||||
| } | ||||||||||
| scrollIntoView(0, 'auto'); | ||||||||||
| return 0; | ||||||||||
| } | ||||||||||
| onNavigateRef.current(null); | ||||||||||
| prevFocusedThreadId.current = null; | ||||||||||
| return null; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| if (prevIndex < itemsRef.current.length - 1) { | ||||||||||
| const newIndex = prevIndex; | ||||||||||
| const nextItem = itemsRef.current[prevIndex + 1]; | ||||||||||
| // Current focused index is beyond the available items (thread was deleted) | ||||||||||
| if (prevIndex >= itemsRef.current.length) { | ||||||||||
| const newIndex = Math.max(0, itemsRef.current.length - 1); | ||||||||||
| const nextItem = itemsRef.current[newIndex]; | ||||||||||
| if (nextItem) { | ||||||||||
| onNavigateRef.current(nextItem.id); | ||||||||||
| prevFocusedThreadId.current = nextItem.id; | ||||||||||
| scrollIntoView(newIndex, 'auto'); | ||||||||||
| return newIndex; | ||||||||||
| } else { | ||||||||||
| onNavigateRef.current(null); | ||||||||||
| prevFocusedThreadId.current = null; | ||||||||||
| return null; | ||||||||||
| } | ||||||||||
| scrollIntoView(newIndex, 'auto'); | ||||||||||
| return newIndex; | ||||||||||
| } else { | ||||||||||
| const newIndex = itemsRef.current.length > 1 ? prevIndex - 1 : null; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| if (newIndex !== null) { | ||||||||||
| // Check if the focused thread was deleted by comparing thread IDs | ||||||||||
| const currentItem = itemsRef.current[prevIndex]; | ||||||||||
| const currentThreadId = currentItem?.id; | ||||||||||
| const wasFocusedThreadDeleted = prevFocusedThreadId.current && | ||||||||||
| (!currentThreadId || currentThreadId !== prevFocusedThreadId.current); | ||||||||||
|
|
||||||||||
| if (wasFocusedThreadDeleted) { | ||||||||||
| // The focused thread was deleted, navigate to the same position | ||||||||||
| // which now contains the next email in the list | ||||||||||
| if (prevIndex < itemsRef.current.length) { | ||||||||||
| const nextItem = itemsRef.current[prevIndex]; | ||||||||||
| if (nextItem) { | ||||||||||
| onNavigateRef.current(nextItem.id); | ||||||||||
| prevFocusedThreadId.current = nextItem.id; | ||||||||||
| scrollIntoView(prevIndex, 'auto'); | ||||||||||
| return prevIndex; | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // If no item at current position, try the previous position | ||||||||||
| if (prevIndex > 0) { | ||||||||||
| const newIndex = prevIndex - 1; | ||||||||||
| const nextItem = itemsRef.current[newIndex]; | ||||||||||
| if (nextItem) { | ||||||||||
| onNavigateRef.current(nextItem.id); | ||||||||||
| prevFocusedThreadId.current = nextItem.id; | ||||||||||
| scrollIntoView(newIndex, 'auto'); | ||||||||||
| return newIndex; | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| onNavigateRef.current(null); | ||||||||||
| prevFocusedThreadId.current = null; | ||||||||||
| return null; | ||||||||||
| } else if (currentItem) { | ||||||||||
| // Current item existts and wasnt deleted, then navigate to the next one | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's a typo in this comment:
Suggested change
Spotted by Diamond
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. BRO STOP
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix typo: "existts" should be "exists" - // Current item existts and wasnt deleted, then navigate to the next one
+ // Current item exists and wasn't deleted, then navigate to the next one📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| if (prevIndex < itemsRef.current.length - 1) { | ||||||||||
| const newIndex = prevIndex + 1; | ||||||||||
| const nextItem = itemsRef.current[newIndex]; | ||||||||||
| if (nextItem) { | ||||||||||
| onNavigateRef.current(nextItem.id); | ||||||||||
| prevFocusedThreadId.current = nextItem.id; | ||||||||||
| } | ||||||||||
| scrollIntoView(newIndex, 'auto'); | ||||||||||
| return newIndex; | ||||||||||
| } else { | ||||||||||
| onNavigateRef.current(null); | ||||||||||
| return null; | ||||||||||
| // we're at the end so stay at the current thread | ||||||||||
| onNavigateRef.current(currentItem.id); | ||||||||||
| prevFocusedThreadId.current = currentItem.id; | ||||||||||
| scrollIntoView(prevIndex, 'auto'); | ||||||||||
| return prevIndex; | ||||||||||
| } | ||||||||||
| } else { | ||||||||||
| // no current item so try to find any available thread | ||||||||||
| if (itemsRef.current.length > 0) { | ||||||||||
| const newIndex = Math.min(prevIndex, itemsRef.current.length - 1); | ||||||||||
| const nextItem = itemsRef.current[newIndex]; | ||||||||||
| if (nextItem) { | ||||||||||
| onNavigateRef.current(nextItem.id); | ||||||||||
| prevFocusedThreadId.current = nextItem.id; | ||||||||||
| scrollIntoView(newIndex, 'auto'); | ||||||||||
| return newIndex; | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // 0 threads available | ||||||||||
| onNavigateRef.current(null); | ||||||||||
| prevFocusedThreadId.current = null; | ||||||||||
| return null; | ||||||||||
| } | ||||||||||
| }); | ||||||||||
| }, [onNavigateRef, scrollIntoView, setFocusedIndex]); | ||||||||||
|
|
@@ -186,18 +259,22 @@ export function useMailNavigation({ items, containerRef, onNavigate }: UseMailNa | |||||||||
| if (focusedIndex === null) return; | ||||||||||
|
|
||||||||||
| const message = itemsRef.current[focusedIndex]; | ||||||||||
| if (message) onNavigateRef.current(message.id); | ||||||||||
| if (message) { | ||||||||||
| onNavigateRef.current(message.id); | ||||||||||
| prevFocusedThreadId.current = message.id; | ||||||||||
| } | ||||||||||
| }, [focusedIndex]); | ||||||||||
|
|
||||||||||
| const handleEscape = useCallback(() => { | ||||||||||
| setFocusedIndex(null); | ||||||||||
| onNavigateRef.current(null); | ||||||||||
| keyboardActiveRef.current = false; | ||||||||||
| prevFocusedThreadId.current = null; | ||||||||||
| }, [setFocusedIndex, onNavigateRef]); | ||||||||||
|
|
||||||||||
| useHotkeys('ArrowUp', handleArrowUp, { preventDefault: true, enabled: !isCommandPaletteOpen }); | ||||||||||
| useHotkeys('ArrowDown', handleArrowDown, { preventDefault: true, enabled: !isCommandPaletteOpen }); | ||||||||||
| useHotkeys('j', handleArrowDown,{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 }); | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,6 +1,9 @@ | ||||||||||
| import { useTRPC } from '@/providers/query-provider'; | ||||||||||
| import { useQuery } from '@tanstack/react-query'; | ||||||||||
| import { useSession } from '@/lib/auth-client'; | ||||||||||
| import { useAtomValue } from 'jotai'; | ||||||||||
| import { optimisticActionsAtom } from '@/store/optimistic-updates'; | ||||||||||
| import { useMemo } from 'react'; | ||||||||||
|
|
||||||||||
| export const useStats = () => { | ||||||||||
| const { data: session } = useSession(); | ||||||||||
|
|
@@ -15,3 +18,49 @@ export const useStats = () => { | |||||||||
|
|
||||||||||
| return statsQuery; | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| export const useAdjustedStats = () => { | ||||||||||
| const { data: session } = useSession(); | ||||||||||
| const trpc = useTRPC(); | ||||||||||
| const optimisticActions = useAtomValue(optimisticActionsAtom); | ||||||||||
|
|
||||||||||
| const statsQuery = useQuery( | ||||||||||
| trpc.mail.count.queryOptions(void 0, { | ||||||||||
| enabled: !!session?.user.id, | ||||||||||
| staleTime: 1000 * 60 * 60, | ||||||||||
| }), | ||||||||||
| ); | ||||||||||
|
|
||||||||||
| const adjustedStats = useMemo(() => { | ||||||||||
| if (!statsQuery.data) return statsQuery.data; | ||||||||||
|
|
||||||||||
| const deltas: Record<string, number> = {}; | ||||||||||
|
|
||||||||||
| Object.values(optimisticActions).forEach((action) => { | ||||||||||
| if (action.type !== 'MOVE') return; | ||||||||||
|
|
||||||||||
| const source = action.source.toLowerCase(); | ||||||||||
| const destination = action.destination?.toLowerCase(); | ||||||||||
|
Comment on lines
+42
to
+43
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: source is accessed without null check which could crash if action.source is undefined
Suggested change
|
||||||||||
|
|
||||||||||
| if (source) { | ||||||||||
| deltas[source] = (deltas[source] ?? 0) - action.threadIds.length; | ||||||||||
| } | ||||||||||
| if (destination && destination !== source) { | ||||||||||
| deltas[destination] = (deltas[destination] ?? 0) + action.threadIds.length; | ||||||||||
| } | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| return statsQuery.data.map((stat) => { | ||||||||||
| const delta = deltas[stat.label?.toLowerCase() ?? ''] ?? 0; | ||||||||||
| return { | ||||||||||
| ...stat, | ||||||||||
| count: Math.max(0, (stat.count ?? 0) + delta), | ||||||||||
| }; | ||||||||||
| }); | ||||||||||
| }, [statsQuery.data, optimisticActions]); | ||||||||||
|
|
||||||||||
| return { | ||||||||||
| ...statsQuery, | ||||||||||
| data: adjustedStats, | ||||||||||
| }; | ||||||||||
| }; | ||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logic: This check may return true for any query param containing 'threadId='. Use URLSearchParams to properly parse the query string
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
please fix