diff --git a/components/email/__tests__/email-list-scroll-reset.test.tsx b/components/email/__tests__/email-list-scroll-reset.test.tsx new file mode 100644 index 000000000..d6ac07fe5 --- /dev/null +++ b/components/email/__tests__/email-list-scroll-reset.test.tsx @@ -0,0 +1,163 @@ +import { act, render } from '@testing-library/react'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import { EmailList } from '../email-list'; +import { useEmailStore } from '@/stores/email-store'; +import { useMessageListTabsStore } from '@/stores/message-list-tabs-store'; +import { useAuthStore } from '@/stores/auth-store'; +import type { Email } from '@/lib/jmap/types'; + +vi.mock('@/hooks/use-email-drag', () => ({ + useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }), +})); +// The avatar resolves app config and favicons; neither matters here. +vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null })); +vi.mock('@/stores/auth-store', async () => { + const { create } = await import('zustand'); + return { + useAuthStore: create(() => ({ client: null, identities: [], username: null, activeAccountId: 'acct-1' })), + }; +}); +vi.mock('@/stores/account-store', () => { + const state = { getAccountById: () => undefined }; + return { + useAccountStore: Object.assign( + (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state), + { getState: () => state }, + ), + }; +}); + +const makeEmail = (id: string): Email => ({ + id, + threadId: `thread-${id}`, + mailboxIds: { inbox: true }, + keywords: { $seen: true }, + size: 1000, + receivedAt: '2026-09-30T10:00:00Z', + from: [{ name: 'Alice', email: 'alice@example.com' }], + subject: `Mail ${id}`, + hasAttachment: false, +}); + +const INBOX = [makeEmail('a'), makeEmail('b')]; + +// jsdom has no layout and no Element.scrollTo, which the list's virtualizer +// scrolls with. Record the calls: a reset is a scroll to the top. +const scrollTo = vi.fn(); +const element = HTMLElement.prototype as unknown as Record; +beforeAll(() => { element.scrollTo = scrollTo; }); +afterAll(() => { delete element.scrollTo; }); + +const initialEmailState = useEmailStore.getState(); +const initialTabsState = useMessageListTabsStore.getState(); + +beforeEach(() => { + useEmailStore.setState(initialEmailState, true); + useEmailStore.setState({ selectedMailbox: 'inbox' }); + useMessageListTabsStore.setState(initialTabsState, true); + useAuthStore.setState({ activeAccountId: 'acct-1' }); +}); + +/** The list, scrolled down by the user. */ +function renderScrolledList() { + const view = render(); + const scroller = view.container.querySelector('[data-tour="email-list"]') as HTMLElement; + scroller.scrollTop = 800; + scrollTo.mockClear(); + return view; +} + +const resetToTop = () => scrollTo.mock.calls.some(([options]) => options?.top === 0); + +describe('email list scroll position', () => { + it.each([ + ['another folder', () => useEmailStore.getState().selectMailbox('archive')], + ['a folder of another account', () => useEmailStore.getState().selectAccountMailbox('acct-2', 'inbox')], + ['a tag', () => useEmailStore.getState().selectKeyword('$label1')], + ['a unified view', () => useEmailStore.setState({ isUnifiedView: true, unifiedRole: 'inbox' })], + ['a cross-account view', () => useEmailStore.setState({ isUnifiedView: true, crossView: 'unread' })], + ['another account', () => useAuthStore.setState({ activeAccountId: 'acct-2' })], + ['a plugin tab', () => useMessageListTabsStore.setState({ activeTabId: 'newsletters' })], + ['a search', () => useEmailStore.getState().setSearchQuery('invoice')], + ['a search filter', () => useEmailStore.setState({ searchFilters: { ...useEmailStore.getState().searchFilters, from: 'alice@example.com' } })], + // What a click on a folder's unread count does. + ['the unread mail of a folder', () => { + useEmailStore.getState().scopeSearchToOpenFolder(); + useEmailStore.getState().setSearchFilters({ isUnread: true }); + }], + ])('opens %s at the top', (_view, switchView) => { + renderScrolledList(); + act(() => { switchView(); }); + expect(resetToTop()).toBe(true); + }); + + it('opens the scheduled messages at the top', () => { + const view = renderScrolledList(); + view.rerender(); + expect(resetToTop()).toBe(true); + }); + + it('starts over when the scope of a running search changes', () => { + useEmailStore.getState().setSearchQuery('invoice'); + renderScrolledList(); + act(() => { useEmailStore.setState({ searchMailboxId: 'archive' }); }); + expect(resetToTop()).toBe(true); + }); + + it('ends on the mail a link opens along with its folder', () => { + // A layout for the virtualizer: a 300px list of 60px rows. jsdom has + // none, and without one every scroll target clamps to 0. + const mails = Array.from({ length: 30 }, (_, i) => makeEmail(`m${i}`)); + const isList = (el: Element) => (el as HTMLElement).dataset?.tour === 'email-list'; + const stubs = [ + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) { return isList(this) ? 300 : 0; }), + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(300), + vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockImplementation(function (this: Element) { return isList(this) ? mails.length * 60 : 0; }), + vi.spyOn(Element.prototype, 'clientHeight', 'get').mockImplementation(function (this: Element) { return isList(this) ? 300 : 0; }), + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + const height = this.hasAttribute('data-index') ? 60 : 0; + return { x: 0, y: 0, width: 300, height, top: 0, left: 0, right: 300, bottom: height, toJSON: () => ({}) } as DOMRect; + }), + ]; + try { + const view = render(); + scrollTo.mockClear(); + act(() => { + useEmailStore.getState().selectMailbox('archive'); + view.rerender(); + }); + // The reset comes first and the scroll down to the selected mail after + // it - the other way round, the reset would leave the mail out of view. + const tops = scrollTo.mock.calls.map(([options]) => options?.top); + expect(tops).toContain(0); + expect(tops.lastIndexOf(0)).toBeLessThan(tops.length - 1); + expect(tops[tops.length - 1]).toBeGreaterThan(0); + } finally { + stubs.forEach((stub) => stub.mockRestore()); + } + }); + + it('keeps the position through new mail, loading more and a refresh', () => { + const view = renderScrolledList(); + view.rerender(); + view.rerender(); + view.rerender(); + expect(resetToTop()).toBe(false); + }); + + it('keeps the position when a unified view refetches in place (as after "not spam")', () => { + useEmailStore.setState({ isUnifiedView: true, unifiedRole: 'junk' }); + const view = renderScrolledList(); + // The store's own view counter moves on such a refetch; the view does not. + act(() => { useEmailStore.setState((state) => ({ viewToken: state.viewToken + 1, isUnifiedView: true, unifiedRole: 'junk' })); }); + view.rerender(); + expect(resetToTop()).toBe(false); + }); + + it('keeps the position when only the search scope is picked, with no search running', () => { + renderScrolledList(); + act(() => { useEmailStore.setState({ searchMailboxId: 'archive' }); }); + expect(resetToTop()).toBe(false); + }); +}); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index b9c5541a2..359a2817c 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -9,13 +9,14 @@ import { listVerificationCode } from "@/lib/verification-code"; import { EmailContextMenu } from "./email-context-menu"; import { cn } from "@/lib/utils"; import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck } from "@/components/icons"; -import { useState, useEffect, useRef, useCallback, useMemo } from "react"; +import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useEmailStore, ArchiveMailboxNotFoundError } from "@/stores/email-store"; import { useAuthStore } from "@/stores/auth-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; +import { useMessageListTabsStore } from "@/stores/message-list-tabs-store"; import { groupEmailsByThread, sortThreadGroups, threadKeyFor } from "@/lib/thread-utils"; import { useContextMenu } from "@/hooks/use-context-menu"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; @@ -148,6 +149,11 @@ export function EmailList({ // The row opened last stays where it was clicked while that order would // move it (e.g. read in "unread first"). const listHold = useEmailStore((state) => state.listHold); + const viewingAccountId = useEmailStore((state) => state.viewingAccountId); + const selectedKeyword = useEmailStore((state) => state.selectedKeyword); + const searchMailboxId = useEmailStore((state) => state.searchMailboxId); + const activeAccountId = useAuthStore((state) => state.activeAccountId); + const activeTabId = useMessageListTabsStore((state) => state.activeTabId); const threadGroups = useMemo(() => { const listOrder = searchQuery || crossView || !isFilterEmpty(searchFilters) ? [] : fetchedListOrder; @@ -269,6 +275,30 @@ export function EmailList({ entry?.borderBoxSize?.[0]?.blockSize ?? element.getBoundingClientRect().height, }); + // Another folder, tag, account, unified view or plugin tab - or another + // search - opens at the top. The scroll container outlives the switch, so + // the new list would otherwise open wherever the last one was scrolled to. + // Refreshes, new mail and loading more keep the view and so the position. + const searching = !!searchQuery.trim() || !isFilterEmpty(searchFilters); + const viewKey = JSON.stringify([ + activeAccountId, + viewingAccountId, + selectedMailbox, + selectedKeyword, + isUnifiedView && (crossView ?? unifiedRole), + isScheduledView, + activeTabId, + // The scope only matters while a search runs; the dropdown alone does not + // change the list. + searching && [searchQuery, searchFilters, searchMailboxId], + ]); + const shownViewKey = useRef(viewKey); + useLayoutEffect(() => { + if (shownViewKey.current === viewKey) return; + shownViewKey.current = viewKey; + virtualizer.scrollToOffset(0); + }, [viewKey, virtualizer]); + const LoadingSkeleton = () => (
{[...Array(8)].map((_, i) => (