{#
Renders one row in the message list.
Accepts either a MessageThread (`thread`) or a Message (`message`);
everything below normalises onto the same set of variables so the markup
is shared.
Draft handling: a row opens the compose dock instead of the reading pane
only when the row IS the draft — i.e. a thread holding a single draft
message, or a bare Message row flagged as a draft. A real conversation
carrying an unsent reply still opens the thread; that draft is edited from
inside the reading pane.
`draft_scope` (the Drafts list) overrides that: every row there is listed
because of its draft, so it opens that draft even when the thread also holds
the conversation it answers.
#}
{% set draft_scope = draft_scope|default(false) %}
{# The Sent list, where the who column has the same problem the Drafts list had:
every row is from you, so naming the sender fills the column with your own
name and says nothing. See the `sent_scope` block further down. #}
{% set sent_scope = sent_scope|default(false) %}
{# The Snoozed list. Its rows answer "when does this come back", which is the
only question that list is asked. #}
{% set snooze_scope = snooze_scope|default(false) %}
{# Search results, and only the ones the ordinary search would not have found:
the row is here because a vector said it is about what was asked, not because
anything in it matches the words. Every other list passes nothing and gets no
badge — see the `meaning_match` block beside the New badge. #}
{% set meaning_match = meaning_match|default(false) %}
{% set item = thread ?? message %}
{% set isThread = item.messages is defined %}
{#
Which account a row belongs to.
The unified lists merge every account, and the same message can genuinely be in
three of them at once — the same Message-ID delivered to three addresses puts
three rows in unified Trash, identical in sender, subject and minute, with
nothing on screen to say why there are three. Not a dedup bug; the rows are
real. They were just indistinguishable.
Shown only where it answers something: a list spanning accounts, on an install
that has more than one. A single-account install would get a dot that is the
same colour on every row forever, and an account-scoped list already says which
account it is at the top. `unified` defaults to true because most lists are —
the account view passes false.
#}
{# Where this conversation sits, for the hover actions below. Read from the
mail's own labels rather than from the route, so a row reached from a search
result or a live update answers the same as one in the folder listing. #}
{% set placement = mail_placement(item) %}
{# `?? true`, not `|default(true)`: the default filter fires on false as well
as on absent, so the per-account list's `unified: false` was ignored and its
rows carried the account disambiguation only the merged lists need. #}
{% set unified = unified ?? true %}
{% set rowAccount = item.account ?? null %}
{% set showAccountDot = unified and rowAccount is not null and accounts|length > 1 %}
{#
Is this conversation NEW — never yet put in front of the user?
Two sources, and the fallback is not defensive padding. A list render passes
`newThreadIds`: the set was read BEFORE the same request retired it, so the row
can still say "new" in the very frame that stops it being so — see
MailController::renderList(). Anything re-rendering one row on its own (the
turbo-streams under thread/status/, which redraw a row after a star or a
mark-read) has no such set and asks the thread directly, which by then answers
"already listed" — the badge goes when the row it belongs to is redrawn, rather
than being resurrected by whichever redraw happened last.
Threads only. A bare Message row is a message, and only a conversation arrives.
#}
{% set isNew = isThread
and (newThreadIds is defined
? item.id in newThreadIds
: newMail.isNew(item)) %}
{# An escalation of isNew, never a replacement: an answer IS new, and every
count, dot and retirement path goes on treating it as exactly that. All this
decides is which of two badges the row wears. See
MessageThread::isAnswerAt(). #}
{% set isAnswer = isNew and isThread and newMail.isAnswer(item) %}
{% if isThread %}
{% set type = 'thread' %}
{% set unread = item.unreadCount > 0 %}
{% set subject = item.subject ?: ('thread_row.no_subject'|trans) %}
{% set starred = item.starredAt is not null %}
{% set date = item.lastMessageAt %}
{% set count = item.messageCount %}
{% set latest = item.messages|last %}
{# Everyone who has written in the conversation, oldest first — not the
newest sender, which made every thread you had answered look like it
came from you. #}
{% set sender = thread_participants(item)|join(', ') %}
{# No |striptags: bodyText is ALREADY plain text — DraftPersister::plainTextBody()
stripped the markup and then decoded the entities, and ingest does the same.
Running it again does not remove markup, it removes the user's WRITING: a
message whose text literally says `bold` was shown as "bold", and one
containing `` had the whole thing silently deleted from the
preview, so the list misreported what the message said. Twig's autoescaping
is what makes this safe, and it is also what makes it correct — escaped, the
angle brackets are shown; stripped, they were obeyed. #}
{# mail_snippet, not bodyText. The text part is whatever the sender put in
the half of the mail most recipients never see — for bulk senders that is
routinely a sentence saying the mail is only available in HTML, and for
some it is HTML itself, which arrived in the list as visible `
`. The preview is made from the rendered body instead, which is
what the reader would actually see. See App\Service\Mail\MessageSnippet. #}
{% set snippet = mail_snippet(latest ?: null)|slice(0, 100) %}
{# `latest` comes from |last, which yields FALSE (not null) on an empty
collection — so `is not null` passes and .isDraft blows up on a bool.
A thread can legitimately end up with no messages (every message
expunged provider-side), and this row is rendered for the whole inbox,
so one such thread would 500 the entire list. #}
{# Not `latest`: the collection is not ordered by date, so the draft is
looked up by flag rather than assumed to be the last message. #}
{% set draftMessage = item.messages|filter(m => m.isDraft)|last %}
{% set draft = draftMessage
and (draft_scope or item.messageCount == 1) %}
{% set draftId = draft ? draftMessage.id : null %}
{# In Drafts the who column asked the wrong question. Every draft is from
you, so the participant list answered "ich" on every row and the column
carried no information at all — the one thing that distinguishes one
unsent message from another is who it is FOR. #}
{# `?? []` is not defensive padding: a draft is saved the moment the body
clears its minimum, which is routinely BEFORE a recipient has been typed,
and toAddresses is null until one is. Without it every Drafts list holding
one such draft is a 500 — which is exactly what the compose e2e spec
found. #}
{% if draft_scope and draftMessage %}
{% set recipients = (draftMessage.toAddresses ?? [])
|map(r => r.name ?: r.address)
|filter(r => r is not empty) %}
{% set sender = recipients|length > 0
? ('thread_row.to'|trans({ '%recipients%': recipients|join(', ') }))
: ('thread_row.no_recipient'|trans) %}
{% endif %}
{# Sent, for the same reason and with the same answer.
The column read "Sven" on every row — or "ich" on some and "Sven" on
others, depending on whether the participant list had learned the name —
and in a folder of mail you sent, the one fact nobody needs is who sent
it. The recipient is what tells one row from another, which the Drafts
list has always got right.
Taken from the newest message rather than from the thread's participants:
a conversation you have replied into contains everybody, and the question
here is who THIS went to. #}
{% if sent_scope and latest %}
{% set recipients = (latest.toAddresses ?? [])
|map(r => r.name ?: r.address)
|filter(r => r is not empty) %}
{% set sender = recipients|length > 0
? ('thread_row.to'|trans({ '%recipients%': recipients|join(', ') }))
: ('thread_row.no_recipient'|trans) %}
{% endif %}
{# A draft on a hold. sentAt guards it for the reason the compose window
guards it: once the mail has gone, submissionSendAt is the record of
when it was due, not a schedule. #}
{% set scheduledAt = (draftMessage and draftMessage.sentAt is null)
? draftMessage.submissionSendAt : null %}
{# Which message the hold is on. Not draftId: that one is null unless the
ROW opens the draft (a conversation carrying an unsent reply opens the
thread), and the hold belongs to the draft either way. #}
{% set scheduledId = scheduledAt is not null ? draftMessage.id : null %}
{% set hasAttachment = item.attachmentCount > 0 %}
{% set senderSeed = latest ? (latest.fromAddress ?: sender) : sender %}
{% set href = path('app_mail_thread', { id: item.id }) %}
{% set rowId = item.id %}
{% else %}
{% set type = 'message' %}
{% set unread = item.seenAt is null %}
{% set subject = item.subject ?: ('thread_row.no_subject'|trans) %}
{% set starred = item.starredAt is not null %}
{% set date = item.receivedAt %}
{% set count = 1 %}
{% set sender = item.fromName ?: item.fromAddress %}
{% set senderSeed = item.fromAddress ?: sender %}
{# Same source as the thread branch above, so the two cannot drift. #}
{% set snippet = mail_snippet(item)|slice(0, 100) %}
{% set hasAttachment = item.hasAttachments %}
{% set draft = item.isDraft %}
{% set draftId = item.id %}
{% set scheduledAt = (item.isDraft and item.sentAt is null)
? item.submissionSendAt : null %}
{% set scheduledId = scheduledAt is not null ? item.id : null %}
{% set href = path('app_mail_message', { id: item.id }) %}
{% set rowId = item.id %}
{% endif %}
{#
Search results, and the reason a row can show WHY it matched.
`highlight` is `{ subject, snippet }`, either half of which may be null, built
by App\Service\Search\SearchResultHighlights from Postgres `ts_headline` over
the message the search actually hit. Only the search list passes it; every
other list — inbox, sent, drafts, trash, archive, spam, starred, snoozed,
labels, accounts, and the per-row turbo-streams under thread/status/ — passes
nothing, gets null here, and renders exactly as it did before this existed.
The values are `Twig\Markup`: already escaped, with `` put back in
afterwards by the one class that did the escaping. That is what lets this print
them without `|raw`, which this file still does not contain. A sender's own
markup inside a fragment therefore arrives as visible, escaped text, which is
the same treatment the plain preview gives it — see the striptags note above.
`subjectLine` and not `subject` itself, and that distinction is load-bearing:
`subject` is also spent on `aria-label` and on the select checkbox's label, and
a screen reader announcing "Oak for the <mark>alcove</mark>" would
be a worse regression than the one this feature fixes.
#}
{% set highlight = highlight|default(null) %}
{% set subjectLine = highlight.subject|default(subject) %}
{# No |slice on this one, unlike the plain preview above: the fragment is HTML
now, and cutting it at a hundred characters can land inside a tag. Its length
is bounded by MaxWords in SearchHighlighter::HEADLINE_OPTIONS instead, and
the row goes on truncating with CSS the way it always has. #}
{% set snippet = highlight.snippet|default(snippet) %}
{# `data-enter-NEW`, not `data-enter`, and the difference is the whole reason
this can exist at all.
A row is not automatically an arrival. This template is re-rendered for mail
that has been on screen for an hour every time somebody stars it, archives it
or snoozes it — the turbo-streams under thread/status/ hand the page a brand
new node carrying the same mail. An entrance keyed on the NODE would fire
every time, and a list that shimmers at rest is unreadable.
The list's own sync refresh used to be the worst of these and no longer is:
mail--mail-pane#_morphRows morphs the rows region rather than assigning over
it, so a row that survives a sync keeps its node. What remains is everything
that legitimately rebuilds one row, which is what the id rule below is for.
What tells the two apart is the id: `thread_1234` is the same conversation
whoever rendered it. motion.js animates an element the first time it sees a
given id and never again, so this plays for mail that has actually just
arrived and stays silent through every redraw of mail that has not.
It has to be a separate attribute because a CSS animation starts the instant
an element carrying one is inserted — before any observer can object. A row
that might not deserve an entrance must therefore arrive with no animation
attached, and be given one only once its id has proved to be new. That is
what motion.js does with this attribute, and it is why the stylesheet knows
nothing about it.
Arriving as part of a whole new list — a folder change, a search, the next
page — is silent too: those rows are new ids, but they come inside the
playing its own fade, and motion.js suppresses a child whose ancestor is
announcing it. One arrival, one gesture. #}
{# Draggable, and only where a drag would mean something.
Threads only: the destinations are folders and category tabs, both of which
are properties of a conversation, and the bulk endpoint a drop posts to takes
thread ids. A bare Message row — which this partial also renders, for the
streams under compose/ — has nothing to send.
Not drafts. A draft's row opens the composer rather than the conversation,
and filing an unsent message into Receipts is not a gesture anybody makes;
the Drafts folder is where a draft lives until it is sent or thrown away.
`draggable` is what makes the whole row the handle, deliberately: a grip
would be one more thing on a row that already carries five hover controls,
and every mail client makes the row itself the thing you pick up. The gesture
itself lives in mail--dnd, on — see that controller for why the
sidebar and the tab strip cannot be reached from here. #}
{% set draggable = isThread and false == draft %}