Batch delete grouped the selection by bare email id, so with the same
id in two accounts, permanent-deleting B's row destroyed A's message
and left B's alone. Copy had the same lookup and went out through the
wrong account.
- resolve the selection to email objects by key, including ticked
members of an expanded thread that are not list rows
- delete, move, mark read, tag and copy group by those objects
- rows, thread checkboxes, drag and drop compare by key, so ticked
rows show as checked in unified and tag views
- the Mountain View toolbar uses the same resolver
The top bar replaces the navigation rail on desktop, and with it went
the shield that takes a Stalwart admin to the console, so an admin under
Mountain View had no way in. The bar now shows it, with the same update
dot. The check that decides it moves out of the rail into
useStalwartAdmin so both use the same one.
Calendar, contacts, files and settings still drew the left navigation
rail under Mountain View, so moving away from mail switched to the
default frame. They now show the Mountain View top bar too, without the
mail search and the menu button, which only make sense in mail.
The browser reads document.title back with its whitespace stripped and
collapsed, so a title with two spaces in a row, a tab or a space at the
edge never reads back as written. useDocumentTitle put its title back on
every <head> mutation whenever the two differed, and its own write was
such a mutation: an endless loop that froze the tab at full CPU, for
example on opening a message whose subject has two spaces in a row. It
now also accepts what the browser read back after its own write.
The tab title was "<context> - <app>", so two mailboxes open side by side
read the same in the tab strip and in the browser's session-restore list.
It is now "<context> - <account> - <app>", the order Gmail and Outlook Web
use: browsers cut a long title from the end, so the part that changes comes
first and the app name last. There is no setting for it; the account is what
tells the tabs apart.
The formatting, and the mail view's choice of context (composer, then
message, then mailbox), live in lib/tab-title.ts with tests. The mail view
sets its title through useDocumentTitle(), which other surfaces can use too.
Next renders the root layout's metadata <title> again on every client-side
navigation, after the surface has mounted; the hook puts its title back when
that happens. Only the surface that set the title last does so, so two
titles never fight.
Refs #1159
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add an "Interface layout" setting at the top of Settings > Layout with two
options, Default and Mountain View. Default is the existing layout and stays
the default, so nothing changes unless a user opts in.
Mountain View rearranges the chrome while keeping the theme's colours, icons
and type:
- desktop: a top bar with a wide search field (filters drop from the field),
an apps grid and the account avatar instead of the left app rail; flat
navigation with Starred under Inbox and the rest behind "More"; a Compose
pill; one list toolbar with select-all, refresh, overflow menu, a 1-N of
total range and the batch actions; rows led by a checkbox and a star; an
icon-only message toolbar with Reply / Reply all / Forward under the
message; compose in a docked window; an account popover with per-account
unread counts; j/k cursor, x to select and g-sequences
- phone: rounded search header, compact rows with the star on the preview
line, and Reply / Reply all / Forward at the end of a message instead of
the bottom action bar
It is one setting (`interfaceLayout`), written to a data attribute on <html>
that keys a block of CSS tokens in globals.css, plus a few components gated
on the flag. The mail layout in force is derived (effectiveMailLayout), so
the user's saved mail layout is never rewritten. The setting is listed in
the admin policy tab, and the new strings are translated in all locales.
Includes unit tests and a Playwright smoke test against a running instance
(scripts/mountain-view-smoke/).
useFocusTrap started over whenever its onEscape changed, and callers pass
a new function on every render. Each start moved the focus back to the
opener and then to the dialog's first element, so a field lost the focus
whenever the page behind rendered again: every 30 seconds through the
connection check, or when new mail updated the folder counts. The trap
now reads the latest onEscape from a ref and starts once per opening.
A view change in a dialog (a list giving way to a form) can remove the
element that had the focus. The restart used to pull it back by
accident; the trap now does that on purpose, and only when the focused
element was removed.
The folder-list cache from #1132 published a reused list only when it
was freshly fetched. When the Pro folder tree had loaded a login first,
the unified build reused that list and never stored it under the
login's JMAP id, so trash and archive for that login's mail were
resolved against the active login's folders until it pushed a change.
A reused list now fills any key nothing has published yet, without
overwriting a live one.
The Pro folder tree also marked a login as loaded before its first
fetch finished and never retried a failure, which left that login's
tree empty until an account switch. A failed load is now retried on
the next run, as the identities hook already does.
Two effects keyed on the account list reloaded every connected login each
time the list changed - and while a browser restores its logins it changes
once per connection, so the cost was 1 + 2 + … + N:
- useProMultiAccountMailboxes, which the Pro shell runs for its per-account
folder tree, reloaded every login's folders: 55 Mailbox/get with ten logins.
It now loads each login once per client, through the same cache as the
unified scope, and reloads all of them once on an account switch.
- useProMultiAccountIdentities reread every other login's identities: 45
Identity/get. Each mount now reads each account once; what is already
known shows at once, and a read that outlives the run that started it
still reaches the dropdown.
Both are keyed on the set of connected logins instead of `accounts`, which
also changes for display-name and login-time updates.
Resolve against the explicit batch targets upstream added for filter rules:
a targeted call names its messages and their account itself, the user's
selection stays keyed by owning account. Also key the context menu's
multi-select check and the rule targets, which compared a selection key
with a bare email id.
Retry interrupted or invalid runtime configuration requests with bounded backoff and per-attempt timeouts. Validate the complete response before caching it, and prevent stale HTTP-cached configuration.
Cover request and body timeouts, shared hook consumers, retry bounds, the server response contract, and both Lite configuration paths.
Behind a reverse proxy at a sub-path (NEXT_PUBLIC_BASE_PATH, e.g.
/webmail) both WOPI requests used a bare fetch('/api/wopi/...') and
reached the mail server instead of the webmail. The status probe came
back 404, so office editing always looked unavailable there - and since
#1047 the probe runs for every opened message, logging the 404 each
time. Route both through apiFetch like every other API call, and cover
the sub-path mount with a test.
Email ids are unique only within their account, so in a view that merges
several - the unified mailbox, and since #1038 the tag view - two
unrelated messages can share one. Selection was a set of bare ids
resolved with `emails.filter(e => selected.has(e.id))`, so ticking one
row also ticked its namesake in another account, and batch delete, move,
archive or mark-read then acted on a message nobody selected.
Reported by @alyyousuf7 on #1013, where `eiaaaabc` exists in two
accounts at once.
Selection is now keyed by `emailKeyFor` - the same account scoping
#1013 introduced for threads - and the actions take the email rather
than its id, so a caller cannot reintroduce the collision by passing a
bare id. Unstamped emails keep the bare id as their key, so
single-account views are unchanged.
The batch actions derive the JMAP ids from the selected emails
themselves instead of looking each id up again, which is where the
namesake used to ride along, and every place that pruned the selection
after a delete or a move now prunes by key.
In the unified view each row can be shaded with its account colour
instead of carrying an account dot, behind a layout setting. Tags keep
their own generated hue on their chips.
Resolves the two conflicts in components/calendar/event-modal.tsx created
by #967 (RSVP controls), both keep-both:
- #967's canRsvp memo and #986's resolveContactName hook are both kept;
they are independent and were simply inserted at the same point.
- #967's RsvpBar component is kept, and StatusBadge takes #986's
signature ({ status, t }) since #986 drops the isOrganizer prop and
updates both call sites to match.
The calendar loads the principal's alias addresses so an alias-organised
event is recognised as the user's own. The fetch fired on mount, but after
a page load the JMAP client is restored asynchronously: the call failed
with "Not authenticated" in the console, and the once-only guard then
blocked the retry for the rest of the session, leaving the aliases empty.
Move the logic into useAccountPrincipalEmails, which waits for the client
and fetches once, and cover the sequence with a hook test.
The organizer of a self-created event still rendered as a bare address
(#738 follow-up): the event stores the organizer under the server's
node domain while the name lives elsewhere — the account/principal
name, the identity From-name, or a contact card on the account's
primary domain. Exact contact matching alone never hit.
useContactNameResolver now resolves in layers, first hit wins:
1. Contact card holding the exact address (curated data outranks all).
2. Contact card with the same local part on a *related* domain — one
domain extends the other at a "."/"-" label boundary
(node-example.com vs example.com, mail.example.com vs example.com).
Applied only when the related cards agree on a single name.
3. The account display name (live principal "Full name") for the
user's own addresses.
4. The identity From-name (a one-time snapshot of the same, so it only
serves when the account layer had nothing).
Name sources that just echo the address itself are skipped — they add
nothing over the bare address renderers already show.
Co-Authored-By: Claude <noreply@anthropic.com>
Three bugs when viewing an event's participants (#738):
1. Phantom participant. Stalwart can emit the organizer both as the
ORGANIZER-derived participant and as an ATTENDEE line, so the same
address rendered twice — "4 participants" for three people.
getParticipantList() now folds same-address entries into the
first-seen one (real RSVP beats needs-action on merge), and
getParticipantCount() counts addresses so event-card and agenda
badges agree.
2. Bare email for a known contact. The organizer's display name is
dropped on Stalwart's iCalendar round-trip, so the organizer
rendered as a bare address despite having a contact card with a
备注 name. New useContactNameResolver() hook fills missing
participant names from the contact store (fill-only — event data
stays authoritative), threaded through the panel, the hover
popover, the RSVP view, and the organizer name written on save.
3. Organizer status. The organizer with no explicit status read as
待处理; they owe no reply, so they now display as accepted, and the
panel's status badge no longer repeats the organizer label the
(组织者) suffix already carries.
Co-Authored-By: Claude <noreply@anthropic.com>
Adds useAppBadge alongside the existing useFaviconBadge, mounted from the
same FaviconBadge component and gated by the same faviconUnreadBadge
setting. Targets the separate icon Chrome/Edge/WebKit show for an
installed PWA (taskbar/dock/Home Screen/pinned-app-shortcut), which does
not react to favicon changes at all - a merely pinned browser tab is
unaffected, only an actually installed app.
No unmount cleanup: the badge is scoped to the installed app/origin, not
to this component or window, so clearing on every unmount would blank
another still-open window's accurate badge. A count of zero or the
setting being off already clears it through the main effect.