mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
feat(insight): a strip of what is coming, above the mail list
Up to three dated insights as a band over the list — a parcel out for
delivery, a flight tomorrow — each with what it is, when it is, and the
one button worth having. The facts were already being read; until now
the only place they surfaced was a panel somebody had to go and open.
InsightPane the whole visibility rule in one place, because
three readers need it: the fragment, the dismiss
endpoint and the tests. Empty means the strip does
not exist, whichever of the three reasons applies.
InsightPaneCtrl the lazy fragment and the dismiss. Re-stamping the
dismissal is deliberate and the opposite of
InsightActionsController::dismiss — that records a
fact about one card, this records a position on a
moving list.
InsightNotifier insights.changed on mail/user/{id}, published after
the flush and once per user per batch, so a tab that
comes to read finds rows that are actually there.
_pane.html.twig renders NOTHING when there is nothing — no heading,
no empty box, no height.
Dismissing the band means "not now": it returns when an insight it has
never shown is extracted, which is decided against createdAt and not
updatedAt. A carrier revising an estimate rewrites a row the user
already waved away, and reading that as news would bring the strip back
for the whole of a parcel's journey.
HappeningSoonController rejected a panel in the mailbox, in writing, for
two costs: width taken from panes that have none to give, and a query on
every mailbox load for a list that is usually empty. Both are answered
rather than ignored — the strip takes height, arrives in a lazy frame,
and renders nothing when empty — and that controller's doc now says so
instead of contradicting what ships beside it.
One line of this is not in either worker's scope and is the thing that
would have shipped silently broken: core--mercure drops any update type
it has no branch for, so insights.changed arrived at the hub, was
delivered, and reached nobody. The branch is there now, and the method
says out loud that a missing one fails quietly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7aeae603ab
commit
4690f9d9ea
20 files changed
+1890
-8
No files matched your search
@@ -367,9 +367,15 @@ export default class extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
// Update types come from App\Service\Mail\SyncNotifier. Both are dispatched
|
||||
// on <body>, so listeners nested deeper (the sidebar) catch them bubbling
|
||||
// up to document rather than via a data-action.
|
||||
// Update types come from the notifier services on the PHP side. All are
|
||||
// dispatched on <body>, so listeners nested deeper (the sidebar, the
|
||||
// insight strip) catch them bubbling up to document rather than via a
|
||||
// data-action.
|
||||
//
|
||||
// A type with no branch here is silently dropped, which is the one thing
|
||||
// to remember when adding a publisher: the hub delivers it, this method
|
||||
// ignores it, and the feature waiting for it simply never hears anything.
|
||||
// Nothing fails and nothing is logged.
|
||||
_handleUpdate(data) {
|
||||
if (data.type === "mailbox.synced") {
|
||||
this.dispatch("mailbox-synced", {detail: data});
|
||||
@@ -388,6 +394,13 @@ export default class extends Controller {
|
||||
// lives on the MailRule row, so a missed message costs a stale
|
||||
// panel until the next load, never a wrong answer.
|
||||
this.dispatch("rule-run", {detail: data});
|
||||
} else if (data.type === "insights.changed") {
|
||||
// Published by App\Service\Insight\InsightNotifier when a sync
|
||||
// actually landed insights for this user. The strip above the mail
|
||||
// list reloads its frame on it — a hint to re-read and nothing
|
||||
// more, so a missed message costs a strip that is one sync stale
|
||||
// rather than a wrong one.
|
||||
this.dispatch("insights-changed", {detail: data});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
import { leave } from "../../motion.js";
|
||||
|
||||
/**
|
||||
* The insight strip above the mail list: waving away one row, waving away the
|
||||
* whole band, and keeping up when the extractor finds something new.
|
||||
*
|
||||
* Values:
|
||||
* token — CSRF for `insight-dismiss`, the per-row endpoint
|
||||
* paneToken — CSRF for `insight-pane-dismiss`, the whole-band endpoint
|
||||
* paneUrl — where the whole-band dismissal POSTs
|
||||
*
|
||||
* Action params:
|
||||
* url — the per-row dismiss endpoint, on the row's own menu item
|
||||
*
|
||||
* Routes used:
|
||||
* row → POST /insights/{id}/dismiss
|
||||
* band → POST /insights/pane/dismiss
|
||||
* reload → the frame's own `src`, GET /insights/pane
|
||||
*
|
||||
* Both tokens ride in as VALUES from the template rather than through csrf.js,
|
||||
* for the reason radar_controller documents: these are the per-action tokens
|
||||
* the two endpoints check, not the shared `ajax` one. One scope wraps the whole
|
||||
* band, so each is minted once however many rows there are, and the per-row URL
|
||||
* travels as an action param on the menu item.
|
||||
*
|
||||
* Nothing is removed before the server has said ok. An optimistic removal would
|
||||
* eat the card on an expired session and hand it back on the next load, which
|
||||
* reads as the dismissal "not sticking" — the one behaviour MailInsight's
|
||||
* dismissedAt column exists to rule out.
|
||||
*
|
||||
* ── When the last row goes, the band goes ───────────────────────────────────
|
||||
*
|
||||
* Both dismissals end at the same place: the FRAME is removed, not merely this
|
||||
* element. A heading over nothing reads as a section that failed to load, and
|
||||
* an empty bordered band above the inbox is exactly what _pane.html.twig
|
||||
* refuses to render in the first place — the client must not be able to produce
|
||||
* a state the server would never send.
|
||||
*
|
||||
* Removing the frame rather than its contents also removes this controller with
|
||||
* it, which is what makes the next section true.
|
||||
*
|
||||
* ── The dismiss/refresh race ────────────────────────────────────────────────
|
||||
*
|
||||
* A refresh and a dismissal both want the same DOM, and they arrive from
|
||||
* different directions: the user presses ✕ while an `insights.changed` is
|
||||
* already on the wire. Three things keep them apart.
|
||||
*
|
||||
* The dismissal wins by OUTLIVING the refresh. Once it succeeds the frame is
|
||||
* gone, and a frame that is not in the document cannot be reloaded — there is
|
||||
* no element left holding a `src`, and no controller left listening. So a
|
||||
* message arriving after a completed dismissal is not merely harmless, it is
|
||||
* unreachable.
|
||||
*
|
||||
* DURING the round trip the flag below holds refreshes off. A reload landing
|
||||
* mid-flight would swap in a freshly rendered band — correct markup, drawn from
|
||||
* a server that does not yet know about the press — and the dismissal would
|
||||
* then remove an element nobody is looking at any more while the new one stays
|
||||
* on screen. The strip would visibly come back a beat after being waved away.
|
||||
*
|
||||
* And a refresh that arrives during that window is not dropped, it is
|
||||
* REMEMBERED: if the dismissal fails, the flag clears and the remembered reload
|
||||
* runs, so a failed press costs the user a button that did nothing rather than
|
||||
* a strip that silently stopped updating.
|
||||
*
|
||||
* The reload itself is safe to run whenever it is allowed to, because the
|
||||
* server already holds the dismissal — `InsightPane::rowsFor` re-derives it on
|
||||
* every request, and answers with an empty band. This client-side removal is
|
||||
* about the FRAME between the press and the next render, nothing more.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Published by the mercure controller when the hub says `insights.changed` on
|
||||
* this user's topic. Listened for on `document` rather than through a
|
||||
* data-action, the way ui--sidebar and rules--rule-run do: the event is
|
||||
* dispatched on <body>, which is an ANCESTOR of this frame, so it never reaches
|
||||
* a listener nested inside — it only passes here on its way up to document.
|
||||
*
|
||||
* No EventSource of its own: `core--mercure` is already mounted on <body> for
|
||||
* every mailbox page and its stream is shared at module scope. A second
|
||||
* subscription would be a second socket for a message the first one already has.
|
||||
*/
|
||||
const CHANGED_EVENT = "core--mercure:insights-changed";
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
token: String,
|
||||
paneToken: String,
|
||||
paneUrl: String,
|
||||
};
|
||||
|
||||
connect() {
|
||||
this.#busy = false;
|
||||
this.#missed = false;
|
||||
|
||||
this.#onChanged = () => this.#refresh();
|
||||
document.addEventListener(CHANGED_EVENT, this.#onChanged);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
document.removeEventListener(CHANGED_EVENT, this.#onChanged);
|
||||
}
|
||||
|
||||
/** The ✕ on the heading: the whole band, until something new turns up. */
|
||||
async dismissPane(event) {
|
||||
const band = this.#frame() ?? this.element;
|
||||
|
||||
if (true === await this.#post(event.currentTarget, this.paneUrlValue, this.paneTokenValue)) {
|
||||
// The band leaves as one thing because that is what was pressed.
|
||||
// The same 120ms radar_controller argues for, hidden inside a round
|
||||
// trip that was happening anyway. `leave` removes the node itself
|
||||
// when no callback says otherwise.
|
||||
leave(band);
|
||||
}
|
||||
}
|
||||
|
||||
/** A row's `⋮` → Dismiss: this insight, and the band with it if it was the last. */
|
||||
async dismiss(event) {
|
||||
const { url } = event.params;
|
||||
const card = event.currentTarget.closest("[data-insight-card]");
|
||||
|
||||
if (false === await this.#post(event.currentTarget, url, this.tokenValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
const band = this.#frame() ?? this.element;
|
||||
|
||||
// The cleanup rides in the callback because `leave` removes the node
|
||||
// when the animation ends — counting what is left before that would
|
||||
// always find the card still in it. Under `none` and reduced motion
|
||||
// `leave` calls back synchronously, so this is one code path, not two.
|
||||
leave(card, () => {
|
||||
card.remove();
|
||||
|
||||
if (null === this.element.querySelector("[data-insight-card]")) {
|
||||
band.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────
|
||||
|
||||
#busy;
|
||||
#missed;
|
||||
#onChanged;
|
||||
|
||||
/**
|
||||
* POSTs a dismissal and says whether it stuck.
|
||||
*
|
||||
* The button is disabled for the round trip so a slow network cannot take a
|
||||
* second click. The endpoints are idempotent; this is about not looking
|
||||
* broken, not about safety.
|
||||
*/
|
||||
async #post(button, url, token) {
|
||||
this.#busy = true;
|
||||
button.disabled = true;
|
||||
|
||||
let response;
|
||||
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": token,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
this.#release();
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
|
||||
if (false === response.ok) {
|
||||
this.#release();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Deliberately still busy. The removal below is what ends this
|
||||
// dismissal, and until the element is gone a reload could still draw a
|
||||
// band over it.
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The dismissal did not stick — let refreshes through again, including a missed one. */
|
||||
#release() {
|
||||
this.#busy = false;
|
||||
|
||||
if (true === this.#missed) {
|
||||
this.#missed = false;
|
||||
this.#refresh();
|
||||
}
|
||||
}
|
||||
|
||||
#refresh() {
|
||||
if (true === this.#busy) {
|
||||
this.#missed = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.#frame()?.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame this strip was rendered into. Looked up each time rather than
|
||||
* cached on connect: the controller lives INSIDE the frame, so a reload
|
||||
* replaces this element and any reference taken on a previous render would
|
||||
* point at markup that has already been thrown away.
|
||||
*/
|
||||
#frame() {
|
||||
return this.element.closest("turbo-frame#insight_pane");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- translated-from: features/mail.md sha1:86cafc80aa868a2ed9981e09d2a87cc256d4a744 -->
|
||||
<!-- translated-from: features/mail.md sha1:77ce738f1d94b302130b86d0166418bf6add39ce -->
|
||||
|
||||
# Mail
|
||||
|
||||
@@ -127,6 +127,28 @@ Und ein Liefertag, der in Worten statt in Ziffern steht ("Arriving today", "Anku
|
||||
gegen den Eingang der Mail aufgelöst und nie gegen die Uhr: Wer eine alte Mail noch einmal liest,
|
||||
landet auf demselben Tag wie beim ersten Mal.
|
||||
|
||||
### Die Leiste über der Mailliste
|
||||
|
||||
Datierte Insights erscheinen außerdem als Band direkt über der Mailliste — höchstens drei, das
|
||||
Nächste zuerst, jeweils mit dem, was es ist, wann es ist, und der einen Schaltfläche, die sich
|
||||
lohnt (**Verfolgen**, oder die Sache auf GitHub). Es ist dieselbe Menge, die auch das Radar-Panel
|
||||
führt, nur dort gesagt, wo du ohnehin bist: Das Panel beantwortet "Was steht an?", wenn du danach
|
||||
suchst — das Band sagt dir, dass ein Paket in Zustellung ist, wenn du nicht gesucht hast.
|
||||
|
||||
Das Band hält sich selbst aktuell. Findet ein Durchlauf etwas Neues, aktualisiert es sich an Ort
|
||||
und Stelle, ohne Neuladen und ohne dass du auf einer bestimmten Seite sein musst.
|
||||
|
||||
**Das ✕ am Band heißt "gerade nicht", nicht "nie".** Blendest du es aus, bleibt es weg, bis ein
|
||||
Insight auftaucht, das es dir noch nie gezeigt hat — ein Paket, das morgen losgeht, holt es zurück,
|
||||
das eben Weggewischte bleibt fort. Dauerhaft ist das Ausblenden einer einzelnen Karte über ihr
|
||||
`⋮`-Menü, und wenn die letzte Karte geht, geht das Band mit. Ganz loswerden kannst du es mit einem
|
||||
Schalter unter **Einstellungen → Radar**; der blendet nur das Band aus — die Fakten werden weiter
|
||||
gelesen und landen weiter im Radar-Panel und in der Konversation.
|
||||
|
||||
Das Band nimmt Höhe und nie Breite: Keine Spalte gibt dafür ein Pixel her, es wird erst geholt,
|
||||
wenn die Mail schon auf dem Schirm ist, und an einem gewöhnlichen Tag, an dem es nichts zu sagen
|
||||
gibt, ist es gar nicht da.
|
||||
|
||||
Das Auslesen ist deterministisch und lokal. Es läuft auf deinem eigenen Server, gegen
|
||||
Absender-Domains, Header und feste Muster — Sendungsnummern, Flugcodes, `#123` —, ohne
|
||||
Cloud-Dienst und ohne Modell: Nichts rät, und eine Mail, die auf kein bekanntes Muster passt,
|
||||
|
||||
@@ -112,6 +112,27 @@ delivery day promised in words rather than digits ("arriving today", "Arriving M
|
||||
against the mail's own arrival, never against the clock, so re-reading an old mail lands on the day
|
||||
it always did.
|
||||
|
||||
### The strip above the mail list
|
||||
|
||||
Dated insights also appear as a band directly above the mail list — up to three of them, soonest
|
||||
first, each with what it is, when it is, and the one button worth having (**Track package**, or the
|
||||
thing on GitHub). It is the same set the radar panel holds, said where you already are: the panel
|
||||
answers "what is coming up?" for someone who went looking, the strip tells you a parcel is out for
|
||||
delivery when you did not.
|
||||
|
||||
The band updates itself. A sync that finds something new refreshes it in place, with no reload and
|
||||
no page you have to be on.
|
||||
|
||||
**The ✕ on the band means "not now", not "never".** Dismissing it hides the strip until an insight
|
||||
it has never shown you is extracted — a parcel that ships tomorrow brings it back, while the one
|
||||
you just waved away stays gone. Dismissing a single card through its `⋮` menu is the permanent
|
||||
one, and when the last card goes the band goes with it. To be rid of it altogether there is a
|
||||
switch under **Settings → Insights**; switching it off hides the band only — the facts are still
|
||||
read, and still reach the radar panel and the conversation strip.
|
||||
|
||||
The band takes height and never width: no pane gives up a pixel for it, it is only fetched after
|
||||
the mail is on screen, and on the ordinary day when there is nothing to say it is not there at all.
|
||||
|
||||
Extraction is deterministic and local. It runs on your own server against sender domains, headers
|
||||
and regular shapes — tracking numbers, flight codes, `#123` — with no cloud service and no model:
|
||||
nothing guesses, and a mail that matches no known shape simply yields nothing. That is a deliberate
|
||||
|
||||
@@ -32,6 +32,16 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
* with the calendar docking into that row; a fourth region would have to take
|
||||
* width from panes that have none to give, and it would render — and query — on
|
||||
* every mailbox load for a list that is empty for most users on most days.
|
||||
* Those two costs are still the reason THIS list is not in the mailbox, and
|
||||
* nothing below repeals them.
|
||||
*
|
||||
* What has since been built in the mailbox is not this list in another place.
|
||||
* The insight strip takes height rather than width, arrives in a lazy frame so
|
||||
* a mailbox render pays nothing for it, and renders nothing at all when there
|
||||
* is nothing to say — which answers both objections above without weakening
|
||||
* either, and it answers a different question for a different person besides.
|
||||
* {@see \App\Service\Insight\InsightPane} makes that argument in full; it is
|
||||
* the place to read before concluding that this doc and that feature disagree.
|
||||
*
|
||||
* So it opens from the topbar, into the app's shared body-level modal frame.
|
||||
* The indicator beside the calendar button already answers "is there a reason to
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Insight;
|
||||
|
||||
use App\Controller\ChecksCsrf;
|
||||
use App\Entity\User\User;
|
||||
use App\Service\Insight\InsightPane;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* The strip of insights above the mail list: the fragment that draws it, and
|
||||
* the button that waves it away.
|
||||
*
|
||||
* Whether it draws at all is not decided here. {@see InsightPane} owns that
|
||||
* sentence — off-switch, emptiness, dismissal — because the answer has three
|
||||
* readers and one of them is a test; this class only turns the answer into a
|
||||
* response. Its class doc also carries the argument for the strip existing at
|
||||
* all, against HappeningSoonController's written rejection of a panel in the
|
||||
* mailbox, and that is the doc to read before moving anything here.
|
||||
*
|
||||
* A route of its own rather than an include in the mailbox template, and that
|
||||
* is what makes the whole feature affordable: the strip arrives in a lazy
|
||||
* turbo-frame, so a mailbox render pays no query for it, and a user who has
|
||||
* switched it off pays nothing ever. The frame id is `insight_pane`; the
|
||||
* Stimulus side re-requests this URL when Mercure says `insights.changed`.
|
||||
*
|
||||
* Not folded onto InsightActionsController, though it is next door and shares
|
||||
* the prefix: that class is about the verbs on ONE insight — it resolves a
|
||||
* MailInsight, checks OwnershipVoter on it, stamps a column on it. Nothing
|
||||
* here has an insight to resolve. Both actions are about the STRIP, which
|
||||
* belongs to the user and not to any row, and the ownership question therefore
|
||||
* never arises: the user is the subject, so being signed in is the whole of
|
||||
* the authorisation.
|
||||
*/
|
||||
#[Route('/insights', name: 'app_insight_')]
|
||||
#[IsGranted('IS_AUTHENTICATED')]
|
||||
final class InsightPaneController extends AbstractController
|
||||
{
|
||||
use ChecksCsrf;
|
||||
|
||||
public function __construct(
|
||||
private readonly InsightPane $pane,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The fragment. Cheap by construction: the switch is read off the settings
|
||||
* bag already in memory on the User, so a user who turned the strip off
|
||||
* never reaches the database at all, and everybody else pays one windowed,
|
||||
* three-row query. When it comes back empty the template renders nothing,
|
||||
* which is the common case and the one that has to stay free.
|
||||
*
|
||||
* `now` in UTC, the way HappeningSoonController resolves it and for the
|
||||
* same reason: every instant in this database is UTC, the window this is
|
||||
* compared against is an interval and not a wall clock, and a zone only
|
||||
* enters when a template turns an instant back into digits — which is
|
||||
* UserTimezoneResolver's job, in Twig, not this controller's.
|
||||
*/
|
||||
#[Route('/pane', name: 'pane', methods: ['GET'])]
|
||||
public function pane(): Response
|
||||
{
|
||||
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
|
||||
|
||||
return $this->render('insight/_pane.html.twig', [
|
||||
'rows' => $this->pane->rowsFor($this->currentUser(), $now),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Not now." Stamps the instant the strip was waved away; what comes back
|
||||
* afterwards is decided by comparing that instant against each insight's
|
||||
* createdAt, which is InsightPane::isDismissed()'s business.
|
||||
*
|
||||
* A second dismiss RE-STAMPS, and that is the opposite of what the
|
||||
* neighbouring InsightActionsController::dismiss does — deliberately. That
|
||||
* one keeps the first click's instant because it records a fact about one
|
||||
* card: when the user said no to THIS parcel, and a slow double-click must
|
||||
* not move it. This records a position on a moving list. The only reason
|
||||
* the strip is on screen to be dismissed twice is that something newer
|
||||
* arrived since the last dismissal, so the second click means "and that
|
||||
* one too" — keeping the older stamp would leave the newer insight
|
||||
* unclaimed and bring the strip straight back.
|
||||
*
|
||||
* No `if` around the write, then: there is no state in which re-stamping
|
||||
* is wrong, so guarding it would only be a branch that never pays.
|
||||
*/
|
||||
#[Route('/pane/dismiss', name: 'pane_dismiss', methods: ['POST'])]
|
||||
public function dismiss(Request $request, EntityManagerInterface $em): JsonResponse
|
||||
{
|
||||
$this->assertCsrf($request, 'insight-pane-dismiss');
|
||||
|
||||
$dismissedAt = new DateTimeImmutable('now', new DateTimeZone('UTC'));
|
||||
|
||||
$this->currentUser()->setSetting(
|
||||
User::SETTING_INSIGHT_PANE_DISMISSED_AT,
|
||||
$dismissedAt->format(DateTimeImmutable::ATOM),
|
||||
);
|
||||
|
||||
$em->flush();
|
||||
|
||||
return $this->json(['ok' => true]);
|
||||
}
|
||||
|
||||
/** Narrowed the way HappeningSoonController narrows it — IS_AUTHENTICATED is not a type. */
|
||||
private function currentUser(): User
|
||||
{
|
||||
$user = $this->getUser();
|
||||
|
||||
if (false === $user instanceof User) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -16,17 +16,44 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* One endpoint: which insight extractors this user has switched off.
|
||||
* One endpoint: which of the switches on Settings → Insights this user has
|
||||
* turned off.
|
||||
*
|
||||
* The setting stores DISABLED keys rather than enabled ones — see
|
||||
* User::SETTING_INSIGHTS_DISABLED for why — so "toggle on" is a removal and
|
||||
* "toggle off" an addition, and an extractor nobody ever touched is simply
|
||||
* absent from the list.
|
||||
*
|
||||
* Two kinds of switch arrive here, not one. Most rows are extractors, keyed by
|
||||
* the registry. One row is the strip above the mail list, which is not an
|
||||
* extractor and does not live in that list — see PANE_KEY. They share this
|
||||
* action because they share a control: the segments are one Stimulus
|
||||
* controller (assets/controllers/settings/insights_controller.js) posting
|
||||
* `key` and `enabled` for whichever row moved, and giving the strip an
|
||||
* endpoint of its own would mean a second controller, a second token and a
|
||||
* second revert-on-failure, all to write a different key in the same bag.
|
||||
*/
|
||||
#[Route('/settings/insights', name: 'app_settings_insights_')]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
final class InsightSettingsController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* The row that is not an extractor: the strip above the mail list.
|
||||
*
|
||||
* Reserved rather than derived, and checked before the registry, so an
|
||||
* extractor shipped later under this key cannot quietly take the strip's
|
||||
* switch away from it. It is safe to reserve because it names a REGION and
|
||||
* every extractor key names a SOURCE — `parcel`, `flight`, `invoice` — so
|
||||
* nothing that belongs in the registry would ever want it.
|
||||
*
|
||||
* It writes User::SETTING_INSIGHT_PANE_DISABLED and not a member of
|
||||
* SETTING_INSIGHTS_DISABLED, which is the whole distinction that constant's
|
||||
* doc draws: switching the strip off hides a band above the inbox, it does
|
||||
* not stop a single fact being found. The extractors keep running, the
|
||||
* radar panel keeps filling, and the strips inside conversations stay.
|
||||
*/
|
||||
public const string PANE_KEY = 'pane';
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly InsightExtractorRegistry $extractors,
|
||||
@@ -56,7 +83,21 @@ final class InsightSettingsController extends AbstractController
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$key = $request->request->getString('key');
|
||||
$key = $request->request->getString('key');
|
||||
$enabled = $request->request->getBoolean('enabled');
|
||||
|
||||
if (self::PANE_KEY === $key) {
|
||||
// DISABLED and not ENABLED, so absent still means on: a user who
|
||||
// never opened this page has no key, and InsightPane reads that
|
||||
// absence as the feature being on. Switching it back on writes
|
||||
// false rather than removing the key — the bag has no remove, and
|
||||
// false and absent are the same answer to isDisabledFor().
|
||||
$user->setSetting(User::SETTING_INSIGHT_PANE_DISABLED, false === $enabled);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json(['ok' => true]);
|
||||
}
|
||||
|
||||
$known = array_map(
|
||||
static fn (InsightExtractorInterface $extractor): string => $extractor::key(),
|
||||
@@ -73,7 +114,7 @@ final class InsightSettingsController extends AbstractController
|
||||
$stored = $user->getSetting(User::SETTING_INSIGHTS_DISABLED);
|
||||
$disabled = is_array($stored) ? array_values(array_filter($stored, is_string(...))) : [];
|
||||
|
||||
if (true === $request->request->getBoolean('enabled')) {
|
||||
if (true === $enabled) {
|
||||
$disabled = array_values(array_diff($disabled, [$key]));
|
||||
} elseif (false === in_array($key, $disabled, true)) {
|
||||
$disabled[] = $key;
|
||||
|
||||
@@ -484,6 +484,33 @@ class User extends UserEntityModel implements UserInterface, PasswordAuthenticat
|
||||
*/
|
||||
public const string SETTING_INSIGHTS_DISABLED = 'insights.disabled_extractors';
|
||||
|
||||
/**
|
||||
* Whether the insight strip above the mail list is switched off.
|
||||
*
|
||||
* Absent means ON, the rule every switch in this bag follows and the same
|
||||
* one SETTING_INSIGHTS_DISABLED gives its reasons for: a feature that
|
||||
* shipped switched off is a feature nobody finds. Distinct from that key
|
||||
* on purpose — that one silences a SOURCE everywhere, including the strip
|
||||
* inside a conversation and the radar panel, while this one is only about
|
||||
* whether the mail list wears a strip above it. Somebody who wants parcels
|
||||
* found but does not want a band over their inbox is asking for this key,
|
||||
* and squeezing both meanings into one switch would make that unsayable.
|
||||
*/
|
||||
public const string SETTING_INSIGHT_PANE_DISABLED = 'insights.pane_disabled';
|
||||
|
||||
/**
|
||||
* When the strip was last waved away, as an ATOM string.
|
||||
*
|
||||
* A timestamp rather than a boolean, because "dismiss" here means "not
|
||||
* now" and not "never": the strip returns the moment an insight it has
|
||||
* never shown is extracted, which is decided by comparing that insight's
|
||||
* createdAt against this instant. A boolean could only have meant "hidden
|
||||
* forever", which is what the setting above is for — and a dismissal that
|
||||
* silently retired the feature would be a mis-click nobody could undo
|
||||
* without finding a settings page they have no reason to suspect.
|
||||
*/
|
||||
public const string SETTING_INSIGHT_PANE_DISMISSED_AT = 'insights.pane_dismissed_at';
|
||||
|
||||
/**
|
||||
* Whether a forward opens with its quoted original folded behind the
|
||||
* "show quoted text" pill. TRUE (fold) when unset — the pill is the
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Service\Insight;
|
||||
|
||||
use App\Domain\DTO\Mail\PostIngestResult;
|
||||
use App\Domain\Interface\PostIngestStepInterface;
|
||||
use App\Entity\User\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
@@ -17,12 +18,22 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
* the only job left here is the flush — once, after the batch, and only when
|
||||
* something was actually written. The pipeline's own catch shields the sync
|
||||
* from this step entirely; see PostIngestStepInterface.
|
||||
*
|
||||
* The Mercure announcement belongs here for the same reason the flush does:
|
||||
* this is the one place that knows whether the batch wrote anything, and for
|
||||
* whom. The harvester returns a count per message but has no notion of a
|
||||
* batch, and the step above has no notion of what was written — so a publish
|
||||
* anywhere else would either fire on a sync that changed nothing (waking every
|
||||
* open tab to re-request a fragment that comes back identical) or fire once
|
||||
* per insight, which for a nightly catch-up of thirty parcels is thirty
|
||||
* identical updates for one strip.
|
||||
*/
|
||||
final readonly class ExtractInsightsStep implements PostIngestStepInterface
|
||||
{
|
||||
public function __construct(
|
||||
private InsightHarvester $harvester,
|
||||
private EntityManagerInterface $em,
|
||||
private InsightNotifier $notifier,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -30,8 +41,28 @@ final readonly class ExtractInsightsStep implements PostIngestStepInterface
|
||||
{
|
||||
$written = 0;
|
||||
|
||||
/** @var array<int, User> $touched users whose strip has something new, keyed by id */
|
||||
$touched = [];
|
||||
|
||||
foreach ($result->messages as $message) {
|
||||
$written += $this->harvester->harvest($message);
|
||||
$count = $this->harvester->harvest($message);
|
||||
|
||||
if (0 === $count) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$written += $count;
|
||||
|
||||
// Keyed by id, so a batch is one update per user however many of
|
||||
// their messages carried facts. A batch is usually one account and
|
||||
// therefore one user, but Gmail's handler ingests per account
|
||||
// inside a loop and nothing here should depend on that staying
|
||||
// true.
|
||||
$user = $message->account->usr;
|
||||
|
||||
if ($user instanceof User) {
|
||||
$touched[$user->id] = $user;
|
||||
}
|
||||
}
|
||||
|
||||
if (0 === $written) {
|
||||
@@ -39,5 +70,15 @@ final readonly class ExtractInsightsStep implements PostIngestStepInterface
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
// After the flush, never before: the update tells tabs to come and
|
||||
// read, and a tab that reads before the rows are committed sees the
|
||||
// strip it had. The publish itself is not worth failing the sync over,
|
||||
// but it does not need its own catch — PostIngestStepInterface's
|
||||
// contract is that the pipeline swallows whatever this step throws,
|
||||
// and the rows are already durable by the time it can throw.
|
||||
foreach ($touched as $user) {
|
||||
$this->notifier->publishInsightsChanged($user);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Insight;
|
||||
|
||||
use App\Entity\User\User;
|
||||
use Symfony\Component\Mercure\HubInterface;
|
||||
use Symfony\Component\Mercure\Update;
|
||||
|
||||
/**
|
||||
* Tells a user's open tabs that their insights moved.
|
||||
*
|
||||
* ── Why not a third method on SyncNotifier ──────────────────────────────────
|
||||
* It would have fit: same hub, same `mail/user/{id}` topic, same envelope. It
|
||||
* is a separate class because of what the two are ABOUT. SyncNotifier speaks
|
||||
* for the mail sync — its two updates both carry an account or a mailbox id,
|
||||
* and its own class doc is a note about what the sync handlers do and do not
|
||||
* dispatch alongside them. This speaks for the insight pipeline, which runs
|
||||
* as one post-ingest step among several and can be switched off per extractor
|
||||
* per user without the sync noticing. Widening SyncNotifier would have made
|
||||
* `App\Service\Mail` the home of an announcement no mail service makes, and
|
||||
* given ExtractInsightsStep a dependency on the sync's notifier to say
|
||||
* something the sync has no opinion about.
|
||||
*
|
||||
* The payload carries no ids on purpose — no insight id, no count. The
|
||||
* receiver is a lazy turbo-frame whose only move is to re-request
|
||||
* app_insight_pane, and that request re-derives everything: the off-switch,
|
||||
* the dismissal, the window, the cap. Anything sent here would be a second
|
||||
* copy of a rule that already has one home ({@see InsightPane}), and the first
|
||||
* time the two disagreed the strip would show a row the fragment would not.
|
||||
*/
|
||||
final readonly class InsightNotifier
|
||||
{
|
||||
public function __construct(
|
||||
private HubInterface $hub,
|
||||
) {
|
||||
}
|
||||
|
||||
public function publishInsightsChanged(User $user): void
|
||||
{
|
||||
$this->hub->publish(new Update(
|
||||
topics: [
|
||||
sprintf('mail/user/%d', $user->id),
|
||||
],
|
||||
data: json_encode([
|
||||
'type' => 'insights.changed',
|
||||
]),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Insight;
|
||||
|
||||
use App\Entity\Insight\MailInsight;
|
||||
use App\Entity\User\User;
|
||||
use App\Repository\Insight\MailInsightRepository;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Whether the mail list wears a strip of insights above it, and which ones.
|
||||
*
|
||||
* One place for a rule three readers need — the fragment that renders the
|
||||
* strip, the dismiss endpoint that has to know whether anything is left to
|
||||
* show, and the tests. Spread across those it would be three derivations of
|
||||
* the same sentence, which is the duplication OwnershipVoter's class doc
|
||||
* argues against at length.
|
||||
*
|
||||
* ── This reverses a decision that was written down ───────────────────────────
|
||||
* HappeningSoonController rejected exactly this panel, and its reasons were
|
||||
* good ones: a fourth region would take width from panes that have none to
|
||||
* give, and it would query on every mailbox load for a list that is empty for
|
||||
* most people on most days. Both are answered rather than ignored.
|
||||
*
|
||||
* The strip takes HEIGHT and not width — it is a band above the list, not a
|
||||
* column beside it — so no pane loses a pixel it had. And it costs nothing on
|
||||
* a mailbox load, because it is not rendered with the page: it arrives in a
|
||||
* lazy turbo-frame, so the query runs after the mail is on screen and only for
|
||||
* people who have the feature on. When there is nothing to show it renders
|
||||
* nothing at all, which is the common case and has to stay free.
|
||||
*
|
||||
* What is genuinely different from the panel that was rejected is the audience.
|
||||
* Happening Soon answers "what is coming up?" for somebody who went looking;
|
||||
* this answers "your parcel is out for delivery" for somebody who did not, and
|
||||
* that only works where the mail already is.
|
||||
*/
|
||||
final readonly class InsightPane
|
||||
{
|
||||
/**
|
||||
* How many rows the strip may carry.
|
||||
*
|
||||
* Three, because the strip's whole claim is that it costs less attention
|
||||
* than the mail it sits above — and a fourth row starts pushing the list
|
||||
* off a laptop screen, at which point it is competing with the inbox
|
||||
* rather than introducing it. What does not fit is not lost: the radar
|
||||
* panel behind the topbar indicator holds the full list, and it is the
|
||||
* thing to open when three is not the answer.
|
||||
*/
|
||||
public const int MAX_ROWS = 3;
|
||||
|
||||
public function __construct(
|
||||
private MailInsightRepository $insights,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows to render, or none at all — an empty list means the strip does
|
||||
* not exist for this request, whichever of the three reasons applies.
|
||||
*
|
||||
* @return list<MailInsight> soonest first
|
||||
*/
|
||||
public function rowsFor(User $user, DateTimeImmutable $now): array
|
||||
{
|
||||
if (true === $this->isDisabledFor($user)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->insights->upcomingForUser($user, $now, limit: self::MAX_ROWS);
|
||||
|
||||
if ([] === $rows) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return true === $this->isDismissed($user, $rows) ? [] : $rows;
|
||||
}
|
||||
|
||||
/** The settings switch, which is off-until-switched-on's opposite: on until switched off. */
|
||||
public function isDisabledFor(User $user): bool
|
||||
{
|
||||
return true === $user->getSetting(User::SETTING_INSIGHT_PANE_DISABLED, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waved away, and nothing has happened since that it has not already
|
||||
* shown.
|
||||
*
|
||||
* The comparison is against each row's createdAt — when the FACT was
|
||||
* first landed — rather than against its updatedAt. A carrier revising an
|
||||
* estimate rewrites the row it already showed you, and treating that as
|
||||
* news would bring the strip back for a parcel the user dismissed twenty
|
||||
* minutes ago, over and over, for the whole of its journey.
|
||||
*
|
||||
* @param list<MailInsight> $rows
|
||||
*/
|
||||
public function isDismissed(User $user, array $rows): bool
|
||||
{
|
||||
$dismissedAt = $this->dismissedAt($user);
|
||||
|
||||
if (null === $dismissedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if ($row->createdAt > $dismissedAt) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** When the strip was last waved away, or null if it never was. */
|
||||
public function dismissedAt(User $user): ?DateTimeImmutable
|
||||
{
|
||||
$stored = $user->getSetting(User::SETTING_INSIGHT_PANE_DISMISSED_AT);
|
||||
|
||||
if (false === is_string($stored) || '' === $stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A value that is not a date is treated as no dismissal rather than as
|
||||
// an error: this bag is free-form jsonb, and a strip that refuses to
|
||||
// render because of a malformed preference is a worse failure than a
|
||||
// strip that comes back once.
|
||||
try {
|
||||
return new DateTimeImmutable($stored);
|
||||
} catch (\Exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,40 @@ Pagination variables expected from the controller (all optional, fallback to 0/1
|
||||
every row stayed on one line and truncated to nothing. #}
|
||||
class="@container flex flex-col flex-1 overflow-hidden {% block list_hidden %}{% endblock %}"
|
||||
>
|
||||
{# ── The insight strip ─────────────────────────────
|
||||
A band of up to three cards — a parcel out for
|
||||
delivery, a flight tomorrow — above the list rather
|
||||
than beside it.
|
||||
|
||||
HEIGHT, not width, and that is the whole reason it is
|
||||
allowed to be here at all. HappeningSoonController
|
||||
turned this panel down because a fourth REGION would
|
||||
take width from panes that have none to give; a band
|
||||
takes a little of the list's own vertical run and
|
||||
leaves the sidebar, the reading pane and the calendar
|
||||
at exactly the widths they had. InsightPane's class
|
||||
doc records both halves of that trade.
|
||||
|
||||
Inside the list pane rather than above the whole
|
||||
content column, so it spans the LIST's width and
|
||||
narrows when the reading pane opens. Deliberate: the
|
||||
strip introduces the inbox, and a band still running
|
||||
the full width over an open mail would be a header
|
||||
for something the reader has already left.
|
||||
|
||||
Outside `list_frame`, so a background list refresh
|
||||
re-renders fifty rows without touching it — the strip
|
||||
has its own refresh, on its own event.
|
||||
|
||||
LAZY, and that is not an optimisation. The query is
|
||||
empty for most people on most days and a mailbox
|
||||
render must not pay for it: the frame fetches after
|
||||
the mail is on screen, and renders nothing when there
|
||||
is nothing to say. #}
|
||||
<turbo-frame id="insight_pane"
|
||||
src="{{ path('app_insight_pane') }}"
|
||||
loading="lazy"></turbo-frame>
|
||||
|
||||
{{ block('list_frame') }}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
{#
|
||||
insight/_pane.html.twig
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
The insight strip: a band of up to three cards above the mail list, saying the
|
||||
thing before the mail that carries it does — a parcel out for delivery, a
|
||||
flight tomorrow, a pull request waiting on eyes.
|
||||
|
||||
Rendered alone, as the whole response of the lazy `app_insight_pane` fragment,
|
||||
into the frame the mailbox layout leaves for it.
|
||||
|
||||
Accepts:
|
||||
rows list<MailInsight> from InsightPane::rowsFor(), soonest first, already
|
||||
capped at InsightPane::MAX_ROWS and already EMPTY for all three of the
|
||||
reasons the strip may not exist — switched off, nothing upcoming, waved
|
||||
away. This template applies none of those rules and re-checks none of
|
||||
them; it renders what it is given.
|
||||
|
||||
An empty `rows` renders NOTHING — no heading, no empty state, no bordered box.
|
||||
That is not politeness, it is the deal InsightPane's class doc strikes with
|
||||
HappeningSoonController: this band is allowed above the list only because it
|
||||
costs nothing on the days it has nothing to say, and those are most days for
|
||||
most people. An "on your radar" box saying it looked and found nothing would
|
||||
spend attention above the inbox every morning to report an absence.
|
||||
|
||||
The frame element itself is still rendered in that case, and has to be: it is
|
||||
what Turbo swaps the lazy load into, and a response without it is a frame that
|
||||
never finishes loading. Empty, it has no content and therefore no height.
|
||||
|
||||
HEIGHT, not width. The strip is a band above the list rather than a column
|
||||
beside it, so no pane loses a pixel it had — that is the answer InsightPane
|
||||
gives to the objection on the record against a fourth region, and it is why
|
||||
everything below is sized by its content and nothing here grows.
|
||||
|
||||
The card vocabulary is _insight_strip's and the radar panel's, deliberately:
|
||||
the same kind chip, the same status tones, the same one primary action. Three
|
||||
visual languages for one object would make a parcel look like three different
|
||||
things depending on where the user met it.
|
||||
|
||||
One Stimulus scope over the whole band, so both CSRF tokens are minted once
|
||||
rather than once per row — the split radar_controller documents, plus the
|
||||
pane-wide token the ✕ needs.
|
||||
#}
|
||||
|
||||
{% set rows = rows|default([]) %}
|
||||
|
||||
<turbo-frame id="insight_pane">
|
||||
{% if rows is not empty %}
|
||||
<div data-insight-pane
|
||||
data-controller="insight--pane"
|
||||
data-insight--pane-token-value="{{ csrf_token('insight-dismiss') }}"
|
||||
data-insight--pane-pane-token-value="{{ csrf_token('insight-pane-dismiss') }}"
|
||||
data-insight--pane-pane-url-value="{{ path('app_insight_pane_dismiss') }}"
|
||||
class="shrink-0 rounded-pane border border-line bg-surface px-3 py-2 mb-gutter shadow-sm">
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-ink-faint">
|
||||
{{ 'insights.pane.heading'|trans }}
|
||||
</p>
|
||||
|
||||
<button type="button"
|
||||
data-action="click->insight--pane#dismissPane"
|
||||
data-insight-pane-dismiss
|
||||
aria-label="{{ 'insights.pane.dismiss_all'|trans }}"
|
||||
title="{{ 'insights.pane.dismiss_all'|trans }}"
|
||||
class="-mr-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full
|
||||
text-ink-faint hover:bg-hover hover:text-ink transition-colors">
|
||||
<i class="fa-solid fa-xmark text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="mt-1.5 divide-y divide-line" data-insight-list>
|
||||
{% for insight in rows %}
|
||||
{% set trackingUrl = insight.payload.trackingUrl|default(null) %}
|
||||
{% set externalUrl = insight.payload.url|default(null) %}
|
||||
{% set status = insight.payload.status|default(null) %}
|
||||
{# Only the vocabulary the catalogue knows — an extractor's
|
||||
stray status must not print a raw key onto a card. The
|
||||
one place raw colours are spelled out, and the same five
|
||||
lines the radar and the reading-pane strip use. #}
|
||||
{% set statusTone = {
|
||||
'announced': 'border-ink-faint/40 text-ink-muted',
|
||||
'in_transit': 'border-sky-500/40 text-sky-700 dark:text-sky-300',
|
||||
'out_for_delivery': 'border-amber-500/40 text-amber-700 dark:text-amber-300',
|
||||
'delivered': 'border-green-500/40 text-green-700 dark:text-green-300',
|
||||
}[status] ?? null %}
|
||||
|
||||
<li data-insight-card
|
||||
data-insight-id="{{ insight.id }}"
|
||||
data-insight-kind="{{ insight.kind.value }}"
|
||||
class="flex items-center gap-2.5 py-1.5">
|
||||
|
||||
{% include 'insight/_kind_chip.html.twig' with { kind: insight.kind, size: 'h-8 w-8' } only %}
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-semibold text-ink" title="{{ insight.title }}">
|
||||
{{ insight.title }}
|
||||
</p>
|
||||
|
||||
<p class="truncate text-xs text-ink-faint">
|
||||
{% if insight.happensAt is not null %}
|
||||
{# The near-date ladder the message list already
|
||||
uses (see _partials/_thread_row.html.twig):
|
||||
compare whole days as the reader's clock has
|
||||
them, then hand the wording to `intl_date` so
|
||||
German gets its own field order rather than an
|
||||
English pattern with German words in it.
|
||||
|
||||
A week is the width of the ladder because that
|
||||
is how long a weekday name stays unambiguous —
|
||||
"Thursday" nine days out is a puzzle, not a
|
||||
date. #}
|
||||
{% set day = insight.happensAt|date('Y-m-d') %}
|
||||
{% set today = 'now'|date('Y-m-d') %}
|
||||
{% set nextDay = 'now'|date_modify('+1 day')|date('Y-m-d') %}
|
||||
|
||||
{% if day == today %}
|
||||
{{ 'insights.pane.when.today'|trans }}
|
||||
{% elseif day == nextDay %}
|
||||
{{ 'insights.pane.when.tomorrow'|trans }}
|
||||
{% elseif day > today and day <= 'now'|date_modify('+6 days')|date('Y-m-d') %}
|
||||
{{ insight.happensAt|intl_date('EEE') }}
|
||||
{% elseif insight.happensAt|date('Y') == 'now'|date('Y') %}
|
||||
{{ insight.happensAt|intl_date('dMMM') }}
|
||||
{% else %}
|
||||
{{ insight.happensAt|intl_date('dMMMy') }}
|
||||
{% endif %}
|
||||
|
||||
{#- A parcel's ETA is a day, not an hour — its
|
||||
midnight is a placeholder, not a time. -#}
|
||||
{%- if insight.happensAt|date('His') != '000000' %} · {{ insight.happensAt|date(clock.time) }}{% endif %}
|
||||
{% else %}
|
||||
{# A guard, not a feature. InsightPane hands
|
||||
this template only DATED rows — the radar
|
||||
panel is where the undated family lives, and
|
||||
a strip about what is coming up has nothing
|
||||
to say about a row with no when. The column
|
||||
is nullable all the same, and a blank line
|
||||
under a title reads as a render that failed,
|
||||
so the kind speaks instead. #}
|
||||
{{ insight.kind.transKey|trans }}
|
||||
{% endif %}
|
||||
|
||||
{% if statusTone is not null %}
|
||||
<span data-insight-status="{{ status }}"
|
||||
class="ml-1 inline-flex items-center rounded-full border px-2 py-px
|
||||
text-[11px] leading-[1.35] font-medium {{ statusTone }}">
|
||||
{{ ('insights.status.' ~ status)|trans }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# One primary action per row: the tracking page where there
|
||||
is one, else the thing on GitHub. Never both — the strip's
|
||||
whole claim is that it costs less attention than the mail
|
||||
under it, and a row with two buttons has neither pressed. #}
|
||||
{% if trackingUrl is not empty %}
|
||||
<a href="{{ trackingUrl }}"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
data-insight-track
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-accent px-2.5 py-1
|
||||
text-xs font-medium text-accent-ink hover:bg-accent-strong transition-colors">
|
||||
<i class="fa-solid fa-location-arrow text-[10px]" aria-hidden="true"></i>
|
||||
{{ 'insights.panel.track'|trans }}
|
||||
</a>
|
||||
{% elseif externalUrl is not empty %}
|
||||
{# The brand's own name rather than a catalogue key —
|
||||
"GitHub" is not a word any locale translates. #}
|
||||
<a href="{{ externalUrl }}"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
data-insight-open-url
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line px-2.5 py-1
|
||||
text-xs font-medium text-ink-muted hover:bg-hover hover:text-ink transition-colors">
|
||||
<i class="fa-brands fa-github text-[11px]" aria-hidden="true"></i>
|
||||
GitHub
|
||||
<i class="fa-solid fa-arrow-up-right-from-square text-[9px]" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# The row's own overflow menu. A second bare ✕ beside the
|
||||
strip's would be two identical marks a pixel apart meaning
|
||||
two different scopes — "this one" and "all of them" — which
|
||||
is the press nobody can take back. Behind a `⋮` the item
|
||||
can say which it is. #}
|
||||
<div class="relative shrink-0" data-controller="ui--dropdown">
|
||||
<button type="button"
|
||||
data-action="click->ui--dropdown#toggle"
|
||||
aria-label="{{ 'insights.pane.more'|trans }}"
|
||||
title="{{ 'insights.pane.more'|trans }}"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-full
|
||||
text-ink-faint hover:bg-hover hover:text-ink transition-colors">
|
||||
<i class="fa-solid fa-ellipsis-vertical text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<div data-ui--dropdown-target="menu" hidden
|
||||
class="absolute right-0 top-full mt-1 z-30 w-44
|
||||
rounded-pane bg-pane-soft backdrop-blur-sm
|
||||
border border-line shadow-pop py-1 text-sm">
|
||||
<button type="button"
|
||||
data-action="click->insight--pane#dismiss click->ui--dropdown#close"
|
||||
data-insight--pane-url-param="{{ path('app_insight_dismiss', { id: insight.id }) }}"
|
||||
data-insight-dismiss
|
||||
class="flex w-full items-center gap-2 px-4 py-2 text-left text-ink-soft
|
||||
hover:bg-hover transition-colors">
|
||||
<i class="fa-solid fa-xmark w-4 text-ink-faint" aria-hidden="true"></i>
|
||||
{{ 'insights.pane.dismiss'|trans }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
@@ -34,6 +34,53 @@ Variables:
|
||||
data-settings--insights-url-value="{{ path('app_settings_insights_toggle') }}"
|
||||
data-settings--insights-token-value="{{ csrf_token('insights_toggle') }}">
|
||||
|
||||
{#
|
||||
The strip above the mail list. Not an extractor and not in the loop below,
|
||||
because it is not a SOURCE — it is a region. Its own row rather than a
|
||||
section of its own, so the one page about insights holds every insight
|
||||
switch; the hint carries the distinction, since a reader who has just read
|
||||
eight rows that each stop a kind of card being found would otherwise read
|
||||
this one the same way.
|
||||
|
||||
The state is read straight off the user rather than passed in like
|
||||
insightExtractors: SettingsController::insightsSectionData() maps the
|
||||
extractor registry, and there is no registry entry to map for this. Adding
|
||||
one variable there to carry one boolean the template can already see would
|
||||
be a hop with no decision in it.
|
||||
|
||||
It posts through the same controller and the same token as every row below,
|
||||
with the reserved key InsightSettingsController::PANE_KEY.
|
||||
#}
|
||||
{% set paneKey = constant('App\\Controller\\Settings\\InsightSettingsController::PANE_KEY') %}
|
||||
{% set paneOn = app.user.getSetting(constant('App\\Entity\\User\\User::SETTING_INSIGHT_PANE_DISABLED'), false) != true %}
|
||||
{# NOT `data-insight-extractor` — that attribute is the registry's census,
|
||||
and InsightSettingsTest counts it against InsightExtractorRegistry::all().
|
||||
This row is a region, not a source, and must not be counted as one. #}
|
||||
<div class="flex items-start justify-between gap-3" data-insight-pane-switch>
|
||||
<span class="flex items-start gap-3 min-w-0">
|
||||
<i class="fa-regular fa-rectangle-list w-4 text-zinc-400 mt-0.5 shrink-0" aria-hidden="true"></i>
|
||||
<span class="min-w-0">
|
||||
<span class="block text-xs font-medium text-ink-muted">{{ 'settings.insights.pane'|trans }}</span>
|
||||
<span class="block text-[11px] text-ink-faint mt-0.5">{{ 'settings.insights.pane_hint'|trans }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<div class="{{ segGroup }}" role="radiogroup" aria-label="{{ 'settings.insights.pane'|trans }}">
|
||||
{% for on in [true, false] %}
|
||||
<label class="cursor-pointer">
|
||||
<input type="radio" name="insight-{{ paneKey }}" class="peer sr-only"
|
||||
value="{{ on ? '1' : '0' }}"
|
||||
data-key="{{ paneKey }}"
|
||||
data-action="change->settings--insights#toggle"
|
||||
aria-label="{{ (on ? 'settings.insights.seg.on' : 'settings.insights.seg.off')|trans }}"
|
||||
{{ paneOn == on ? 'checked' : '' }}>
|
||||
<span class="{{ segText }}">
|
||||
{{ (on ? 'settings.insights.seg.on' : 'settings.insights.seg.off')|trans }}
|
||||
</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% for extractor in insightExtractors %}
|
||||
{% set nameKey = 'settings.insights.extractor.' ~ extractor.key %}
|
||||
{% set name = nameKey|trans %}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Insight;
|
||||
|
||||
use App\Domain\Enum\Insight\InsightKind;
|
||||
use App\Entity\Insight\MailInsight;
|
||||
use App\Entity\Mail\Account;
|
||||
use App\Entity\User\User;
|
||||
use App\Service\Insight\InsightPane;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* The strip above the mail list, as MARKUP: what `insight/_pane.html.twig`
|
||||
* draws for a set of rows, what it draws for none, and the frame the mailbox
|
||||
* layout leaves for it.
|
||||
*
|
||||
* Pinned as requests rather than as a Twig render, for the reason
|
||||
* RadarPanelTest gives: what matters is what a browser receives. Everything is
|
||||
* selected by the data attributes the template stamps — `data-insight-pane`,
|
||||
* `data-insight-card`, `data-insight-kind` — and never by a translated string,
|
||||
* because the catalogues belong to another workstream and a test that grepped
|
||||
* for "Happening soon" would fail in German for reasons no assertion states.
|
||||
*
|
||||
* The claim worth the most here is the SECOND one. An empty strip must render
|
||||
* nothing at all — no heading, no bordered band, no empty state — because that
|
||||
* is the case InsightPane's class doc trades on to justify a band above the
|
||||
* inbox at all, and it is the case that happens to most people on most days.
|
||||
* The frame element itself still comes back, and that is asserted too: without
|
||||
* it Turbo has nothing to swap the lazy load into and the frame never finishes
|
||||
* loading.
|
||||
*
|
||||
* The mailbox page is checked for the FRAME and against the CARDS: lazy means
|
||||
* the strip is not in the page's own HTML, and a frame that had quietly become
|
||||
* eager would still pass an assertion that only looked for the frame.
|
||||
*
|
||||
* Fixtures follow RadarPanelTest exactly — a fresh user inside a transaction
|
||||
* that is never committed — rather than borrowing the seeded e2e user, whose
|
||||
* mailbox this suite would then have to leave as it found it.
|
||||
*/
|
||||
final class InsightPaneRenderTest extends WebTestCase
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
private Connection $connection;
|
||||
private User $user;
|
||||
private Account $account;
|
||||
private DateTimeImmutable $now;
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (true === isset($this->connection) && true === $this->connection->isTransactionActive()) {
|
||||
$this->connection->rollBack();
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testTheStripDrawsARowPerInsight(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
|
||||
$this->parcel();
|
||||
$this->ticket();
|
||||
|
||||
$crawler = $client->request('GET', '/insights/pane');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertCount(1, $crawler->filter('turbo-frame#insight_pane'));
|
||||
self::assertCount(1, $crawler->filter('[data-insight-pane]'));
|
||||
self::assertCount(2, $crawler->filter('[data-insight-card]'));
|
||||
|
||||
// The dated row: kind, status tone and the tracking link with the
|
||||
// attributes that make it safe to open.
|
||||
$parcel = $crawler->filter('[data-insight-card][data-insight-kind="parcel"]');
|
||||
self::assertCount(1, $parcel);
|
||||
self::assertCount(1, $parcel->filter('[data-insight-status="in_transit"]'));
|
||||
|
||||
$track = $parcel->filter('a[data-insight-track]');
|
||||
self::assertCount(1, $track);
|
||||
self::assertSame('https://tracking.example.test/PKG-1', $track->attr('href'));
|
||||
self::assertSame('_blank', $track->attr('target'));
|
||||
self::assertStringContainsString('noopener', (string) $track->attr('rel'));
|
||||
|
||||
// A row with no tracking page falls through to its `url` instead, and
|
||||
// gets the quieter of the two buttons — never both.
|
||||
$ticket = $crawler->filter('[data-insight-card][data-insight-kind="ticket"]');
|
||||
self::assertCount(1, $ticket);
|
||||
self::assertCount(0, $ticket->filter('a[data-insight-track]'));
|
||||
self::assertSame(
|
||||
'https://tickets.example.test/T-9',
|
||||
$ticket->filter('a[data-insight-open-url]')->attr('href'),
|
||||
);
|
||||
|
||||
// Every row carries its own dismissal, pointed at the per-insight
|
||||
// endpoint — the strip's own ✕ is a different scope and a different
|
||||
// route, and both are on the page exactly once each.
|
||||
self::assertCount(2, $crawler->filter('[data-insight-card] [data-insight-dismiss]'));
|
||||
self::assertCount(1, $crawler->filter('[data-insight-pane-dismiss]'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing to say, nothing drawn. The frame comes back so the lazy load can
|
||||
* finish; everything inside it is absent.
|
||||
*/
|
||||
public function testAnEmptyStripRendersNothingAtAll(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
|
||||
$crawler = $client->request('GET', '/insights/pane');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertCount(1, $crawler->filter('turbo-frame#insight_pane'), 'the frame must come back even when empty');
|
||||
self::assertCount(0, $crawler->filter('[data-insight-pane]'), 'an empty strip drew a band');
|
||||
self::assertCount(0, $crawler->filter('[data-insight-card]'));
|
||||
self::assertCount(0, $crawler->filter('[data-insight-pane-dismiss]'));
|
||||
self::assertSame(
|
||||
'',
|
||||
trim($crawler->filter('turbo-frame#insight_pane')->text()),
|
||||
'an empty strip printed a heading or an empty state',
|
||||
);
|
||||
}
|
||||
|
||||
/** More than fits is not the template's problem, but nor may it draw more than it was given. */
|
||||
public function testTheStripDrawsNoMoreRowsThanItIsHanded(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
|
||||
for ($i = 0; $i < InsightPane::MAX_ROWS + 2; ++$i) {
|
||||
$this->insight(
|
||||
$this->account,
|
||||
InsightKind::Parcel,
|
||||
'Parcel ' . $i,
|
||||
$this->now->modify('+' . ($i + 1) . ' days'),
|
||||
);
|
||||
}
|
||||
|
||||
$crawler = $client->request('GET', '/insights/pane');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertCount(InsightPane::MAX_ROWS, $crawler->filter('[data-insight-card]'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The mailbox still renders, and it renders the frame rather than the
|
||||
* strip: lazy is the whole answer to "a mailbox load must not pay for this
|
||||
* query", so an eager frame here would be the feature's cost coming back.
|
||||
*/
|
||||
public function testTheMailboxCarriesTheLazyFrameAndNotTheStrip(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
|
||||
$this->parcel();
|
||||
|
||||
$crawler = $client->request('GET', '/mail/inbox');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$frame = $crawler->filter('turbo-frame#insight_pane');
|
||||
|
||||
self::assertCount(1, $frame);
|
||||
self::assertSame('/insights/pane', $frame->attr('src'));
|
||||
self::assertSame('lazy', $frame->attr('loading'));
|
||||
self::assertCount(0, $crawler->filter('[data-insight-card]'), 'the mailbox rendered the strip inline');
|
||||
|
||||
// Inside the list pane, so the band spans the list's width and narrows
|
||||
// when the reading pane opens — the placement the layout comments.
|
||||
self::assertCount(1, $crawler->filter('#message-list > turbo-frame#insight_pane'));
|
||||
}
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────
|
||||
|
||||
private function parcel(): MailInsight
|
||||
{
|
||||
return $this->insight(
|
||||
$this->account,
|
||||
InsightKind::Parcel,
|
||||
'DHL parcel from Zalando',
|
||||
$this->now->modify('+2 days')->setTime(9, 0),
|
||||
[
|
||||
'status' => 'in_transit',
|
||||
'trackingUrl' => 'https://tracking.example.test/PKG-1',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The second shape of row: dated like the parcel, but with a `url` where
|
||||
* the parcel has a `trackingUrl`.
|
||||
*
|
||||
* Dated on purpose, and not a pull request. InsightPane hands the strip
|
||||
* `upcomingForUser`, which windows on happensAt and therefore never
|
||||
* carries the undated GitHub family at all — those belong to the radar
|
||||
* panel, and a fixture that put one here would be asserting against a
|
||||
* combination the strip cannot be given.
|
||||
*/
|
||||
private function ticket(): MailInsight
|
||||
{
|
||||
return $this->insight(
|
||||
$this->account,
|
||||
InsightKind::Ticket,
|
||||
'Two seats, Thursday',
|
||||
$this->now->modify('+4 days')->setTime(19, 30),
|
||||
['url' => 'https://tickets.example.test/T-9'],
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $payload */
|
||||
private function insight(
|
||||
Account $account,
|
||||
InsightKind $kind,
|
||||
string $title,
|
||||
?DateTimeImmutable $happensAt,
|
||||
array $payload = [],
|
||||
): MailInsight {
|
||||
$insight = new MailInsight();
|
||||
$insight->account = $account;
|
||||
$insight->kind = $kind;
|
||||
$insight->title = $title;
|
||||
$insight->payload = $payload;
|
||||
$insight->happensAt = $happensAt;
|
||||
$insight->extractor = 'pane-test';
|
||||
$insight->dedupeKey = uniqid('pane-', true);
|
||||
|
||||
$this->em->persist($insight);
|
||||
$this->em->flush();
|
||||
|
||||
return $insight;
|
||||
}
|
||||
|
||||
private function signIn(): KernelBrowser
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->disableReboot();
|
||||
|
||||
$container = static::getContainer();
|
||||
$this->em = $container->get(EntityManagerInterface::class);
|
||||
$this->connection = $container->get(Connection::class);
|
||||
$this->now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
|
||||
|
||||
// Never committed, so the suite leaves nothing behind and can be run
|
||||
// repeatedly against the same database.
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
$this->user = $this->seedUser('insight-pane-');
|
||||
$this->account = $this->seedAccount($this->user);
|
||||
|
||||
$client->loginUser($this->user);
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function seedUser(string $prefix): User
|
||||
{
|
||||
$user = new User();
|
||||
$user->email = $prefix . uniqid('', true) . '@example.test';
|
||||
$user->nameFirst = 'Insight';
|
||||
$user->nameLast = 'Pane';
|
||||
$user->roles = ['ROLE_USER'];
|
||||
$user->password = '$2y$04$abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOP';
|
||||
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function seedAccount(User $user): Account
|
||||
{
|
||||
$account = new Account();
|
||||
$account->usr = $user;
|
||||
$account->name = 'Insight pane fixture';
|
||||
$account->email = uniqid('pane-', true) . '@example.test';
|
||||
$account->username = uniqid('pane-', true);
|
||||
$account->imapHost = 'localhost';
|
||||
$account->imapPort = 993;
|
||||
$account->imapEncryption = 'ssl';
|
||||
$account->smtpHost = 'localhost';
|
||||
$account->smtpPort = 587;
|
||||
$account->smtpEncryption = 'starttls';
|
||||
$account->password = 'x';
|
||||
$account->authType = 'password';
|
||||
$account->isActive = true;
|
||||
|
||||
$this->em->persist($account);
|
||||
$this->em->flush();
|
||||
|
||||
return $account;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Insight;
|
||||
|
||||
use App\Domain\Enum\Insight\InsightKind;
|
||||
use App\Entity\Insight\MailInsight;
|
||||
use App\Entity\Mail\Account;
|
||||
use App\Entity\User\User;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* The strip above the mail list, as requests: the fragment and the dismiss.
|
||||
*
|
||||
* Pinned as requests rather than against InsightPane, for the reason
|
||||
* RadarPanelTest gives — the rule itself has a unit test, and what this file
|
||||
* owns is the wiring around it: that the fragment answers on its own URL, that
|
||||
* the dismiss writes the key it claims to write and nothing else, that a
|
||||
* tokenless POST writes nothing at all, and that a dismissal is a fact about
|
||||
* ONE user's settings bag and not about the insights.
|
||||
*
|
||||
* Selected by the title the fixture set rather than by markup: the template is
|
||||
* the other stream's, and a test that greps for its class names would fail the
|
||||
* first time somebody restyles a band. Fixtures follow RadarPanelTest exactly
|
||||
* — a fresh user inside a transaction that is never committed.
|
||||
*/
|
||||
final class InsightPaneTest extends WebTestCase
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
private Connection $connection;
|
||||
private User $user;
|
||||
private Account $account;
|
||||
private DateTimeImmutable $now;
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (true === isset($this->connection) && true === $this->connection->isTransactionActive()) {
|
||||
$this->connection->rollBack();
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testTheFragmentDrawsTheUsersInsights(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
$this->parcel('DHL parcel from Zalando');
|
||||
|
||||
$client->request('GET', '/insights/pane');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertStringContainsString(
|
||||
'DHL parcel from Zalando',
|
||||
(string) $client->getResponse()->getContent(),
|
||||
);
|
||||
}
|
||||
|
||||
/** Nothing to say means nothing rendered — an empty band is still a band. */
|
||||
public function testAnEmptyStripRendersNoRows(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
|
||||
$crawler = $client->request('GET', '/insights/pane');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertCount(0, $crawler->filter('[data-insight-pane-row]'));
|
||||
}
|
||||
|
||||
/** The off-switch, from the settings bag straight through to the response. */
|
||||
public function testASwitchedOffStripRendersNoRows(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
$this->parcel('DHL parcel from Zalando');
|
||||
|
||||
$this->user->setSetting(User::SETTING_INSIGHT_PANE_DISABLED, true);
|
||||
$this->em->flush();
|
||||
|
||||
$client->request('GET', '/insights/pane');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertStringNotContainsString(
|
||||
'DHL parcel from Zalando',
|
||||
(string) $client->getResponse()->getContent(),
|
||||
);
|
||||
}
|
||||
|
||||
public function testDismissStampsTheSettingAndTheStripGoesQuiet(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
$this->parcel('DHL parcel from Zalando');
|
||||
|
||||
$client->request('POST', '/insights/pane/dismiss', [
|
||||
'_token' => $this->token($client),
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSame(
|
||||
['ok' => true],
|
||||
json_decode((string) $client->getResponse()->getContent(), true),
|
||||
);
|
||||
|
||||
$stored = $this->reread($this->user)->getSetting(User::SETTING_INSIGHT_PANE_DISMISSED_AT);
|
||||
self::assertIsString($stored);
|
||||
self::assertInstanceOf(DateTimeImmutable::class, new DateTimeImmutable($stored));
|
||||
|
||||
// And the insight itself was not touched: the strip was waved away,
|
||||
// not the parcel — InsightActionsController owns that verb.
|
||||
$client->request('GET', '/insights/pane');
|
||||
self::assertStringNotContainsString(
|
||||
'DHL parcel from Zalando',
|
||||
(string) $client->getResponse()->getContent(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A second dismiss re-stamps rather than keeping the first instant, which
|
||||
* is the opposite of InsightActionsController::dismiss and deliberate:
|
||||
* this records a position on a moving list, not a fact about one card.
|
||||
*/
|
||||
public function testASecondDismissMovesTheStampForward(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
$this->parcel('DHL parcel from Zalando');
|
||||
|
||||
$token = $this->token($client);
|
||||
|
||||
$this->user->setSetting(
|
||||
User::SETTING_INSIGHT_PANE_DISMISSED_AT,
|
||||
$this->now->modify('-2 days')->format(DateTimeImmutable::ATOM),
|
||||
);
|
||||
$this->em->flush();
|
||||
|
||||
$client->request('POST', '/insights/pane/dismiss', ['_token' => $token]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$stored = $this->reread($this->user)->getSetting(User::SETTING_INSIGHT_PANE_DISMISSED_AT);
|
||||
self::assertIsString($stored);
|
||||
self::assertGreaterThan(
|
||||
$this->now->modify('-1 hour'),
|
||||
new DateTimeImmutable($stored),
|
||||
'the second dismiss kept the older stamp',
|
||||
);
|
||||
}
|
||||
|
||||
public function testATokenlessDismissWritesNothing(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
$this->parcel('DHL parcel from Zalando');
|
||||
|
||||
$client->request('POST', '/insights/pane/dismiss');
|
||||
|
||||
self::assertSame(403, $client->getResponse()->getStatusCode());
|
||||
self::assertNull(
|
||||
$this->reread($this->user)->getSetting(User::SETTING_INSIGHT_PANE_DISMISSED_AT),
|
||||
'a tokenless POST dismissed the strip',
|
||||
);
|
||||
}
|
||||
|
||||
/** A dismissal is one user's, and a stranger's strip is untouched by it. */
|
||||
public function testAnotherUsersDismissalDoesNotAffectYours(): void
|
||||
{
|
||||
$client = $this->signIn();
|
||||
$this->parcel('DHL parcel from Zalando');
|
||||
|
||||
$stranger = $this->seedUser('pane-stranger-');
|
||||
$stranger->setSetting(
|
||||
User::SETTING_INSIGHT_PANE_DISMISSED_AT,
|
||||
$this->now->modify('+1 day')->format(DateTimeImmutable::ATOM),
|
||||
);
|
||||
$this->em->flush();
|
||||
|
||||
$client->request('GET', '/insights/pane');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertStringContainsString(
|
||||
'DHL parcel from Zalando',
|
||||
(string) $client->getResponse()->getContent(),
|
||||
"a stranger's dismissal hid this user's strip",
|
||||
);
|
||||
}
|
||||
|
||||
/** Signed out, both ends are the firewall's business and neither answers. */
|
||||
public function testTheFragmentIsNotPublic(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('GET', '/insights/pane');
|
||||
|
||||
self::assertTrue(
|
||||
$client->getResponse()->isRedirection() || 401 === $client->getResponse()->getStatusCode(),
|
||||
'the fragment answered an anonymous request',
|
||||
);
|
||||
}
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────
|
||||
|
||||
private function parcel(string $title): MailInsight
|
||||
{
|
||||
$insight = new MailInsight();
|
||||
$insight->account = $this->account;
|
||||
$insight->kind = InsightKind::Parcel;
|
||||
$insight->title = $title;
|
||||
$insight->payload = ['status' => 'in_transit', 'trackingUrl' => 'https://tracking.example.test/PKG-1'];
|
||||
$insight->happensAt = $this->now->modify('+2 days')->setTime(9, 0);
|
||||
$insight->extractor = 'pane-test';
|
||||
$insight->dedupeKey = uniqid('pane-', true);
|
||||
|
||||
$this->em->persist($insight);
|
||||
$this->em->flush();
|
||||
|
||||
return $insight;
|
||||
}
|
||||
|
||||
/**
|
||||
* The token under the id the strip's button posts, minted container-side.
|
||||
*
|
||||
* Read from the manager rather than off the rendered fragment on purpose:
|
||||
* that template belongs to the other stream and the attribute it stamps
|
||||
* the token into is theirs to name, while the token ID is the contract
|
||||
* this endpoint actually checks.
|
||||
*
|
||||
* The GET and the carrier request are the pattern CalendarDeletion-
|
||||
* RevokesPushTest established, and neither is incidental: the token store
|
||||
* is the session, there is no session until the client has made a request,
|
||||
* and the carrier is how the container-side manager is pointed at it.
|
||||
*/
|
||||
private function token(KernelBrowser $client): string
|
||||
{
|
||||
$client->request('GET', '/insights/pane');
|
||||
|
||||
$stack = static::getContainer()->get('request_stack');
|
||||
$carrier = new Request();
|
||||
$carrier->setSession($client->getRequest()->getSession());
|
||||
$stack->push($carrier);
|
||||
|
||||
try {
|
||||
return (string) static::getContainer()
|
||||
->get('security.csrf.token_manager')
|
||||
->getToken('insight-pane-dismiss')
|
||||
->getValue();
|
||||
} finally {
|
||||
$stack->pop();
|
||||
}
|
||||
}
|
||||
|
||||
/** The row as the database now has it, not as the last request left it. */
|
||||
private function reread(User $user): User
|
||||
{
|
||||
$id = $user->id;
|
||||
|
||||
$this->em->clear();
|
||||
|
||||
$found = $this->em->find(User::class, $id);
|
||||
|
||||
self::assertInstanceOf(User::class, $found);
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
private function signIn(): KernelBrowser
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->disableReboot();
|
||||
|
||||
$container = static::getContainer();
|
||||
$this->em = $container->get(EntityManagerInterface::class);
|
||||
$this->connection = $container->get(Connection::class);
|
||||
$this->now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
|
||||
|
||||
// Never committed, so the suite leaves nothing behind and can be run
|
||||
// repeatedly against the same database.
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
$this->user = $this->seedUser('insight-pane-');
|
||||
$this->account = $this->seedAccount($this->user);
|
||||
|
||||
$client->loginUser($this->user);
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function seedUser(string $prefix): User
|
||||
{
|
||||
$user = new User();
|
||||
$user->email = $prefix . uniqid('', true) . '@example.test';
|
||||
$user->nameFirst = 'Insight';
|
||||
$user->nameLast = 'Pane';
|
||||
$user->roles = ['ROLE_USER'];
|
||||
$user->password = '$2y$04$abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOP';
|
||||
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function seedAccount(User $user): Account
|
||||
{
|
||||
$account = new Account();
|
||||
$account->usr = $user;
|
||||
$account->name = 'Pane fixture';
|
||||
$account->email = uniqid('pane-', true) . '@example.test';
|
||||
$account->username = uniqid('pane-', true);
|
||||
$account->imapHost = 'localhost';
|
||||
$account->imapPort = 993;
|
||||
$account->imapEncryption = 'ssl';
|
||||
$account->smtpHost = 'localhost';
|
||||
$account->smtpPort = 587;
|
||||
$account->smtpEncryption = 'starttls';
|
||||
$account->password = 'x';
|
||||
$account->authType = 'password';
|
||||
$account->isActive = true;
|
||||
|
||||
$this->em->persist($account);
|
||||
$this->em->flush();
|
||||
|
||||
return $account;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Insight;
|
||||
|
||||
use App\Domain\Enum\Insight\InsightKind;
|
||||
use App\Entity\Insight\MailInsight;
|
||||
use App\Entity\User\User;
|
||||
use App\Repository\Insight\MailInsightRepository;
|
||||
use App\Service\Insight\InsightPane;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The one sentence that decides whether the mail list wears a strip.
|
||||
*
|
||||
* Held against a stubbed repository rather than the schema, unlike its sibling
|
||||
* InsightHarvesterTest — and the difference is deliberate. That file tests
|
||||
* LANDING, where every promise is the kind that passes in a mock and fails on
|
||||
* a unique constraint. This tests a rule made entirely of a boolean, a list
|
||||
* and two instants; the query it reads from is the repository's own business,
|
||||
* and booting a kernel to reach it would buy nothing but a slower test of the
|
||||
* same three branches.
|
||||
*
|
||||
* What is worth pinning is that the three ways of returning nothing are three
|
||||
* and not one — off, empty, dismissed — and that "dismissed" is a position on
|
||||
* a moving list rather than an off-switch: a fact the user has never seen
|
||||
* brings the strip back, and only that.
|
||||
*/
|
||||
final class InsightPaneTest extends TestCase
|
||||
{
|
||||
private DateTimeImmutable $now;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->now = new DateTimeImmutable('2026-08-21 09:00:00', new DateTimeZone('UTC'));
|
||||
}
|
||||
|
||||
/** The off-switch short-circuits: a disabled strip must not even ask the database. */
|
||||
public function testASwitchedOffStripRendersNothingAndAsksNothing(): void
|
||||
{
|
||||
$user = $this->user();
|
||||
$user->setSetting(User::SETTING_INSIGHT_PANE_DISABLED, true);
|
||||
|
||||
$repository = $this->createMock(MailInsightRepository::class);
|
||||
$repository->expects(self::never())->method('upcomingForUser');
|
||||
|
||||
self::assertSame([], new InsightPane($repository)->rowsFor($user, $this->now));
|
||||
self::assertTrue(new InsightPane($repository)->isDisabledFor($user));
|
||||
}
|
||||
|
||||
/** Absent means ON — the whole reason the key stores DISABLED. */
|
||||
public function testAUserWhoNeverTouchedTheSettingHasTheStrip(): void
|
||||
{
|
||||
$user = $this->user();
|
||||
$rows = [$this->insight('DHL parcel', $this->now->modify('-1 hour'))];
|
||||
|
||||
$pane = $this->pane($rows);
|
||||
|
||||
self::assertFalse($pane->isDisabledFor($user));
|
||||
self::assertSame($rows, $pane->rowsFor($user, $this->now));
|
||||
}
|
||||
|
||||
/** Nothing to say is the common case, and it must be indistinguishable from off. */
|
||||
public function testNoInsightsRendersNothing(): void
|
||||
{
|
||||
self::assertSame([], $this->pane([])->rowsFor($this->user(), $this->now));
|
||||
}
|
||||
|
||||
public function testADismissalHidesRowsThatAreOlderThanIt(): void
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
$insight = $this->insight('DHL parcel', $this->now->modify('+2 days'));
|
||||
$insight->restoreCreatedAt($this->now->modify('-2 hours'));
|
||||
|
||||
$this->dismiss($user, $this->now->modify('-1 hour'));
|
||||
|
||||
self::assertSame([], $this->pane([$insight])->rowsFor($user, $this->now));
|
||||
}
|
||||
|
||||
/**
|
||||
* And comes back for one it has never shown. This is the difference
|
||||
* between "not now" and "never" that the timestamp exists to express.
|
||||
*/
|
||||
public function testAnInsightNewerThanTheDismissalBringsTheStripBack(): void
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
$old = $this->insight('DHL parcel', $this->now->modify('+2 days'));
|
||||
$old->restoreCreatedAt($this->now->modify('-2 hours'));
|
||||
|
||||
$fresh = $this->insight('Flight LH400', $this->now->modify('+3 days'));
|
||||
$fresh->restoreCreatedAt($this->now->modify('-1 minute'));
|
||||
|
||||
$this->dismiss($user, $this->now->modify('-1 hour'));
|
||||
|
||||
// Both rows, not just the new one: the strip is dismissed or it is
|
||||
// not, and half a strip would be a third state nothing can express.
|
||||
self::assertSame([$old, $fresh], $this->pane([$old, $fresh])->rowsFor($user, $this->now));
|
||||
}
|
||||
|
||||
/**
|
||||
* A jsonb bag is free-form, so a value that is not a date is possible —
|
||||
* from an older format, a restore, a hand-edited row. It is read as "never
|
||||
* dismissed" rather than thrown, because a strip that comes back once is a
|
||||
* smaller failure than a strip that refuses to render.
|
||||
*/
|
||||
public function testAMalformedStoredTimestampIsTreatedAsNoDismissal(): void
|
||||
{
|
||||
$user = $this->user();
|
||||
$user->setSetting(User::SETTING_INSIGHT_PANE_DISMISSED_AT, 'yesterday-ish');
|
||||
|
||||
$insight = $this->insight('DHL parcel', $this->now->modify('+2 days'));
|
||||
$insight->restoreCreatedAt($this->now->modify('-2 days'));
|
||||
|
||||
self::assertNull($this->pane([])->dismissedAt($user));
|
||||
self::assertSame([$insight], $this->pane([$insight])->rowsFor($user, $this->now));
|
||||
}
|
||||
|
||||
/** An empty string is the same non-answer as an absent key, not a date at epoch. */
|
||||
public function testAnEmptyStoredTimestampIsTreatedAsNoDismissal(): void
|
||||
{
|
||||
$user = $this->user();
|
||||
$user->setSetting(User::SETTING_INSIGHT_PANE_DISMISSED_AT, '');
|
||||
|
||||
self::assertNull($this->pane([])->dismissedAt($user));
|
||||
}
|
||||
|
||||
/**
|
||||
* The cap is asked of the repository rather than applied to its answer —
|
||||
* a strip that fetched thirty rows to draw three would be paying for the
|
||||
* radar panel's query on every mailbox load.
|
||||
*/
|
||||
public function testTheQueryIsAskedForAtMostMaxRows(): void
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
$repository = $this->createMock(MailInsightRepository::class);
|
||||
$repository
|
||||
->expects(self::once())
|
||||
->method('upcomingForUser')
|
||||
->with($user, $this->now, self::anything(), self::anything(), InsightPane::MAX_ROWS)
|
||||
->willReturn([]);
|
||||
|
||||
new InsightPane($repository)->rowsFor($user, $this->now);
|
||||
}
|
||||
|
||||
/** Three, and the constant is the number every reader must agree on. */
|
||||
public function testTheStripCarriesNoMoreThanMaxRows(): void
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
for ($i = 0; $i < InsightPane::MAX_ROWS; ++$i) {
|
||||
$rows[] = $this->insight('Parcel ' . $i, $this->now->modify(sprintf('+%d days', $i + 1)));
|
||||
}
|
||||
|
||||
self::assertCount(InsightPane::MAX_ROWS, $this->pane($rows)->rowsFor($this->user(), $this->now));
|
||||
self::assertSame(3, InsightPane::MAX_ROWS);
|
||||
}
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A STUB and not a mock: everything below is asserted on the return value,
|
||||
* so the repository is a source of rows and never a thing under test. Only
|
||||
* the two cases that are genuinely about the call — that a disabled strip
|
||||
* does not make one, and that the cap travels into it — reach for a mock.
|
||||
*
|
||||
* @param list<MailInsight> $rows
|
||||
*/
|
||||
private function pane(array $rows): InsightPane
|
||||
{
|
||||
$repository = $this->createStub(MailInsightRepository::class);
|
||||
$repository->method('upcomingForUser')->willReturn($rows);
|
||||
|
||||
return new InsightPane($repository);
|
||||
}
|
||||
|
||||
private function dismiss(User $user, DateTimeImmutable $at): void
|
||||
{
|
||||
$user->setSetting(
|
||||
User::SETTING_INSIGHT_PANE_DISMISSED_AT,
|
||||
$at->format(DateTimeImmutable::ATOM),
|
||||
);
|
||||
}
|
||||
|
||||
private function user(): User
|
||||
{
|
||||
$user = new User();
|
||||
$user->email = 'pane@example.test';
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function insight(string $title, DateTimeImmutable $happensAt): MailInsight
|
||||
{
|
||||
$insight = new MailInsight();
|
||||
$insight->kind = InsightKind::Parcel;
|
||||
$insight->title = $title;
|
||||
$insight->payload = [];
|
||||
$insight->happensAt = $happensAt;
|
||||
$insight->extractor = 'pane-test';
|
||||
$insight->dedupeKey = uniqid('pane-', true);
|
||||
|
||||
// createdAt is stamped on persist, and nothing here persists. The
|
||||
// dismissal rule compares against it, so every row that takes part in
|
||||
// that comparison sets it explicitly rather than leaning on a
|
||||
// lifecycle callback this test never fires.
|
||||
$insight->restoreCreatedAt($happensAt->modify('-1 day'));
|
||||
|
||||
return $insight;
|
||||
}
|
||||
}
|
||||
@@ -1360,6 +1360,14 @@ insights:
|
||||
parcel: Paket
|
||||
subscription: Abo
|
||||
ticket: Ticket
|
||||
pane:
|
||||
dismiss: Ausblenden
|
||||
dismiss_all: Leiste ausblenden
|
||||
heading: Steht bald an
|
||||
more: Weitere Aktionen
|
||||
when:
|
||||
today: Heute
|
||||
tomorrow: Morgen
|
||||
panel:
|
||||
dismiss: Ausblenden
|
||||
heading: Auf deinem Radar
|
||||
@@ -2378,6 +2386,13 @@ settings:
|
||||
ticket: Tickets
|
||||
ticket_hint: Veranstaltungstickets und Buchungen mit Datum.
|
||||
heading: Radar
|
||||
pane: Streifen über der Mailliste
|
||||
pane_hint: >-
|
||||
Zeigt dein nächstes Paket, deinen nächsten Flug oder dein
|
||||
nächstes Ticket in einem Band über dem Posteingang. Schaltest
|
||||
du ihn aus, verschwindet nur das Band — plMail liest diese
|
||||
Angaben weiterhin aus deinen Mails, und du findest sie im
|
||||
Radar und an der Mail selbst.
|
||||
seg:
|
||||
'off': 'Aus'
|
||||
'on': 'An'
|
||||
|
||||
@@ -1351,6 +1351,14 @@ insights:
|
||||
parcel: Parcel
|
||||
subscription: Subscription
|
||||
ticket: Ticket
|
||||
pane:
|
||||
dismiss: Dismiss
|
||||
dismiss_all: Hide this strip
|
||||
heading: Happening soon
|
||||
more: More actions
|
||||
when:
|
||||
today: Today
|
||||
tomorrow: Tomorrow
|
||||
panel:
|
||||
dismiss: Dismiss
|
||||
heading: On your radar
|
||||
@@ -2354,6 +2362,12 @@ settings:
|
||||
ticket: Tickets
|
||||
ticket_hint: Event tickets and bookings with a date attached.
|
||||
heading: Insights
|
||||
pane: Strip above the mail list
|
||||
pane_hint: >-
|
||||
Shows the next parcel, flight or ticket in a band above your
|
||||
inbox. Switching it off hides that band and nothing else —
|
||||
plMail still reads those facts out of your mail, and they stay
|
||||
in the radar panel and on the mail itself.
|
||||
seg:
|
||||
'off': 'Off'
|
||||
'on': 'On'
|
||||
|
||||
@@ -1076,6 +1076,14 @@ insights:
|
||||
parcel: Cargo
|
||||
subscription: "Standin' order"
|
||||
ticket: Passage papers
|
||||
pane:
|
||||
dismiss: Wave it off
|
||||
dismiss_all: Strike the whole banner
|
||||
heading: Soon upon ye
|
||||
more: More doings
|
||||
when:
|
||||
today: This very day
|
||||
tomorrow: On the morrow
|
||||
panel:
|
||||
dismiss: Wave it off
|
||||
heading: Through the spyglass
|
||||
@@ -2037,6 +2045,12 @@ settings:
|
||||
ticket: Passage papers
|
||||
ticket_hint: "Tickets an' bookin's with a date writ on them."
|
||||
heading: The spyglass
|
||||
pane: "Banner o'er the mail list"
|
||||
pane_hint: >-
|
||||
Hoists yer next cargo, passage or ticket on a banner above the
|
||||
inbox. Strikin' it lowers the banner an' nothin' else — plMail
|
||||
still spies those facts in yer post, an' they bide in the
|
||||
spyglass an' on the mail itself.
|
||||
seg:
|
||||
'off': 'Nay'
|
||||
'on': 'Aye'
|
||||
|
||||
Reference in new issue
Block a user