With Conversation view switched off, the list correctly showed individual messages — but clicking one highlighted every row in its thread and filled the reading pane with the whole conversation. Reported from the inbox, reproduced in prod.
Cause
conversationMode was consulted in exactly two places (MailView.tsx:89 and :96), both setting collapseThreads on the JMAP query. It changed what the server returned and nothing about what the client did afterwards.
Where
Was
MessageList.tsx
open={openThreadId === e.threadId} — matched the thread, so siblings lit up
MailView.tsx
rendered <ThreadView> unconditionally
MailView.tsx
empty state hardcoded "conversation"/"conversations" — still said "62 conversations" with the setting off
Fix
The two rules behind it now live together in lib/openMessage.ts, since they're the same question asked in two places: rowIsOpen() and visibleMessages().
The thread id stays in the path and loading is unchanged — one request, and reply/forward keep the context they need. The opened message rides in ?m=. Keeping it in the URL rather than in memory is what makes a reload or a shared link land back on the same message.
An m that names nothing in the thread falls back to the whole conversation. That covers both a link shared by someone whose conversation view is on and a stale parameter after switching the setting back — a conversation beats an empty pane for both.
currentRowIndex also learned about it, so a cold load puts j/k and scroll-into-view on the right row rather than whichever message of the thread sorts first.
With the setting on, every path is byte-identical to before.
Translation
Nine catalogues gain two strings — No message selected and Select a message to read it here · Press {key} for shortcuts — written to match each catalogue's existing register and the noun it already uses for a message (Nachricht / mensaje / message / bericht / mensagem / письмо / лист / メール / 邮件).
{n} messages was already present in all nine with correct plural forms, so the counter needed nothing.
Checks
npm run typecheck — clean, both packages
npm test — 1168 web tests / 115 files, 168 server tests, all passing
npm run build — clean
8 new tests covering both rules, including the conversation-view-on path
Unrelated breakage found
Four of the five i18n scripts are broken on main — i18n-catalog-check, i18n-literals, i18n-coverage and i18n-strings all die on ts.ScriptTarget being undefined. TypeScript 7.0.2 changed that export shape; only i18n-extract still runs. Confirmed on a clean checkout, so it predates this branch and is left alone here. Worth its own fix, since it's the gate that would normally check catalogue work like the above.
With **Conversation view** switched off, the list correctly showed individual messages — but clicking one highlighted *every* row in its thread and filled the reading pane with the whole conversation. Reported from the inbox, reproduced in prod.
## Cause
`conversationMode` was consulted in exactly two places (`MailView.tsx:89` and `:96`), both setting `collapseThreads` on the JMAP query. It changed what the server returned and nothing about what the client did afterwards.
| Where | Was |
|---|---|
| `MessageList.tsx` | `open={openThreadId === e.threadId}` — matched the thread, so siblings lit up |
| `MailView.tsx` | rendered `<ThreadView>` unconditionally |
| `MailView.tsx` | empty state hardcoded "conversation"/"conversations" — still said "62 conversations" with the setting off |
## Fix
The two rules behind it now live together in `lib/openMessage.ts`, since they're the same question asked in two places: `rowIsOpen()` and `visibleMessages()`.
The thread id **stays in the path** and loading is unchanged — one request, and reply/forward keep the context they need. The opened message rides in `?m=`. Keeping it in the URL rather than in memory is what makes a reload or a shared link land back on the same message.
An `m` that names nothing in the thread falls back to the whole conversation. That covers both a link shared by someone whose conversation view is *on* and a stale parameter after switching the setting back — a conversation beats an empty pane for both.
`currentRowIndex` also learned about it, so a cold load puts `j`/`k` and scroll-into-view on the right row rather than whichever message of the thread sorts first.
With the setting on, every path is byte-identical to before.
## Translation
Nine catalogues gain **two** strings — `No message selected` and `Select a message to read it here · Press {key} for shortcuts` — written to match each catalogue's existing register and the noun it already uses for a message (Nachricht / mensaje / message / bericht / mensagem / письмо / лист / メール / 邮件).
`{n} messages` was already present in all nine with correct plural forms, so the counter needed nothing.
## Checks
- `npm run typecheck` — clean, both packages
- `npm test` — 1168 web tests / 115 files, 168 server tests, all passing
- `npm run build` — clean
- 8 new tests covering both rules, including the conversation-view-on path
## Unrelated breakage found
Four of the five i18n scripts are broken on `main` — `i18n-catalog-check`, `i18n-literals`, `i18n-coverage` and `i18n-strings` all die on `ts.ScriptTarget` being `undefined`. TypeScript 7.0.2 changed that export shape; only `i18n-extract` still runs. Confirmed on a clean checkout, so it predates this branch and is left alone here. Worth its own fix, since it's the gate that would normally check catalogue work like the above.
**Merged** 2026-09-10 as coffey-labs/ihasmail@2a323d627042
<sub>Rebuilt from: GH Archive, git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
With Conversation view switched off, the list correctly showed individual messages — but clicking one highlighted every row in its thread and filled the reading pane with the whole conversation. Reported from the inbox, reproduced in prod.
Cause
conversationModewas consulted in exactly two places (MailView.tsx:89and:96), both settingcollapseThreadson the JMAP query. It changed what the server returned and nothing about what the client did afterwards.MessageList.tsxopen={openThreadId === e.threadId}— matched the thread, so siblings lit upMailView.tsx<ThreadView>unconditionallyMailView.tsxFix
The two rules behind it now live together in
lib/openMessage.ts, since they're the same question asked in two places:rowIsOpen()andvisibleMessages().The thread id stays in the path and loading is unchanged — one request, and reply/forward keep the context they need. The opened message rides in
?m=. Keeping it in the URL rather than in memory is what makes a reload or a shared link land back on the same message.An
mthat names nothing in the thread falls back to the whole conversation. That covers both a link shared by someone whose conversation view is on and a stale parameter after switching the setting back — a conversation beats an empty pane for both.currentRowIndexalso learned about it, so a cold load putsj/kand scroll-into-view on the right row rather than whichever message of the thread sorts first.With the setting on, every path is byte-identical to before.
Translation
Nine catalogues gain two strings —
No message selectedandSelect a message to read it here · Press {key} for shortcuts— written to match each catalogue's existing register and the noun it already uses for a message (Nachricht / mensaje / message / bericht / mensagem / письмо / лист / メール / 邮件).{n} messageswas already present in all nine with correct plural forms, so the counter needed nothing.Checks
npm run typecheck— clean, both packagesnpm test— 1168 web tests / 115 files, 168 server tests, all passingnpm run build— cleanUnrelated breakage found
Four of the five i18n scripts are broken on
main—i18n-catalog-check,i18n-literals,i18n-coverageandi18n-stringsall die onts.ScriptTargetbeingundefined. TypeScript 7.0.2 changed that export shape; onlyi18n-extractstill runs. Confirmed on a clean checkout, so it predates this branch and is left alone here. Worth its own fix, since it's the gate that would normally check catalogue work like the above.Merged 2026-09-10 as coffey-labs/ihasmail@2a323d6270
Rebuilt from: GH Archive, git history, session transcript.