mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
**The mobile double scrollbar.** `app.css` sets `html, body { height: 100dvh }` because mobile
browsers measure `100vh` against the viewport with the URL bar HIDDEN, and its comment says the
shells ask for `h-full`. Three did not: the mailbox, settings and admin shells were `h-screen`, so
the shell was taller than the body containing it, the body scrolled to make room, and the page grew
a second scrollbar beside the message list's own. The contract was already written down; these were
the three places not honouring it.
**The demo bar covered the last row.** It is `fixed` on purpose — a banner in the flow costs every
page a strip of reading height — but the cost is that the last row sits behind it, which on a phone
is the row you were reaching for. Scrollers get its height back as clearance.
**The Label-as menu was sliced off inside a thread.** The reading pane is `overflow-hidden` and the
panel was `absolute`, so it was cut at the pane's edge with no way to scroll to the rest — the thing
clipping it is not the thing that scrolls.
`position: fixed` does NOT fix this, which is the part worth recording: a fixed element is
positioned against the nearest ancestor carrying a transform, filter or backdrop-filter, and the
boxed layout gives the panes a backdrop blur. Measured rather than assumed — the panel reported
`top: 125px` inline and rendered at 210, ending eighty pixels below a 420px viewport. So it stays
where it is and is sized to the box that clips it, flipping above the button when there is more room
there.
**More demo data, from using the demo.** There was HTML mail, but List-Unsubscribe correctly files
it under Promotions, so the only formatted mail sat behind a tab nobody clicks and the reading pane
looked as though it could render nothing but plain text. There is now one from a person, in Primary.
And there was no conversation at all — every thread a singleton, which leaves the reading pane's
whole argument with nothing to demonstrate on.
Threading that conversation had to be done one turn at a time. MessageThreader resolves a reply's
parent with a SQL query that joins message.thread, so a parent created earlier in the same UNFLUSHED
batch is invisible to it and all four turns looked like conversation openers. The code already says
as much where Gmail meets the same wall from the other side.
**Tests that survive the fixture growing** — CODESTYLE 9.5, and three specs brought in line. An
assertion that must be edited every time the seeded mailbox gains a thread is asserting that number
rather than the behaviour, and it fails in a spec whose subject has nothing to do with the change.
Baselines where there is a "before", a relation where there is not: every message is its own thread
EXCEPT the conversation, whose turns collapse.
Proved by mutation rather than by reading: seeding a fifth thread and re-running found a third
brittle spec I had not spotted — unread-view's hard-coded "3" — which now takes the badge's own
starting value. Absence checks and own-fixture counts are left alone, and the rule says why.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
308 lines
18 KiB
Twig
308 lines
18 KiB
Twig
<!DOCTYPE html>
|
|
{# `lang` is what tells a screen reader which voice to read the page in, and
|
|
what a browser offers to translate from. It was absent entirely, so assistive
|
|
technology fell back to the OS language and read a German UI in an English
|
|
voice.
|
|
|
|
The request locale, not the user's stored preference: they agree for a
|
|
signed-in user, and for the login page the request locale is the only one
|
|
there is. Underscores become hyphens because BCP 47 writes en-PI where
|
|
Symfony writes en_PI, and an invalid tag is ignored the same as a missing
|
|
one. #}
|
|
<html lang="{{ app.request.locale|replace({'_': '-'}) }}"
|
|
class="{{ appearance_class() }}"
|
|
data-theme="{{ appearance_theme() }}"
|
|
{# How much the interface may move. Server-rendered beside the theme and
|
|
for the same reason: a tier the browser learns only after JavaScript
|
|
has run is a first paint at the wrong one, which for motion means
|
|
every element on the page playing its entrance at once the moment the
|
|
setting arrives. See assets/styles/motion.css. #}
|
|
data-motion="{{ appearance_motion() }}"
|
|
{# The RUNTIME debug flag, read by stimulus_bootstrap.js. The Stimulus
|
|
bundle bakes its own debug flag into the compiled asset at build time,
|
|
so an asset built in a debug context ships ~1000 console log lines to a
|
|
prod runtime that is not in debug at all. This attribute carries the
|
|
value of the kernel actually serving the request — false in prod, true
|
|
in dev — so the bootstrap can correct the baked-in one. #}
|
|
data-app-debug="{{ app.debug ? 'true' : 'false' }}"
|
|
{# The user's twelve-or-twenty-four-hour choice, for the JavaScript that
|
|
prints a time. Twig has `clock.time` and reads the setting directly;
|
|
`Intl.DateTimeFormat` in the browser has no idea it exists and follows
|
|
the *locale's* default instead — which is how the send-later presets
|
|
came to say "8:00 AM" to someone who had asked for a 24-hour clock.
|
|
|
|
On <html> rather than threaded through each controller as a Stimulus
|
|
value because it is wanted in three unrelated places already (the
|
|
send-later menu, the snooze menu, and anything that formats a time
|
|
next) and none of them share a template. Same argument as ClockGlobal
|
|
being a Twig global rather than a controller variable. Read through
|
|
`prefersHour12()` in assets/clock_format.js. #}
|
|
data-clock-hour12="{{ clock.is12Hour ? 'true' : 'false' }}"
|
|
style="{{ appearance_vars() }}">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
{# Without this the browser assumes a ~980px desktop canvas and scales the
|
|
whole app down. `viewport-fit=cover` lets the gradient run under the
|
|
notch; the safe-area padding in app.css keeps content clear of it.
|
|
|
|
Zoom is NOT pinned. `maximum-scale=1, user-scalable=no` used to be here
|
|
to make the app feel installed rather than page-like; it also took away
|
|
pinch-zoom, which WCAG 1.4.4 requires and which is the only way some
|
|
people read anything at all. The reason it was added — iOS zooming when
|
|
a small form control takes focus — is already handled by the 16px
|
|
form-control rule in app.css, which is what actually prevents that; iOS
|
|
ignores user-scalable anyway, so this only ever disabled zoom on Android
|
|
where nothing was wrong.
|
|
|
|
`interactive-widget=resizes-content`: Android's default (resizes-visual)
|
|
shrinks only the visual viewport for the keyboard and leaves the layout
|
|
viewport — where fixed elements live — at full height, so the page
|
|
behind the fullscreen compose window stayed pannable and showed through
|
|
below it. Resizing the content instead means fixed elements end above
|
|
the keyboard and there is nothing to pan. iOS ignores this keyword; the
|
|
compose controller's visualViewport tracking stays as its fallback. #}
|
|
<meta name="viewport"
|
|
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
|
|
<title>{% block title %}Welcome!{% endblock %}</title>
|
|
{#
|
|
Which counts key the "(n)" in the title above came from, or empty on a page
|
|
whose title carries no count.
|
|
|
|
The title is rendered once, server-side, while the sidebar badges are
|
|
patched in place after every sync — so the tab kept saying (4) long after
|
|
the sidebar had moved to 3. Naming the key here lets the sidebar controller
|
|
rewrite the title from the same payload it patches the badges with, which
|
|
makes "the same source" a fact rather than an intention.
|
|
#}
|
|
<meta name="title-count-key" content="{% block title_count_key %}{% endblock %}">
|
|
{% include '_partials/_favicon.html.twig' %}
|
|
{# PWA: iOS only offers Web Push to apps added to the Home Screen, and it
|
|
needs the apple-touch-icon specifically — it ignores the manifest icons. #}
|
|
<link rel="manifest" href="/manifest.webmanifest">
|
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
|
{# The token the fetch-based endpoints check. Stimulus controllers that
|
|
build their own request body cannot carry a form field, so they read this
|
|
and send it as X-CSRF-Token. Every such endpoint validates against the
|
|
same 'ajax' id — until this tag existed they all sent an empty string. #}
|
|
<meta name="csrf-token" content="{{ csrf_token('ajax') }}">
|
|
<meta name="theme-color" content="#a21caf">
|
|
{# Both spellings. `mobile-web-app-capable` is the standardised one every
|
|
engine now reads and the only one Chrome does not warn about; the Apple
|
|
prefix stays because older iOS is the platform that actually needs it,
|
|
and dropping it would take the home-screen behaviour with it on exactly
|
|
the devices the tag was written for. #}
|
|
<meta name="mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-title" content="plMail">
|
|
|
|
{# nonce, not 'unsafe-inline': this script has to run before first paint, so
|
|
it cannot move into a file, and a policy that allowed every inline script
|
|
in order to permit this one would not be worth sending. Same value as the
|
|
header — see App\Security\Csp\CspNonce. #}
|
|
<script nonce="{{ csp_nonce() }}">
|
|
// The appearance is rendered onto <html>, and the server only knows it
|
|
// once the user is authenticated. Mirror it into localStorage so the
|
|
// logged-out screens (login above all) already paint in the theme the
|
|
// user picked instead of the defaults.
|
|
(function () {
|
|
const root = document.documentElement;
|
|
const key = 'plmail:appearance';
|
|
|
|
try {
|
|
{% if app.user %}
|
|
localStorage.setItem(key, JSON.stringify({
|
|
class: root.className,
|
|
theme: root.dataset.theme,
|
|
style: root.getAttribute('style') || '',
|
|
}));
|
|
{% else %}
|
|
const saved = JSON.parse(localStorage.getItem(key) || 'null');
|
|
|
|
if (saved) {
|
|
root.className = saved.class;
|
|
root.dataset.theme = saved.theme;
|
|
|
|
// A custom background is served from behind the firewall,
|
|
// so it would 404 here; every other variable replays fine.
|
|
root.setAttribute('style', saved.style
|
|
.split(';')
|
|
.filter((part) => part.indexOf('/settings/appearance/') === -1)
|
|
.join(';'));
|
|
}
|
|
{% endif %}
|
|
} catch (e) {}
|
|
})();
|
|
|
|
if (document.documentElement.dataset.theme === 'system' &&
|
|
window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
document.documentElement.classList.add('dark');
|
|
}
|
|
|
|
// Collapsed-sidebar state lives in localStorage, so it has to be applied
|
|
// before first paint or the wide sidebar flashes on every load.
|
|
try {
|
|
if (localStorage.getItem('plmail:sidebarRail') === '1') {
|
|
document.documentElement.classList.add('sidebar-rail');
|
|
}
|
|
} catch (e) {}
|
|
</script>
|
|
|
|
{% block stylesheets %}
|
|
{# Linked here rather than imported from assets/app.js. That import is
|
|
implemented by AssetMapper as a data: JavaScript module which appends
|
|
this very tag at runtime — which delays the fetch until the module
|
|
graph has run, and, more importantly, forces `script-src … data:` on
|
|
any CSP worth having. See the note at the top of assets/app.js. #}
|
|
<link rel="stylesheet" href="{{ asset('styles/app.css') }}">
|
|
<link rel="stylesheet" href="{{ asset('vendor/@fortawesome/fontawesome-free/css/all.css') }}">
|
|
{% endblock %}
|
|
|
|
{% block javascripts %}
|
|
{# The import map and its preloads are inline scripts too, and the
|
|
whole application is loaded through them — an unnonced import map
|
|
under an enforced script-src is a blank page. #}
|
|
{% block importmap %}{{ importmap('app', { nonce: csp_nonce() }) }}{% endblock %}
|
|
{% endblock %}
|
|
|
|
{% set frankenphpHotReload = app.request.server.get('FRANKENPHP_HOT_RELOAD') %}
|
|
{% if frankenphpHotReload %}
|
|
<meta name="frankenphp-hot-reload:url" content="{{ frankenphpHotReload }}">
|
|
<script src="https://cdn.jsdelivr.net/npm/idiomorph"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/frankenphp-hot-reload/+esm" type="module"></script>
|
|
{% endif %}
|
|
</head>
|
|
{# One topic, named once: it is both what we subscribe to and what the cookie
|
|
authorizes, and the two drifting apart is a silent no-updates bug. #}
|
|
{% set mercure_topics = app.user ? ['mail/user/' ~ app.user.id] : [] %}
|
|
<body
|
|
{# ui--tooltip is outside the block on purpose: it replaces the native
|
|
`title` tooltip application-wide, so a page overriding body_controllers
|
|
must not be able to drop it and fall back to the black box. #}
|
|
{# mail--mail-pane is deliberately not in this default: its targets live in
|
|
the mailbox layout, and a controller that connects without them throws
|
|
on every page that has no reading pane — which is every admin, settings
|
|
and calendar page. The mailbox layout adds it back. #}
|
|
{# has-demo-bar: the fixed demo bar overlays the panes, so scrollers need
|
|
room to bring their last row clear of it. See app.css. #}
|
|
class="{% block body_class %}{% endblock %}{% if app.user and demo.isEnabled %} has-demo-bar{% endif %}"
|
|
data-controller="ui--tooltip {% if app.user %}{% block body_controllers %}core--mercure{% endblock %}{% endif %}"
|
|
{% if app.user %}
|
|
{# URL only. The cookie that authorizes this subscription is attached by
|
|
MercureCookieSubscriber — passing `subscribe:` here would mint it too,
|
|
but that helper throws for a hub on another domain and would take the
|
|
whole page down with it. #}
|
|
data-core--mercure-url-value="{{ mercure(mercure_topics) }}"
|
|
data-core--mercure-auth-url-value="{{ path('mercure_auth') }}"
|
|
{# The pane owns the list refresh, so the events that should suspend it
|
|
are routed here too: a bulk action writes one request per selected
|
|
row, and a refresh landing mid-run undoes the rows it has already
|
|
finished. See mail_pane_controller#hold. #}
|
|
data-action="core--mercure:mailbox-synced->mail--mail-pane#onMailboxSynced
|
|
core--mercure:account-synced->mail--mail-pane#onAccountSynced
|
|
mail--list-toolbar:writing->mail--mail-pane#hold
|
|
mail--list-toolbar:written->mail--mail-pane#release"
|
|
data-mail--mail-pane-open-value="{% block pane_open %}false{% endblock %}"
|
|
{% endif %}
|
|
>
|
|
{% block body %}{% endblock %}
|
|
{% block javascripts_body %}{% endblock %}
|
|
{% include '_partials/_modal.html.twig' %}
|
|
{# After the modal, and only ever for a signed-in user who has not been
|
|
through setup: it drives the dialog above, so that has to exist first. #}
|
|
{% if app.user %}
|
|
{% include 'onboarding/_boot.html.twig' %}
|
|
{% endif %}
|
|
{# The demo strip, for a signed-in visitor on an install that is a demo.
|
|
Inside the app.user check for the same reason the session-expired bar is:
|
|
it posts to a route that requires a mailbox to deliver into, so on the
|
|
login screen it would be a button that could only fail. #}
|
|
{% if app.user and demo.isEnabled %}
|
|
{% include '_partials/_demo_bar.html.twig' %}
|
|
{% endif %}
|
|
{# The dock's placement lives on this wrapper, never on the turbo-frame it
|
|
holds. The undo stream reopens the window with `action="replace"` on the
|
|
frame element itself, so any class on the frame is thrown away with the
|
|
element it replaces — and because the frame is the ONE the Compose button
|
|
and every draft row navigate into, a classless frame left behind put the
|
|
reopened window, and every dock window opened after it, in normal flow
|
|
below the fold until a full page reload restored this markup. Positioning
|
|
the stable wrapper means the frame can be swapped freely and the dock
|
|
stays where it belongs.
|
|
|
|
`z-50` here because a fixed element creates a stacking context: left at
|
|
`auto` the whole dock painted below the topbar (z-20) and the mobile
|
|
drawer (z-40), which the fullscreen compose window below md has to cover. #}
|
|
<div class="fixed bottom-4 right-6 z-50 flex items-end gap-3"
|
|
data-controller="compose--dock"
|
|
data-compose--dock-i18n-value="{{ {
|
|
failed: 'compose.open_failed.failed'|trans,
|
|
signedOut: 'compose.open_failed.signed_out'|trans,
|
|
offline: 'compose.open_failed.offline'|trans,
|
|
dismiss: 'compose.open_failed.dismiss'|trans,
|
|
}|json_encode }}">
|
|
{# Normally empty — the dock is filled by whatever link targets this
|
|
frame. The `compose` parameter is the one exception: a compose URL
|
|
reached as a PAGE rather than as a frame (a bookmark, a middle-click,
|
|
the back button after a session expired) is redirected to the mailbox
|
|
carrying the URL it was trying to open, and the frame fetches it from
|
|
here. Its request then has the Turbo-Frame header, so the controller
|
|
answers with the fragment exactly as it does for every other route
|
|
into the window.
|
|
|
|
Only our own compose paths, and only paths — the value ends up as a
|
|
`src` this page fetches, so anything else would be an open redirect
|
|
with our session attached. #}
|
|
{% set composeSrc = app.request.query.get('compose') %}
|
|
{% set composeSrc = composeSrc starts with '/compose/' and '//' not in composeSrc ? composeSrc : null %}
|
|
<turbo-frame id="compose_dock"{% if composeSrc %} src="{{ composeSrc }}" loading="eager"{% endif %}></turbo-frame>
|
|
</div>
|
|
{#
|
|
The toast region, and the flashes waiting in it.
|
|
|
|
Turbo-stream responses append into this element, which is how almost
|
|
every toast in the app arrives. What had NO route in was the flash bag:
|
|
`addFlash()` is called from a dozen controllers — the account-health
|
|
repairs among them — and nothing in any template ever read it, so every
|
|
one of those sentences was written into the session and thrown away
|
|
unread on the next request.
|
|
|
|
That is why the health repairs looked like they did nothing. A repair
|
|
POSTs, redirects, and says its piece through a flash; with no renderer
|
|
the page simply reappeared unchanged, which is indistinguishable from a
|
|
button that is not wired up.
|
|
|
|
Rendered server-side into the same region rather than through a stream,
|
|
because a redirect is not a stream response — there is nothing to append
|
|
with. Reading `app.flashes` here also CLEARS the bag, which is what stops
|
|
the message reappearing on the next navigation.
|
|
#}
|
|
{# Only for somebody who HAS a session to lose. Besides being meaningless
|
|
on the login and install screens, the bar carries a link to /login — and
|
|
the install flow asserts that a freshly restored instance with no users
|
|
offers no way to sign in, because there is nobody to sign in as. #}
|
|
{% if app.user %}
|
|
{% include '_partials/_session_expired.html.twig' %}
|
|
{% endif %}
|
|
{% include '_partials/_confirm_dialog.html.twig' %}
|
|
|
|
<div id="toast-region" class="fixed bottom-4 left-4 z-[60] flex flex-col gap-2 items-end">
|
|
{% for type, messages in app.flashes(['success', 'error', 'info']) %}
|
|
{% for message in messages %}
|
|
{# data-turbo-temporary so Turbo strips it from the snapshot it
|
|
caches. Without it the back button restores a page with the
|
|
toast painted back on, announcing a thing that happened two
|
|
navigations ago as though it had just happened. #}
|
|
<div data-turbo-temporary data-flash-toast="{{ type }}">
|
|
{# Already translated by whoever set it: the flash bag holds
|
|
sentences, not keys, because the controllers that fill it
|
|
interpolate names and counts into them. `extra` is passed
|
|
empty rather than omitted — the partial reads into it for
|
|
the undo affordance, which a flash never has. #}
|
|
{% include '_partials/_toast.html.twig' with { message: message, type: type, extra: {} } only %}
|
|
</div>
|
|
{% endfor %}
|
|
{% endfor %}
|
|
</div>
|
|
</body>
|
|
</html>
|