Seven app fixes from public ihasmail #25
No files matched your search
@@ -228,7 +228,23 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
|
||||
}, [messages, reply]);
|
||||
|
||||
const subject = messages[0]?.subject || emails[thread?.emailIds[0] ?? ""]?.subject || "(no subject)";
|
||||
const rowIds = thread ? thread.emailIds.filter((id) => emails[id]) : [];
|
||||
/*
|
||||
* What the toolbar acts on: the messages the pane is showing, not the thread
|
||||
* they belong to.
|
||||
*
|
||||
* With conversation view off, opening a message opens that message -- the
|
||||
* list shows it alone, the pane renders it alone, and the buttons above it
|
||||
* said so, because `anyUnread` and the rest already read `messages`. Only the
|
||||
* ids handed to the action still named the whole thread, so Mark as unread,
|
||||
* Move to, Report spam and Delete quietly took every message in it (#414).
|
||||
*
|
||||
* Same fallback as the pane's: an id naming nothing in this thread means the
|
||||
* whole conversation, so the buttons keep matching what is on screen.
|
||||
*/
|
||||
const rowIds = useMemo(() => {
|
||||
const loaded = thread ? thread.emailIds.filter((id) => emails[id]).map((id) => ({ id })) : [];
|
||||
return visibleMessages(loaded, messageId).map((m) => m.id);
|
||||
}, [thread, emails, messageId]);
|
||||
const anyUnread = messages.some((e) => !e.keywords.$seen);
|
||||
const anyStarred = messages.some((e) => e.keywords.$flagged);
|
||||
const inJunk = Boolean(mailboxId && mailboxes[mailboxId]?.role === "junk");
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ThreadView } from "../ThreadView";
|
||||
import { useMail } from "@/store/mail";
|
||||
import type { ListActions } from "../MessageList";
|
||||
import type { Email, Id } from "@/jmap/types";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
/* jsdom has neither of these, and the opening scroll uses both. */
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver;
|
||||
|
||||
/* jsdom has no matchMedia, and the toolbar asks whether this is a phone. */
|
||||
window.matchMedia = ((q: string) => ({ matches: false, media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
|
||||
|
||||
/*
|
||||
* Reported from the inbox with conversation view off: marking a message unread
|
||||
* from the list -- hover button, right-click menu -- touched that message, but
|
||||
* the same action from the toolbar above the *opened* message marked every
|
||||
* message in its thread. Move to, Report spam and Delete did it too (#414).
|
||||
*
|
||||
* The toolbar's labels were already right: "Mark as unread" read the messages
|
||||
* on screen. Only the ids it handed the action named the whole thread.
|
||||
*/
|
||||
|
||||
const msg = (id: Id, subject: string): Email =>
|
||||
({
|
||||
id, threadId: "t1", subject, mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: "Ann", email: "[email protected]" }], to: [{ name: "Me", email: "[email protected]" }],
|
||||
receivedAt: "2026-09-20T10:00:00Z", size: 10, blobId: "b1", preview: "hi",
|
||||
htmlBody: [], textBody: [{ partId: "1", type: "text/plain" }],
|
||||
bodyValues: { "1": { value: "hi", isEncodingProblem: false, isTruncated: false } },
|
||||
attachments: [],
|
||||
}) as unknown as Email;
|
||||
|
||||
const FIRST = msg("m1", "The question");
|
||||
const SECOND = msg("m2", "Re: The question");
|
||||
|
||||
function stubStore() {
|
||||
useMail.setState({
|
||||
accountId: "a1",
|
||||
threads: { t1: { id: "t1", emailIds: ["m1", "m2"] } } as never,
|
||||
emails: { m1: FIRST, m2: SECOND } as never,
|
||||
fullIds: { m1: true, m2: true } as never,
|
||||
loadingThreads: {} as never,
|
||||
mailboxes: { inbox: { id: "inbox", name: "Inbox", role: "inbox" } } as never,
|
||||
loadThread: (async () => undefined) as never,
|
||||
setOpenThread: (() => undefined) as never,
|
||||
markRead: (async () => undefined) as never,
|
||||
roleId: (() => null) as never,
|
||||
});
|
||||
}
|
||||
|
||||
describe("what the toolbar above an opened message acts on", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
let actions: ListActions;
|
||||
|
||||
const show = async (messageId: Id | null) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ThreadView
|
||||
threadId="t1" mailboxId="inbox" messageId={messageId} actions={actions}
|
||||
onBack={() => undefined} onNavigate={() => undefined} hasPrev={false} hasNext={false}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/** The toolbar buttons carry their shortcut in the title, as the tooltips show. */
|
||||
const press = async (title: string) => {
|
||||
const btn = [...host.querySelectorAll("button")].find((b) => b.title === title);
|
||||
expect(btn, `no toolbar button titled ${title}`).toBeTruthy();
|
||||
await act(async () => btn!.click());
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
stubStore();
|
||||
actions = {
|
||||
archive: vi.fn(async () => undefined), trash: vi.fn(async () => undefined),
|
||||
spam: vi.fn(async () => undefined), read: vi.fn(async () => undefined),
|
||||
star: vi.fn(async () => undefined), move: vi.fn(async () => undefined),
|
||||
label: vi.fn(async () => undefined),
|
||||
} as unknown as ListActions;
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("marks only the message that is open, not its thread", async () => {
|
||||
await show("m1");
|
||||
await press("Mark as unread");
|
||||
expect(actions.read).toHaveBeenCalledWith(false, ["m1"]);
|
||||
});
|
||||
|
||||
it("moves, reports and deletes only that message too", async () => {
|
||||
await show("m1");
|
||||
await press("Move to (v)");
|
||||
await press("Report spam (!)");
|
||||
await press("Delete (#)");
|
||||
expect(actions.move).toHaveBeenCalledWith(["m1"]);
|
||||
expect(actions.spam).toHaveBeenCalledWith(["m1"]);
|
||||
expect(actions.trash).toHaveBeenCalledWith(["m1"]);
|
||||
});
|
||||
|
||||
it("takes the whole thread when the pane is showing the whole thread", async () => {
|
||||
// Conversation view on: no message singled out, and the toolbar is the
|
||||
// conversation's toolbar. That is the behaviour this must not disturb.
|
||||
await show(null);
|
||||
await press("Mark as unread");
|
||||
expect(actions.read).toHaveBeenCalledWith(false, ["m1", "m2"]);
|
||||
});
|
||||
|
||||
it("falls back to the thread when the open id names nothing in it", async () => {
|
||||
// A link from somebody with conversation view on, or a stale `m` in the
|
||||
// URL. The pane shows the conversation, so the toolbar acts on it.
|
||||
await show("gone");
|
||||
await press("Mark as unread");
|
||||
expect(actions.read).toHaveBeenCalledWith(false, ["m1", "m2"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user