diff --git a/components/email/__tests__/thread-conversation-order.test.tsx b/components/email/__tests__/thread-conversation-order.test.tsx new file mode 100644 index 000000000..c01610280 --- /dev/null +++ b/components/email/__tests__/thread-conversation-order.test.tsx @@ -0,0 +1,48 @@ +// mail-p2-6: the phone listed a conversation newest first while every other +// layout reads it oldest first. It now reads oldest first too, with the +// newest message (your own reply as well) open at the bottom. +import { describe, expect, it, vi } from 'vitest'; +import { render } from '@testing-library/react'; + +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false })); +vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false })); +vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + +const { ThreadConversationView } = await import('@/components/email/thread-conversation-view'); + +const mail = (n: number, extra: Record = {}) => ({ + id: `e${n}`, threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: `Sender ${n}`, email: `s${n}@t.example` }], to: [{ email: 'me@example.org' }], + subject: 'Lunch', receivedAt: `2026-10-2${n}T10:00:00Z`, sentAt: `2026-10-2${n}T10:00:00Z`, size: 1, preview: `hi ${n}`, hasAttachment: false, + htmlBody: [], textBody: [], attachments: [], bodyValues: {}, ...extra, +}); + +function renderView(emails: ReturnType[]) { + return render( + {}} + mailboxRole="inbox" + />, + ); +} + +const order = (c: HTMLElement) => [...c.querySelectorAll('[data-thread-email-id]')].map((e) => e.getAttribute('data-thread-email-id')); + +describe('phone conversation order', () => { + it('lists the messages oldest first, as the thread arrives newest first', () => { + const { container } = renderView([mail(3), mail(2), mail(1)]); + expect(order(container)).toEqual(['e1', 'e2', 'e3']); + }); + + it('opens the newest message, your own reply too, and not a draft after it', () => { + const mine = mail(3, { from: [{ name: 'Me', email: 'me@example.org' }], mailboxIds: { sent: true } }); + const draft = mail(4, { keywords: { $draft: true, $seen: true } }); + const { container } = renderView([draft, mine, mail(2), mail(1)]); + const expanded = [...container.querySelectorAll('[data-thread-email-id]')] + .filter((el) => el.querySelector('[aria-expanded="true"]')) + .map((el) => el.getAttribute('data-thread-email-id')); + expect(expanded).toEqual(['e3']); + }); +}); diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index eb6c91992..cb3480049 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -204,7 +204,11 @@ export function ThreadConversationView({ }: ThreadConversationViewProps) { const t = useTranslations(); const externalContent = useConversationExternalContent(); - const emailIdList = emails.map((email) => email.id).join(','); + // Oldest first, the newest at the bottom, as on every other layout and in + // Gmail (mail-p2-6). The thread arrives newest first. + const ordered = useMemo(() => oldestFirst(emails), [emails]); + const newestId = newestReadable(ordered)?.id ?? null; + const emailIdList = ordered.map((email) => email.id).join(','); const { isMobile: summaryOnPhone, isCompact: summaryOnTablet } = useDeviceDetection(); const threadSummaryProps = useMemo( () => ({ threadId: thread.latestEmail.threadId ?? null, emailIds: emailIdList ? emailIdList.split(',') : [], deviceType: summaryOnPhone ? 'mobile' : summaryOnTablet ? 'tablet' : 'desktop' }), @@ -213,17 +217,19 @@ export function ThreadConversationView({ // Track which emails are expanded (most recent by default) const [expandedIds, setExpandedIds] = useState>(new Set()); + const cardsRef = useRef(null); + const scrolledThreadRef = useRef(null); - // Auto-expand most recent email AND all unread emails when thread opens + // Auto-expand the newest email (your own reply too) AND all unread emails + // when the thread opens. useEffect(() => { - if (emails.length > 0) { + if (ordered.length > 0) { const idsToExpand = new Set(); - // Always expand most recent - idsToExpand.add(emails[0].id); + if (newestId) idsToExpand.add(newestId); // Also expand all unread emails - emails.forEach(email => { + ordered.forEach(email => { if (!email.keywords?.$seen) { idsToExpand.add(email.id); } @@ -231,10 +237,21 @@ export function ThreadConversationView({ setExpandedIds(idsToExpand); } - }, [emails]); + }, [ordered, newestId]); + + // Once per conversation, show the newest message: it is at the bottom. + useEffect(() => { + const threadId = ordered[0]?.threadId ?? null; + if (!newestId || !threadId || scrolledThreadRef.current === threadId) return; + scrolledThreadRef.current = threadId; + const frame = requestAnimationFrame(() => { + cardsRef.current?.querySelector(`[data-thread-email-id="${CSS.escape(newestId)}"]`)?.scrollIntoView?.({ block: 'start' }); + }); + return () => cancelAnimationFrame(frame); + }, [ordered, newestId]); const toggleExpanded = (emailId: string) => { - const target = emails.find((e) => e.id === emailId); + const target = ordered.find((e) => e.id === emailId); if (target) emitThreadExpand(target.threadId, [target], !expandedIds.has(emailId)); setExpandedIds(prev => { const next = new Set(prev); @@ -279,13 +296,13 @@ export function ThreadConversationView({ {/* Email Cards */}
-
- {emails.map((email, index) => ( +
+ {ordered.map((email) => ( +
toggleExpanded(email.id)} onAllowExternal={() => externalContent.allow(email.id)} @@ -298,6 +315,7 @@ export function ThreadConversationView({ onMarkAsRead={onMarkAsRead} mailboxRole={mailboxRole} /> +
))}
@@ -305,6 +323,20 @@ export function ThreadConversationView({ ); } +/** A conversation's messages from the oldest to the newest. */ +export function oldestFirst(emails: Email[]): Email[] { + return [...emails].sort((a, b) => (Date.parse(a.receivedAt) || 0) - (Date.parse(b.receivedAt) || 0)); +} + +/** The newest message that isn't a draft or waiting to be sent. */ +export function newestReadable(ordered: Email[]): Email | undefined { + for (let i = ordered.length - 1; i >= 0; i--) { + const e = ordered[i]; + if (!e.keywords?.$draft && !e.isScheduled) return e; + } + return ordered[ordered.length - 1]; +} + // Individual email card component interface EmailCardProps { email: Email;