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.
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>
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.
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.
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.
Adds Gmail-style horizontal swipe actions to message rows on touch
devices. Swiping a row past a threshold fires a configurable action and
snaps back; a coloured background with an icon is revealed under the
finger and grows when the release would commit.
- New `useSwipeActions` hook: claims a touch as a swipe only once the
horizontal travel dominates (vertical stays native list scrolling via
`touch-action: pan-y`, no preventDefault), resists past the commit
distance, and suppresses the tap that would otherwise follow.
- Two settings (Settings -> reading/email behavior): "Swipe right
action" (default Archive) and "Swipe left action" (default Delete),
each one of none / archive / delete / toggle read / toggle star /
spam. A direction set to an action the current folder does not expose
simply does nothing.
- Composes with the existing long-press (context menu) handlers; desktop
is unchanged (hover actions + right-click).
- Scoped to single-message rows; unit tests cover commit/short-drag/
no-action-side/vertical-scroll/tap-suppression.
Previously tags where very much focused on color coding email and less about
adding additional information. They were also visualized in different ways in
different locations.
This commit gets rid of all "Color-coding" references, aligns visualization of
the tags across the whole project and tries to improve user experience of using
tags in general.
A search box is shown in the tagging control so the user can quickly search for
a tag if they have a huge (more than 10) amount of tags.
- levels are joined by forward slashes in the keywords
- behaviour is opt-in for now
- long paths are shortened if there is not enough display room
Closes#687.
Closes#560.
Composes the active inbox's unread count over the base favicon as an SVG
badge, served as a percent-encoded data: URL, so new mail is visible on a
tab that is not focused — including when the browser collapses tabs to
icon-only, where a title-based count disappears entirely.
The base icon is read from the rendered <link rel="icon"> rather than from
config, so admin and per-domain branding overrides are inherited for free:
the count is drawn on whatever logo the deployment actually serves. Keeping
the badge in SVG rather than rasterising to a canvas also means the browser
can rasterise it at whatever size it asks for, so a HiDPI tab is not served a
16px bitmap.
Notes on the approach:
- The badge link is an *additional* icon link that we append and mark as
ours; we never remove or mutate a link we did not create. Next's metadata
icons are rendered by React, which keeps a fiber pointing at that DOM node,
so removing it would leave React holding a detached node and throw
"Cannot read properties of null (reading 'removeChild')" on the next
commit that deletes the fiber. Appending instead means the last-declared
icon wins, and non-SVG fallback links survive with their type/sizes intact.
(The usual recipe for this feature — assign canvas.toDataURL() to the
existing link's href — does both of the things that break here.)
- Every change of state is an *insertion* of a fresh link of ours, never a
mutation or a removal, because that is the only signal a browser reliably
re-reads the favicon on. Firefox ignores an in-place href change, and it
equally ignores a removal — so clearing the badge by deleting our link left
a stale count painted on the tab until a hard reload. Clearing it instead
inserts a new link of ours carrying the original base href.
- Holding last place has to be defended: on a client-side navigation React
re-hoists its metadata icon link into <head>, landing after ours, and the
base icon silently wins again. A MutationObserver on <head> moves our own
link back to the end whenever a foreign icon link appears — moving only our
node, never anyone else's. It no-ops once ours is last again, so a move
cannot feed itself.
- The badge is a full-width band across the foot of the icon, drawn to the
metrics measured from Gmail's own 16px favicon: band height 0.625 of the
icon, digit cap height 0.44, flush to the edges, corners rounded by about a
pixel. Full width is what keeps a three-glyph label legible — rounded ends
waste exactly the horizontal space it needs. Neutral white with black digits
rather than the conventional red: faviconUrl is admin-overridable and
Bulwark's own icon is rgb(219,45,84), so a red badge sat red-on-red.
- The base SVG may be admin-uploaded, and the branding route deliberately
serves it under a sandboxing CSP because SVG can carry script. Re-emitting
it as a same-origin data: URL would un-fence that, so script, foreignObject
and every on* handler are stripped before serialising.
- Mounted in the root layout, not on the mail route: the badge belongs to the
tab, so mounting it on the page would clear it on every hop to settings,
calendar or contacts.