From 7b9da4411606a10700137a333717dabc0fabd648 Mon Sep 17 00:00:00 2001 From: John Coffey <2+jcoffey-dev@noreply.git.coffeylabs.org> Date: Mon, 5 Oct 2026 03:48:28 +0000 Subject: [PATCH] Keep an open message unread after Mark as unread (ihasmail #30) (cherry picked from commit 4a6bf677cbed9ecd23d02c0788c927e0dc9e941a) --- web/src/views/mail/ThreadView.tsx | 20 ++- .../__tests__/thread-mark-unread.test.tsx | 126 ++++++++++++++++++ 2 files changed, 144 insertions(+), 2 deletions(-) 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 46db4f6..ed8eb08 100644 --- a/web/src/views/mail/ThreadView.tsx +++ b/web/src/views/mail/ThreadView.tsx @@ -120,6 +120,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( @@ -136,8 +152,8 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h // changes nothing there, not even $seen const readOnly = useViewingDelegation()?.access === "read"; useEffect(() => { - if (!messages.length || readOnly) return; - const unread = messages.filter((e) => !e.keywords.$seen && isExpanded(e)).map((e) => e.id); +if (!messages.length || readOnly) return; +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); + }); +});