mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
fix: read a conversation oldest first on the phone too, with the newest message open
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user