Files
ihasmail/web/src/lib/__tests__/threadScroll.test.ts
T
jcoffey-dev d64249b46d Open a conversation on its first unread message
Selecting a thread put you at the newest message. Anything unread above
that sat off the top of the pane with nothing to announce it, and the
only way to find out was to scroll up -- by which time the auto-mark-read
timer had marked the whole thread read anyway, so scrolling up meant
scrolling up to mail already counted as seen (#87).

Opening at the bottom is right when there is nothing to catch up on and
wrong the moment there is. The pane now opens on the oldest message that
was unread when the thread was opened, and falls back to the newest when
the thread has already been read.

mbunkus's out-of-order case is the one that rules out guessing at a
position. A participant whose server could not connect for hours
delivers a message long after it was written, and it lands in the middle
of a conversation that has already moved past it -- so "second to last",
or any other fixed offset from the end, finds nothing. Reading the
unread set is the only thing that does.

Two cases leave the pane where it is:

  - a single message, which is already the whole pane
  - the first unread being the first message, where the top of the pane
    shows it anyway, together with the subject; scrolling to it would
    push the subject off for nothing

It reads the set captured when the thread was opened rather than live
`$seen` state, for the same reason expansion does (#69): the mark-read
timer must not change the shape of what you are looking at. That also
makes the landing stable, because everything above the first unread
message is a collapsed row of fixed height -- nothing up there reflows
after the scroll.

The mock grows a thread that reproduces it: seven messages with the
unread one second, four more behind it. Verified against it. Opening the
thread lands the unread message flush against the top of the pane at
scrollTop 158; the old scroll to the newest message put it at 445, with
287px of the message -- header, sender and unread bar included -- above
the fold. On a thread whose first message is the unread one the pane
stays at 0 with the subject in view, where before it would have scrolled
333. Once the thread is read, reopening it goes back to the newest
message.
2026-08-27 06:35:26 -07:00

49 lines
1.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { threadScrollTarget } from "@/lib/threadScroll";
/**
* Issue #87: a conversation opened on its newest message, so unread mail sat
* above the fold with nothing to announce it but a marker you had to scroll up
* to see — and the auto-mark-read timer marked it read while you were still
* looking at the bottom of the thread.
*
* The case that makes "second to last" the wrong answer is out-of-order
* delivery: a message sent hours ago but queued on the sender's server arrives
* last and sorts early. Messages here are in the order the pane renders them,
* oldest first, which is receivedAt order.
*/
const thread = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `m${i + 1}` }));
const unread = (...ids: string[]) => new Set(ids);
describe("where a conversation opens", () => {
it("opens on the oldest unread message", () => {
expect(threadScrollTarget(thread(5), unread("m3", "m4"))).toBe("m3");
});
it("opens on an unread message that arrived late and sorted early", () => {
// The one the issue is about: m2 was delivered after m5, so opening at the
// bottom hides it three messages up.
expect(threadScrollTarget(thread(5), unread("m2"))).toBe("m2");
});
it("opens on the newest message when the thread is all read", () => {
expect(threadScrollTarget(thread(5), unread())).toBe("m5");
});
});
describe("when it leaves the pane where it is", () => {
it("stays at the top when the first message is the unread one", () => {
// Scrolling to it would push the subject off the top for nothing.
expect(threadScrollTarget(thread(4), unread("m1", "m3"))).toBeNull();
});
it("does not scroll a single message", () => {
expect(threadScrollTarget(thread(1), unread("m1"))).toBeNull();
});
it("does not scroll an empty thread", () => {
expect(threadScrollTarget([], unread())).toBeNull();
});
});