fix: read a conversation oldest first on the phone too, with the newest message open

This commit is contained in:
Linus Rath
2026-11-04 12:17:27 +01:00
parent 0da07335cc
commit 05555bc88a
2 changed files with 92 additions and 12 deletions
@@ -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<string, unknown> = {}) => ({
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<typeof mail>[]) {
return render(
<ThreadConversationView
thread={{ threadId: 't1', emails, latestEmail: emails[0] } as never}
emails={emails as never}
onBack={() => {}}
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']);
});
});
+44 -12
View File
@@ -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<Set<string>>(new Set());
const cardsRef = useRef<HTMLDivElement>(null);
const scrolledThreadRef = useRef<string | null>(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<string>();
// 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<HTMLElement>(`[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 */}
<div className="flex-1 overflow-y-auto">
<PluginSlot name="thread-summary" className="w-full" extraProps={threadSummaryProps} />
<div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
{emails.map((email, index) => (
<div ref={cardsRef} className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
{ordered.map((email) => (
<div key={email.id} data-thread-email-id={email.id} className="scroll-mt-16">
<EmailCard
key={email.id}
email={email}
isExpanded={expandedIds.has(email.id)}
isLatest={index === 0}
isLatest={email.id === newestId}
allowExternal={externalContent.isAllowed(email)}
onToggleExpanded={() => toggleExpanded(email.id)}
onAllowExternal={() => externalContent.allow(email.id)}
@@ -298,6 +315,7 @@ export function ThreadConversationView({
onMarkAsRead={onMarkAsRead}
mailboxRole={mailboxRole}
/>
</div>
))}
</div>
</div>
@@ -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;