Merge pull request #81 from LINUXexpert-org/fix-thread-collapse

Stop the reading view rearranging itself as you read
This commit is contained in:
LINUXexpert.org
2026-08-26 15:33:59 -07:00
committed by GitHub
2 changed files with 37 additions and 5 deletions
+7 -2
View File
@@ -28,12 +28,14 @@ import { sendReadReceipt } from "@/store/mdn";
interface Props { interface Props {
email: Email; email: Email;
expanded: boolean; expanded: boolean;
/** Unread when the conversation was opened, which is what the bar marks. */
wasUnread?: boolean;
onToggle: () => void; onToggle: () => void;
isLast: boolean; isLast: boolean;
actions: ListActions; actions: ListActions;
} }
export const MessageView = memo(function MessageView({ email: e, expanded, onToggle, actions }: Props) { export const MessageView = memo(function MessageView({ email: e, expanded, wasUnread, onToggle, actions }: Props) {
const accountId = useMail((s) => s.accountId)!; const accountId = useMail((s) => s.accountId)!;
const settings = useSettings((s) => s.settings); const settings = useSettings((s) => s.settings);
const updateSettings = useSettings((s) => s.update); const updateSettings = useSettings((s) => s.update);
@@ -134,7 +136,10 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog
}; };
return ( return (
<article className={`message ${expanded ? "" : "collapsed"} ${!e.keywords.$seen ? "unread-msg" : ""}`} data-msg-id={e.id} onClick={collapsedClick}> /* `wasUnread` rather than `$seen`: the bar marks what was unread when the
conversation was opened, and keeps marking it after the auto-mark-read
timer has told the server otherwise. Losing it mid-read was half of #69. */
<article className={`message ${expanded ? "" : "collapsed"} ${wasUnread ?? !e.keywords.$seen ? "unread-msg" : ""}`} data-msg-id={e.id} onClick={collapsedClick}>
<header className="message-head" onClick={(ev) => { if (expanded && !(ev.target as HTMLElement).closest("button,a,.message-details")) onToggle(); }}> <header className="message-head" onClick={(ev) => { if (expanded && !(ev.target as HTMLElement).closest("button,a,.message-details")) onToggle(); }}>
<Avatar who={from ?? null} /> <Avatar who={from ?? null} />
<div className="who"> <div className="who">
+30 -3
View File
@@ -65,15 +65,41 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
return (filtered.length ? filtered : all).sort((a, b) => a.receivedAt.localeCompare(b.receivedAt)); return (filtered.length ? filtered : all).sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
}, [thread, emails, fullIds, mailboxId]); }, [thread, emails, fullIds, mailboxId]);
// Default expansion: unread + last message expanded, others collapsed /*
* Which messages were unread when this conversation was opened.
*
* Expansion and the unread bar used to read `$seen` directly, so the moment
* the auto-mark-read timer fired, every message expanded *because* it was
* unread collapsed again -- all but the last -- and the only record of which
* ones they were disappeared with them (#69). Opening a thread with several
* unread messages gave you a few seconds before the view rearranged itself
* underneath you.
*
* Marking read on the server is still right: opening the thread is the signal
* that you are reading it. What was wrong was letting that change the shape
* of what you are looking at. The set only ever grows while a thread is open
* -- a message that arrives unread joins it -- and is discarded on the way to
* another thread.
*
* Accumulated during render rather than in an effect because it is derived
* purely from `messages`, and adding an id twice does nothing. An effect
* would repaint a frame later, which is the flicker this exists to remove.
*/
const threadKey = thread?.id ?? null;
const unreadAtOpen = useRef<{ key: Id | null; ids: Set<Id> }>({ key: null, ids: new Set() });
if (unreadAtOpen.current.key !== threadKey) unreadAtOpen.current = { key: threadKey, ids: new Set() };
for (const m of messages) if (!m.keywords.$seen) unreadAtOpen.current.ids.add(m.id);
const wasUnread = unreadAtOpen.current.ids;
// Default expansion: unread when opened + last message expanded, others collapsed
const lastId = messages[messages.length - 1]?.id; const lastId = messages[messages.length - 1]?.id;
const isExpanded = useCallback( const isExpanded = useCallback(
(e: Email) => { (e: Email) => {
if (e.id in expanded) return expanded[e.id]!; if (e.id in expanded) return expanded[e.id]!;
if (allExpanded) return true; if (allExpanded) return true;
return !e.keywords.$seen || e.id === lastId || messages.length === 1; return wasUnread.has(e.id) || e.id === lastId || messages.length === 1;
}, },
[expanded, allExpanded, lastId, messages.length], [expanded, allExpanded, lastId, messages.length, wasUnread],
); );
// Mark as read after delay // Mark as read after delay
@@ -190,6 +216,7 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
key={e.id} key={e.id}
email={e} email={e}
expanded={isExpanded(e)} expanded={isExpanded(e)}
wasUnread={wasUnread.has(e.id)}
onToggle={() => setExpanded((x) => ({ ...x, [e.id]: !isExpanded(e) }))} onToggle={() => setExpanded((x) => ({ ...x, [e.id]: !isExpanded(e) }))}
isLast={i === messages.length - 1} isLast={i === messages.length - 1}
actions={actions} actions={actions}