From 290cce571a64647d1efe8d3138e8a51e4e1f70f5 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 4 Oct 2026 20:31:37 -0700 Subject: [PATCH] Keep an open message unread after Mark as unread Marking the open message unread re-ran the mark-read-after-delay timer, which found an expanded unread message and marked it read again: the unread count showed 1 for a moment, then dropped back. With the default delay of 0 it was re-read before the reader could leave the message. ThreadView now remembers which messages have been read while the conversation is open, and the timer leaves those alone until it is opened again. A message that was read here and is unread now was made so on purpose, by this reader or on another device. Mail that arrives unread while the thread is open is still marked read as before. Verified on the mock: on main the unread count never rises after Mark as unread; with this change it rises and stays. The new component test fails on main in both the reported case and after the timer has read the message once. No new strings. Not changed: the report also suggests going back to the folder after Mark as unread. That is a behavior change, left for a separate decision. Fixes #25. Reported by @mbjboon82. --- web/src/views/mail/ThreadView.tsx | 18 ++- .../__tests__/thread-mark-unread.test.tsx | 126 ++++++++++++++++++ 2 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 web/src/views/mail/__tests__/thread-mark-unread.test.tsx diff --git a/web/src/views/mail/ThreadView.tsx b/web/src/views/mail/ThreadView.tsx index 44783a8..08f9f9d 100644 --- a/web/src/views/mail/ThreadView.tsx +++ b/web/src/views/mail/ThreadView.tsx @@ -119,6 +119,22 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h for (const m of messages) if (!m.keywords.$seen) unreadAtOpen.current.ids.add(m.id); const wasUnread = unreadAtOpen.current.ids; + /* + * Which messages have been read while this conversation was open. + * + * Mark as unread on an open message used to undo itself: clearing `$seen` + * re-ran the timer below, which found an expanded unread message and marked + * it read again a moment later (Gitea issue #25). A message that has been + * read here and is unread again was made so on purpose -- by this reader or + * on another device -- so the timer leaves it alone until the conversation + * is opened again. Mail that arrives unread was never read here, and is + * still marked read as before. Accumulated during render, like the set + * above. + */ + const readWhileOpen = useRef<{ key: Id | null; ids: Set }>({ key: null, ids: new Set() }); + if (readWhileOpen.current.key !== threadKey) readWhileOpen.current = { key: threadKey, ids: new Set() }; + for (const m of messages) if (m.keywords.$seen) readWhileOpen.current.ids.add(m.id); + // Default expansion: unread when opened + last message expanded, others collapsed const lastId = messages[messages.length - 1]?.id; const isExpanded = useCallback( @@ -133,7 +149,7 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h // Mark as read after delay useEffect(() => { if (!messages.length) return; - const unread = messages.filter((e) => !e.keywords.$seen && isExpanded(e)).map((e) => e.id); + const unread = messages.filter((e) => !e.keywords.$seen && isExpanded(e) && !readWhileOpen.current.ids.has(e.id)).map((e) => e.id); if (!unread.length || settings.markReadDelay < 0) return; if (markTimer.current) window.clearTimeout(markTimer.current); markTimer.current = window.setTimeout(() => void useMail.getState().markRead(unread, true), settings.markReadDelay * 1000); diff --git a/web/src/views/mail/__tests__/thread-mark-unread.test.tsx b/web/src/views/mail/__tests__/thread-mark-unread.test.tsx new file mode 100644 index 0000000..a174db6 --- /dev/null +++ b/web/src/views/mail/__tests__/thread-mark-unread.test.tsx @@ -0,0 +1,126 @@ +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; + +Element.prototype.scrollIntoView = () => {}; +globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver; +window.matchMedia = ((q: string) => ({ matches: false, media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia; + +/* + * Mark as unread on an open message undid itself: the unread count showed 1 + * for a moment, then the message was read again (Gitea issue #25). Clearing + * `$seen` re-ran the mark-read timer, which found an open unread message and + * did its job. + */ + +const msg = (id: Id, seen: boolean, receivedAt: string): Email => + ({ + id, threadId: "t1", subject: "Hello", mailboxIds: { inbox: true }, keywords: seen ? { $seen: true } : {}, + from: [{ name: "Ann", email: "ann@example.com" }], to: [{ name: "Me", email: "me@example.org" }], + receivedAt, 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; + +describe("marking an open message unread", () => { + let host: HTMLDivElement; + let root: Root; + let markRead: ReturnType; + + const setEmails = async (emails: Email[]) => { + await act(async () => { + useMail.setState({ + threads: { t1: { id: "t1", emailIds: emails.map((e) => e.id) } } as never, + emails: Object.fromEntries(emails.map((e) => [e.id, e])) as never, + fullIds: Object.fromEntries(emails.map((e) => [e.id, true])) as never, + }); + }); + }; + + const show = async () => { + const actions = {} as unknown as ListActions; + await act(async () => { + root.render( + undefined} onNavigate={() => undefined} hasPrev={false} hasNext={false} + />, + ); + }); + }; + + const runTimers = async () => { + await act(async () => { + vi.runOnlyPendingTimers(); + }); + }; + + beforeEach(() => { + vi.useFakeTimers(); + markRead = vi.fn(async () => undefined); + useMail.setState({ + accountId: "a1", + loadingThreads: {} as never, + mailboxes: { inbox: { id: "inbox", name: "Inbox", role: "inbox" } } as never, + loadThread: (async () => undefined) as never, + setOpenThread: (() => undefined) as never, + markRead: markRead as never, + roleId: (() => null) as never, + }); + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.useRealTimers(); + }); + + it("leaves it unread instead of marking it read again", async () => { + await setEmails([msg("m1", true, "2026-10-01T10:00:00Z")]); + await show(); + await runTimers(); + expect(markRead).not.toHaveBeenCalled(); + + // What the button does: the server clears $seen and the store follows. + await setEmails([msg("m1", false, "2026-10-01T10:00:00Z")]); + await runTimers(); + expect(markRead).not.toHaveBeenCalled(); + }); + + it("still marks a message read that was unread when the thread opened", async () => { + await setEmails([msg("m1", false, "2026-10-01T10:00:00Z")]); + await show(); + await runTimers(); + expect(markRead).toHaveBeenCalledWith(["m1"], true); + }); + + it("does not re-mark it after the timer has already read it once", async () => { + // Opened unread, read by the timer, then marked unread by the reader. + await setEmails([msg("m1", false, "2026-10-01T10:00:00Z")]); + await show(); + await runTimers(); + expect(markRead).toHaveBeenCalledTimes(1); + await setEmails([msg("m1", true, "2026-10-01T10:00:00Z")]); + await setEmails([msg("m1", false, "2026-10-01T10:00:00Z")]); + await runTimers(); + expect(markRead).toHaveBeenCalledTimes(1); + }); + + it("still marks a reply read that arrives while the thread is open", async () => { + await setEmails([msg("m1", true, "2026-10-01T10:00:00Z")]); + await show(); + await setEmails([msg("m1", true, "2026-10-01T10:00:00Z"), msg("m2", false, "2026-10-01T11:00:00Z")]); + await runTimers(); + expect(markRead).toHaveBeenCalledWith(["m2"], true); + }); +}); -- 2.54.0