diff --git a/src/MeshWeaver.Documentation/Data/Architecture/CommunicationHub.md b/src/MeshWeaver.Documentation/Data/Architecture/CommunicationHub.md index 10d4b43ab7..44624b2fb7 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/CommunicationHub.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/CommunicationHub.md @@ -366,6 +366,133 @@ Two legs, each selecting its own type's own states, merged for display only. nothing, and it must never be mistaken for the retention pass that actually bounds the data ([Notification Retention](/Doc/Architecture/NotificationRetention)). +### Select many, then act — what ships today + +The Inbox app (`/{user}/Inbox`, `InboxLayoutArea`) is ONE `Controls.DataGrid` over every leg, with +the platform's own selection column (`WithRowSelection`, [DataGrid](/Doc/GUI/DataGrid) → "Row +selection") — and everything above the grid is **data-bound**. + +The page holds one piece of view state, an `InboxFilter` in its data section, and each control is +bound to a field of it. No control has a click handler that re-renders the page; the rows are a +function of the filter and the legs. + +| Control | Bound to | What it does | +|---|---|---| +| search box | `search` | the term goes to the query engine with EVERY leg, as free text (full text and the semantic index), under the owner's identity — never a match over the rows already loaded | +| order select | `order` | an `InboxOrder` constant: what needs you first (default — band rank, newest within), newest, oldest, by title, by source — the last two alphabetical in the viewer's language | +| one switch per source | `sources/{key}` (the id, hex-encoded) | shows or hides what one provider brought (Mail, WhatsApp, Notifications, …); a source with no entry is shown, so a leg a package brings later never arrives switched off | +| the grid's checkboxes | `inboxSelection` | the ticked row keys, read by Delete | + +Three properties of that binding are load-bearing: + +- **Every leg is a live read bound to the page.** Idle or searched, a leg is an `IMeshService.Query` + folded from deltas into "what the leg selects now", ended when the term changes or the page closes. + Never the process-wide synced query (`GetQuery`): that cache lives as long as the process, per + distinct query text, and every leg is resolved for its owner — so it would keep a subscription and + a snapshot per leg for everyone who ever opened their inbox. +- 🚨 **The term is free text, never grammar.** It is handed over as one quoted phrase with its quote + characters removed, and the leg's bound is pinned on the request (`MeshQueryRequest.Limit`, which + wins over any `limit:` in the string). Spliced in raw, `OR content.status:Archived` would parse as + "(the leg) OR archived" and bring put-away rows back, and `limit:all` would lift the bound. A search + narrows a leg; it never replaces its constraints. +- 🚨 **The filter is compared by VALUE.** The data section re-emits an id whenever any id in it + changes — the grid's own rows included — and each emission is a freshly deserialized record. Read + naively, the page feeds on its own writes: rows written, filter "changed", rows written again, + without end. `InboxRows.Fingerprint` is what tells a change the viewer made from a re-emission. +- **Importance stays the default order, in one list.** The bands are a column and a rank rather + than three stacked lists, so "what needs you" is still on top — and an order the page does not + recognise falls back to it. + +The toolbar's two buttons act on every ticked row. + +| Button | Does | Never does | +|---|---|---| +| **Delete** | puts each ticked item away through its own type's `IInboxItemHandler.Dismiss`, records the batch | remove a node from the mesh | +| **Undo** | brings the last batch back through `IInboxItemHandler.Restore` | touch anything Delete did not put away | + +🚨 **Delete is SOFT, and what it means belongs to the item's type, not to the inbox.** The action +half of the potluck mirrors the query half: a package that brings a leg brings, as a singleton +service, the `IInboxItemHandler` for the node type its leg selects. "Put away" moves the item into a +state the package's OWN leg excludes — and Undo brings it back into that leg. Not the exact inverse: +the inbox keeps no record of the prior state, so Undo returns the item to a state its leg selects +again, chosen by the handler (an archived mail comes back as Read, whether it was New or Read): + +| Item type | Brought by | Delete | Undo | The leg excludes | +|---|---|---|---|---| +| `Notification` | core | `Dismissed = true` | `Dismissed = false` | `-content.dismissed:true` | +| `Email` (`{user}/_Email`, inbound) | core | `Status = Archived` | `Status = Read` | `-content.status:Archived` | +| `Thread` | the Threads app | closes the conversation | reopens it | `-content.status:Done` | + +Three rules follow, and each is pinned by a test in `InboxSelectAndDeleteTest`: + +- **A row whose type has no handler is LISTED but not selectable**, and its disabled checkbox says + why. The inbox never guesses a write for a type nobody taught it — a governed record (an approval, + an instance action) deliberately has no handler and leaves the inbox by being decided. +- **A row in *Running* is never selectable.** It is not the viewer's to put away; it leaves the band + by finishing. +- **An outcome never quotes an exception.** A handler's `Reason` is shown to the viewer verbatim, so + it is a refusal the handler chose to state. A write that faults is LOGGED and answers with no + reason; the page then says only that the item could not be changed. +- **A leg that does not read exactly the viewer's own partition is never run.** `InboxQueries.Resolve` + drops it — its first term must be a `namespace:` or `path:` whose first segment is the viewer, + literally, with no `partitions:all` — and the page logs it as an error naming the source. A + provider's mistake is loud, and it can never become a fan-out over every schema on every render. +- **Every leg is read through the row projection.** The page appends `InboxQueries.RowProjection` + (and the bound) to whatever a provider wrote: a leg with no `select:` would fetch whole thread and + message bodies to render one line each. +- **A provider band has a display name too.** `InboxQueryLeg.BandLabelKey` names its catalog text; + core's own three bands are named by core. +- **The orderings are a closed set**, owned by the page; a stored order outside it reads as + importance. +- **A source has an id and a display name.** `InboxQueryLeg.Source` keys the switch; `SourceLabelKey` names the catalog text the row and the switch show in the + viewer's language. A provider gives no key only for a name that is the same in every language. + The row's date is written in the viewer's time zone AND the viewer's date format. +- **A bulk act never leaves the owner's partition.** A search runs with the viewer's rights and may + list a node elsewhere; a ticked row outside `{owner}/` is not a target. +- **A selection is the viewer's claim, not a grant — and it is the ONLY thing taken from the + client.** The data section is two-way, so the click never trusts what is in it: the ticked keys are + intersected with the owner's own feed, re-read from the legs at the click (which also supplies the + node type that picks each handler), and every write is issued under the CLICKER's access context, + opened around the handler call. The Undo batch is a host variable for the same reason — kept in the + data section it would be whatever the client says it is. +- **"The write completed" is not "the item was put away".** A handler answers `Done` only when THIS + call changed the content AND the content is now in the asked-for state. A no-op (an outbound mail) + completes like any other write; and an item someone else already put away is in the asked-for + state too — counted, it would enter this click's Undo batch and Undo would reverse the other + writer's act. +- **Each source is asked for its newest `InboxQueries.LegLimit` rows — or fewer, when its provider + names a smaller positive `limit:` — and the page says so when a source answered its full bound.** + `limit:all` and a non-positive limit are UNBOUNDED to the parser, so they are capped like a leg that + names none; the effective bound is also pinned on the searched read's `MeshQueryRequest.Limit`. Unbounded, the page folds every historical row into memory and into the + client's payload on a surface that is open all day. Older items are found with the search, which + goes to the query engine; "oldest first" therefore orders the listed window, not all history. +- **The read names the viewer.** A leg is (re)subscribed on a data emission, where the ambient + identity is gone, so the live read takes the viewer as an argument. +- **A source that cannot be read is named — and read again only when the viewer asks.** It + contributes no rows, is logged, and is listed in a notice above the grid: an unreadable source must + never look like an empty one. It is re-read when the search changes or the page is reopened, never + on a timer — a provider that stays broken would otherwise be re-queried and re-logged for as long + as any inbox is open. +- **The handler builds its write INSIDE the call.** `Update` captures the caller's identity when it + is constructed, and the inbox opens the clicker's scope only around the call. A write deferred to + subscription runs, for every item after the first, on the previous write's completion thread — as + nobody — so a multi-row Delete would put away only its first row. +- **Delete and Undo hold the page one at a time.** They are separate buttons, so the click framework + would run them side by side, and each rewrites the Undo batch when it finishes. A second act while + one runs is refused with its own line; the claim is released however the first one ends. + +🚨 **A default is OMITTED from stored JSON, so a leg negates the STAMPED value it excludes.** +`Dismissed` defaults to `false`, `Email.Status` to `New`, `Direction` to `Inbound`: a positive match +on any of those selects nothing that was ever written. The legs therefore read +`-content.dismissed:true`, `-content.status:Archived`, `-content.direction:Outbound` — the same trap +the activity legs document for `Running`. + +**How a package brings a leg today:** as a service — +`services.AddSingleton(new InboxQueryLeg { Source = "Threads", Band = InboxBand.NeedsYou, Query = "namespace:{viewer}/… select:…" })` +— which `InboxQueries.All` runs beside core's own. That serves a COMPILED module. A node-native +package cannot register a service, so the durable, node-contributed form of a leg described above is +still owed (§11). + ### Ordering: by IMPORTANCE first, date second 🚨 **The default order is importance, not time.** What the viewer must act on comes **first, at the @@ -388,37 +515,32 @@ and for one reading in high-contrast or dark mode, so emphasis is carried by **p glyph and an accessible label together** — colour is the fourth signal, never the only one. This is the same rule as the transport marker: the meaning lives in something a screen reader can say. -### The filter bar is `MeshSearchControl` — not a new one - -🚨 **Do not build an inbox filter bar.** `MeshSearchControl` already is one, and every feature this -surface needs is in it. Building a second is the hand-rolling the platform rules forbid, and it -would be a worse one. - -The mapping is almost exact, which is the tell that this is the right control: - -| Inbox needs | `MeshSearchControl` gives | -|---|---| -| the three **bands** | **scope tabs** — a tab strip above the header, one `MeshSearchScope(Label, Query)` per band, each carrying its band's hidden query | -| **ordering**, importance first | `SortOptions` — `MeshSearchSortOption(Label, Query)`, **per scope**. Each option is a FULL query, so one can change ordering *and* the result set | -| a **filter bar** | the control's own search bar | -| a term that survives switching band | built in — scopes deliberately **share one search bar**, so switching swaps only the hidden query while the typed text stays | -| how rows render | `MeshSearchRenderMode` | - -Two consequences worth stating, because both are properties of the control rather than choices we -get to make later: - -- **The first sort option is the default, and it must match the scope's `HiddenQuery`.** So - importance-first is not styling applied after the fact — it is the *first* `SortOptions` entry and - the hidden query's own ordering, in agreement. If those two disagree the list reorders itself the - moment the user touches the dropdown. -- **The provider legs ARE the hidden queries.** A provider contributing a leg is contributing the - scope's query text, so the potluck and the control are the same mechanism seen from two sides — - nothing has to be adapted between them. - -🚨 **A filter must never silently hide an obligation.** Filtering to *Recent* and seeing an empty -"Needs you" band is indistinguishable from having nothing to do — the same failure as a tick on a -pending row, reached a different way. When a filter or scope hides open items, the inbox says so and -how many: *"3 items needing you are hidden by this filter."* The count is never filtered away with +### The filter bar — what shipped, and why it is not `MeshSearchControl` + +The first design mapped the inbox onto `MeshSearchControl` (bands as scope tabs, ordering as +`SortOptions`, its own search bar). It was superseded when the inbox became **select many, then +act**: Delete and Undo need the platform grid's row selection (`DataGridControl.WithRowSelection`), +which a search result list does not carry, and a person working through their inbox wants every band +in ONE list rather than one tab at a time. What ships is the contract in +[Select many, then act](#select-many-then-act--what-ships-today): one `Controls.DataGrid`, with the +search box, order select and per-source switches as ordinary platform controls bound to ONE +`InboxFilter` in the page's data section. It is still not a hand-built filter bar — every control is +the platform's own, and the search still goes to the mesh query engine with each leg. + +What carried over from the first design: + +- **Importance is the default order** — band rank first, newest within — and an order the page does + not recognise falls back to it. +- **The provider legs ARE the queries.** A provider contributing a leg contributes the query text + the page runs; nothing is adapted between them. +- **The term survives** a change of order or of the source switches: they are fields of the same + bound record. + +🚨 **A filter must never silently hide an obligation.** Switching a source off and seeing no +"Needs you" row is indistinguishable from having nothing to do — the same failure as a tick on a +pending row, reached a different way. When the switches hide open items, the inbox says so and how +many, in a bound notice above the grid (`inboxHidden`): *"3 items needing you are hidden by the +source switches."* The count is taken before the switches apply, so it is never filtered away with the rows. Built from framework controls only — `Controls.DataGrid` with `PropertyColumnControl`, composed @@ -621,6 +743,23 @@ twice. ## 11. What is NOT established here +- **"Done" is judged on the writer's mirror, not on the owner's verdict.** A handler counts an item + as put away when its update lambda changed the content it was handed and the written result is in + the asked-for state. If the same item was put away in ANOTHER session a moment earlier and this + mirror has not seen it yet, the owner applies a no-op while this click counts it — and its Undo can + bring back an item the viewer put away elsewhere. Bounded (the viewer's own partition, a soft and + repeatable act) but real; closing it needs the write path to report an owner-side no-op, which + `Update` does not do today. +- **Legs contributed as NODES do not exist yet.** A compiled module registers its leg and its + handler as services; a node-native package (the Hosting approvals, triage items, fleet issues, + feedback submissions) has no way to bring either. Until it does, those lists stay on their own + pages. +- **The inbox's rows carry no read state and no channel glyph.** A row shows what it is, who brought + it and when it last changed; "unread" and the transport marker of §6 need content the row + projection does not select. +- **Only the viewer's OWN partition is listed.** A conversation started on a page in another + partition lives beside that page, so no anchored leg finds it — it stays in the Threads app until + it has a delivery copy in the viewer's partition (§4). - **No measurement of copy volume.** The write-side fan-out is bounded by recipient count *by construction*; no production distribution has been sampled, because the feature does not exist yet. - **WhatsApp and iMessage transports are asserted to fit the adapter shape, not demonstrated.** diff --git a/src/MeshWeaver.Graph/Configuration/GraphConfigurationExtensions.cs b/src/MeshWeaver.Graph/Configuration/GraphConfigurationExtensions.cs index 550141e54c..61af5a775e 100644 --- a/src/MeshWeaver.Graph/Configuration/GraphConfigurationExtensions.cs +++ b/src/MeshWeaver.Graph/Configuration/GraphConfigurationExtensions.cs @@ -128,6 +128,11 @@ public TBuilder AddGraph() // catalog the menu aggregation maps (one query subscription per silo). builder.AddUiContributionType(); builder.ConfigureServices(s => s.AddSingleton()); + // The inbox's own item handlers — what Delete and Undo mean for the two item types + // core owns. Every other type's handler arrives with its package (IInboxItemHandler). + builder.ConfigureServices(s => s + .AddSingleton() + .AddSingleton()); // Type-scoped recycle (Admin/_Recycle): every process disposes the live activations it // HOSTS of the requested NodeTypes, read from its mesh hub's HostedHubsCollection. // Doc/Architecture/StaleStateUntilRecycle → "Recycling a type". diff --git a/src/MeshWeaver.Graph/InboxItemHandlers.cs b/src/MeshWeaver.Graph/InboxItemHandlers.cs new file mode 100644 index 0000000000..cbaccc944c --- /dev/null +++ b/src/MeshWeaver.Graph/InboxItemHandlers.cs @@ -0,0 +1,126 @@ +using System.Reactive.Linq; +using MeshWeaver.Graph.Configuration; +using MeshWeaver.Mesh; +using MeshWeaver.Messaging; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace MeshWeaver.Graph; + +/// +/// The inbox handlers core itself brings — for the two item types core owns. Every other type's +/// handler arrives with the package that owns the type (the Threads app brings the thread's). +/// +public static class InboxItemHandlers +{ + /// + /// The handler for among , or null when + /// nobody brought one. Ordinal: a node type is an identifier, never display text. + /// + /// The registered handlers. + /// The row's node type. + public static IInboxItemHandler? For(IEnumerable handlers, string? nodeType) + => nodeType is null + ? null + : handlers.FirstOrDefault(h => string.Equals(h.NodeType, nodeType, StringComparison.Ordinal)); + + /// + /// One typed write as an outcome: the node's content read as , + /// changed, written through the owning hub. A fault becomes the item's own answer — a bulk act + /// names what happened to each item rather than aborting on the first refusal. + /// + /// 🚨 The fault is LOGGED here and the outcome carries NO reason: a typed-write failure can + /// quote the content it could not read, and an outcome's reason is shown to the viewer verbatim. + /// The page says "could not be changed"; the log says why. + /// + /// The hub the inbox renders on. + /// The item's node path. + /// The change; returns its argument unchanged when the item may not be changed. + /// Whether the content AS WRITTEN is in the state the action asked for. + /// + /// 🚨 Done needs BOTH: this call changed the content, and the content is now in the + /// asked-for state. "The update completed" proves neither — a no-op completes like a real write. + /// And the final state alone is not enough: an item someone ELSE already put away is in the + /// asked-for state too, and counting it would put it in this click's Undo batch, where Undo + /// would reverse the other writer's act. The update lambda can run more than once (a retried + /// write), so the flag records its LAST run — the one whose result was written. + /// 🚨 The write is BUILT here, inside the call, never in a Defer: Update + /// captures the caller's identity when it is constructed, and the inbox holds the clicker's + /// access scope open only around this call. A deferred build runs at subscription — for every + /// item after the first, on the previous write's completion thread, as nobody. + /// + internal static IObservable Write( + IMessageHub hub, string path, Func change, Func reached) where TContent : class + { + // One flag per call — each call is one write, subscribed once by the inbox. + var changed = false; + return hub.GetMeshNodeStream(path).Update(current => + { + var next = change(current); + changed = !Equals(next, current); + return next; + }) + .Take(1) + .Select(node => new InboxItemOutcome(path, + changed && node.ContentAs(hub.JsonSerializerOptions) is { } content && reached(content))) + .Catch((Exception ex) => + { + hub.ServiceProvider.GetService()?.CreateLogger(typeof(InboxItemHandlers)) + .LogWarning(ex, "Inbox: {Path} could not be written", path); + return Observable.Return(new InboxItemOutcome(path, false)); + }); + } +} + +/// +/// Notifications: Delete sets , Undo clears it. The node stays — +/// retention removes it, exactly as it would have (Doc/Architecture/NotificationRetention). +/// +public sealed class NotificationInboxItemHandler : IInboxItemHandler +{ + /// + public string NodeType => NotificationNodeType.NodeType; + + /// + public IObservable Dismiss(IMessageHub hub, string path) + => InboxItemHandlers.Write(hub, path, n => n with { Dismissed = true }, n => n.Dismissed); + + /// + public IObservable Restore(IMessageHub hub, string path) + => InboxItemHandlers.Write(hub, path, n => n with { Dismissed = false }, n => !n.Dismissed); +} + +/// +/// The user's stored mail ({user}/_Email): Delete archives it, Undo returns it to +/// — the viewer has, by then, seen it. +/// +/// Only an INBOUND mail that is not in flight is touched. An outbound mail's +/// is its send state ( / +/// / ), which the sender watches; +/// archiving one would stop a queued mail from ever leaving. +/// +public sealed class EmailInboxItemHandler : IInboxItemHandler +{ + /// + public string NodeType => EmailNodeType.NodeType; + + /// + public IObservable Dismiss(IMessageHub hub, string path) + => InboxItemHandlers.Write(hub, path, + mail => CanFile(mail) ? mail with { Status = EmailStatus.Archived } : mail, + // Done only when THIS mail is now a filed inbound one — an outbound mail, or one the + // write left alone, was not put away and must never enter the Undo batch. + mail => mail is { Direction: EmailDirection.Inbound, Status: EmailStatus.Archived }); + + /// + public IObservable Restore(IMessageHub hub, string path) + => InboxItemHandlers.Write(hub, path, + mail => mail is { Direction: EmailDirection.Inbound, Status: EmailStatus.Archived } + ? mail with { Status = EmailStatus.Read } + : mail, + mail => mail is { Direction: EmailDirection.Inbound, Status: EmailStatus.New or EmailStatus.Read }); + + /// Whether may be archived from the inbox — inbound, and not already archived. + internal static bool CanFile(Email mail) + => mail is { Direction: EmailDirection.Inbound, Status: EmailStatus.New or EmailStatus.Read }; +} diff --git a/src/MeshWeaver.Graph/InboxLayoutArea.cs b/src/MeshWeaver.Graph/InboxLayoutArea.cs index 7e482df5a6..1bff84cd16 100644 --- a/src/MeshWeaver.Graph/InboxLayoutArea.cs +++ b/src/MeshWeaver.Graph/InboxLayoutArea.cs @@ -1,79 +1,644 @@ using System.Collections.Immutable; +using System.Globalization; +using System.Reactive; using System.Reactive.Linq; +using System.Reactive.Subjects; +using MeshWeaver.Data; using MeshWeaver.Graph.Configuration; using MeshWeaver.Layout; using MeshWeaver.Layout.Composition; +using MeshWeaver.Layout.DataGrid; using MeshWeaver.Mesh; using MeshWeaver.Mesh.Security; +using MeshWeaver.Mesh.Services; using MeshWeaver.Messaging; +using MeshWeaver.Utils; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace MeshWeaver.Graph; /// /// The Inbox app — /{user}/Inbox, installed for every user by -/// SeedInboxAppLogonAction: one page with what needs the person, what is running for them and -/// what just finished. It is the rendered side of : every leg is resolved for -/// the OWNER of the hub (anchored on their own partition, projected — the invariants -/// InboxLegsAreAnchoredTest pins) and shown as a MeshSearch list, grouped by band. +/// SeedInboxAppLogonAction: ONE grid for everything that reaches a person — notifications, +/// mail, conversations from every connected channel, what is running for them and what just +/// finished. It is the rendered side of : every leg (core's own plus each +/// one a package registered) is resolved for the OWNER of the hub — anchored on their own partition, +/// projected, the invariants InboxLegsAreAnchoredTest pins — and its rows are merged into +/// the grid. +/// +/// Everything above the grid is data-bound. The page holds ONE piece of view state, an +/// in its data section, and each control is bound to a field of it: the +/// search box, the order select, and one switch per source. The grid's rows are a function of that +/// state and the legs — nothing is copied, and no control has a click handler that re-renders the +/// page. +/// +/// The search is the mesh's own. A typed term is handed to the query engine with every +/// leg, as FREE TEXT (full text and the semantic index), under the OWNER's identity; it is not a +/// filter over the rows that happened to be loaded. It is never spliced into the query grammar: a +/// typed OR or limit: must not be able to replace a leg's own constraints. +/// +/// Select many, then act. The grid carries the platform's own selection column +/// (); Delete acts on every ticked row and Undo brings +/// the last batch back. What Delete MEANS is the item type's own business — an +/// the type's package registered — and it is always soft: nothing is +/// removed from the mesh. A row whose type has no handler, or that is still running, renders a +/// disabled checkbox that says why. /// /// Only the owner sees it. A User node is public-read, so without the owner check anyone could /// open another person's inbox address; the legs are RLS-scoped anyway, but the page says "not yours" /// instead of rendering someone else's empty shell. -/// -/// Two legs that resolve to the SAME query (core's Running and Recent activity legs do today) are -/// shown once, under the first band that asks for them — a row listed twice on one page is noise. /// public static class InboxLayoutArea { /// The layout area on the User hub — also the app record's id ({user}/_App/Inbox). public const string AreaName = "Inbox"; - /// The order bands are shown in; a provider's own band follows these, alphabetically. + /// + /// The key of the page's — what the last Delete put away, and whether an + /// act is running. A HOST variable, never the data section: the data section is two-way, so a + /// batch kept there is whatever the client says it is, and Undo would restore paths the viewer + /// never deleted. + /// + public const string UndoId = "inboxUndo"; + + /// The data id of the notice naming the sources that could not be read; empty when all were. + public const string UnreadId = "inboxUnread"; + + /// The data id of the notice shown when a source answered as many rows as it was asked for; empty otherwise. + public const string CappedId = "inboxCapped"; + + /// + /// The data id of the notice counting the items needing the viewer that the source switches hide; + /// empty when they hide none. A switch must never silently hide an obligation. + /// + public const string HiddenId = "inboxHidden"; + + /// How long the page waits after the last keystroke before it asks the query engine. + internal static readonly TimeSpan SearchQuiet = TimeSpan.FromMilliseconds(300); + + /// The order bands are ranked in; a provider's own band follows these, alphabetically. internal static readonly ImmutableArray BandOrder = [InboxBand.NeedsYou, InboxBand.Running, InboxBand.Recent]; - /// The page. + /// The page — returned at once as a template; its rows are fed. public static IObservable Render(LayoutAreaHost host, RenderingContext _) { var owner = UserNodeType.OwnerOf(host.Hub.Address.ToString()); - var viewer = host.Hub.ServiceProvider.GetService().ViewerId(); + var access = host.Hub.ServiceProvider.GetService(); + var viewer = access.ViewerId(); if (string.IsNullOrEmpty(viewer) || !string.Equals(viewer, owner, StringComparison.OrdinalIgnoreCase)) return Observable.Return(Controls.Markdown(host.Localize("inbox.notYours"))); - var stack = Controls.Stack.WithWidth("100%").WithVerticalGap(16) - .WithView(Controls.H2(host.Localize("inbox.title")).WithStyle("margin: 0;")); - foreach (var (band, query) in Sections(InboxQueries.BuiltIn, owner)) - stack = stack.WithView(Controls.MeshSearch - // A provider's own band has no catalog key: its id is its title. - .WithTitle(BandOrder.Contains(band) ? host.Localize(BandTitleKey(band)) : band) - .WithHiddenQuery(query) - .WithShowSearchBox(false) - .WithShowEmptyMessage(true) - .WithRenderMode(MeshSearchRenderMode.Flat) - .WithCollapsibleSections(false) - .WithSectionCounts(false) - .WithItemLimit(20) - .WithMaxColumns(1) - .WithReactiveMode(true)); - return Observable.Return(stack); + var legs = InboxQueries.All(host.Hub.ServiceProvider); + // A leg that does not read exactly the owner's partition is never run — and never silently. + foreach (var refused in InboxQueries.Refused(legs, owner)) + host.Hub.ServiceProvider.GetService()?.CreateLogger(typeof(InboxLayoutArea)) + .LogError("Inbox leg {Source} ({Band}) refused: it does not read exactly the viewer's own partition: {Query}", + refused.Source, refused.Band, refused.Query); + var sections = Sections(legs, owner); + var sources = sections.GroupBy(section => section.Source, StringComparer.Ordinal) + .Select(source => (Id: source.Key, Label: SourceLabel(host, source.First()))) + .ToImmutableList(); + // Seeded ONCE per session: a re-render must not reset what the viewer typed, switched or ticked. + if (host.TryMarkEditStateInitialized("inbox:" + InboxRows.FilterId)) + { + host.UpdateData(InboxRows.FilterId, new InboxFilter + { + Sources = sources.ToImmutableDictionary(source => InboxRows.SourceKey(source.Id), _ => true), + }); + host.UpdateData(UnreadId, string.Empty); + host.UpdateData(CappedId, string.Empty); + host.UpdateData(HiddenId, string.Empty); + } + host.SeedRowSelection(InboxRows.SelectionId); + // One per page, created on the render turn; the buttons share it. + if (host.GetVariable(UndoId) is not { } acts) + { + acts = new InboxActs(); + host.SetVariable(UndoId, acts); + host.RegisterForDisposal(acts); + } + // ONE feed, owner-computed: the grid binds it, and Delete re-reads it at the click. + var rows = Rows(host, sections, owner, access.ViewerZoneId()); + + var filterContext = LayoutAreaReference.GetDataPointer(InboxRows.FilterId); + var switches = Controls.Stack.WithOrientation(Orientation.Horizontal).WithStyle("gap: 16px; flex-wrap: wrap;"); + foreach (var source in sources) + switches = switches.WithView( + Controls.Switch(new JsonPointerReference($"sources/{InboxRows.SourceKey(source.Id)}")) with + { + DataContext = filterContext, Label = source.Label, + }, "Show" + InboxRows.SourceKey(source.Id)); + + return Observable.Return(Controls.Stack.WithWidth("100%").WithVerticalGap(12) + .WithView(Controls.H2(host.Localize("inbox.title")).WithStyle("margin: 0;"), "Title") + .WithView(Controls.Stack.WithOrientation(Orientation.Horizontal).WithStyle("gap: 12px; flex-wrap: wrap; align-items: end;") + .WithView(Controls.Text(new JsonPointerReference("search")) with + { + DataContext = filterContext, Label = host.Localize("inbox.search"), + Placeholder = host.Localize("inbox.search.placeholder"), + }, "Search") + .WithView(Controls.Select(new JsonPointerReference("order"), + InboxOrder.All.Select(order => (Option)new Option(order, host.Localize("inbox.order." + order))).ToArray()) with + { + DataContext = filterContext, Label = host.Localize("inbox.order"), + }, "Order"), "Find") + .WithView(switches, "Sources") + // Ordinary buttons: the framework keeps each busy while its bulk act runs and shows the + // outcome line that names every item that was not put away (Doc/GUI/ButtonPendingState). + .WithView(Controls.Toolbar + .WithView(Controls.Button(host.Localize("inbox.delete")).WithAppearance(Appearance.Accent) + .WithReactiveClickAction(ctx => Delete(ctx, acts, owner, rows)), "Delete") + .WithView(Controls.Button(host.Localize("inbox.undo")).WithAppearance(Appearance.Neutral) + .WithReactiveClickAction(ctx => Undo(ctx, acts)), "Undo"), + "Actions") + // Says which sources could not be read — an unreadable source must never look like an + // empty one. Bound: it appears and clears with the reads, with no re-render. + .WithView(Controls.Label(new JsonPointerReference(LayoutAreaReference.GetDataPointer(UnreadId))), "Unread") + // Says when a source has more than the page lists — a capped list must never read as + // "that is everything". + .WithView(Controls.Label(new JsonPointerReference(LayoutAreaReference.GetDataPointer(CappedId))), "Capped") + // Says how many items needing the viewer the switches hide — "nothing here" must never + // be a switch's doing. + .WithView(Controls.Label(new JsonPointerReference(LayoutAreaReference.GetDataPointer(HiddenId))), "Hidden") + .WithView(Grid(host, rows), "Grid")); } /// - /// The sections of the page: every enabled leg resolved for , in band - /// order, each distinct query once. Pure — the page's shape is testable without a hub. + /// The grid's rows: every leg's result for the term the viewer typed, merged, then narrowed to + /// the sources switched on and put in the chosen order. A leg that cannot be read contributes no + /// rows and is LOGGED — one provider's failure must not blank the list the others feed. + /// + internal static IObservable> Rows( + LayoutAreaHost host, ImmutableArray sections, string owner, string? zone) + { + var hub = host.Hub; + var handlers = hub.ServiceProvider.GetServices().ToImmutableArray(); + var logger = hub.ServiceProvider.GetService()?.CreateLogger(typeof(InboxLayoutArea)); + // Resolved ONCE, on the render turn: every text a row may carry. + var running = host.Localize("inbox.blocked.running"); + var noHandler = host.Localize("inbox.blocked.noHandler"); + var culture = ViewerCulture(host.ViewerLocale()); + var labels = sections.GroupBy(s => s.Source, StringComparer.Ordinal) + .ToImmutableDictionary(s => s.Key, s => SourceLabel(host, s.First()), StringComparer.Ordinal); + var bands = sections.Select(s => s.Band).Distinct(StringComparer.Ordinal) + .Select((band, rank) => (band, rank, Title: BandTitle(host, band, sections))) + .ToImmutableDictionary(b => b.band, b => (b.rank, b.Title), StringComparer.Ordinal); + // The rank of the band whose rows are obligations, or none when no leg feeds it. + int? needsYou = bands.TryGetValue(InboxBand.NeedsYou, out var owed) ? owed.rank : null; + // 🚨 Distinct by VALUE. The data section re-emits this id whenever ANY id in it changes — + // the grid's own rows included — and each emission is a freshly deserialized record whose + // dictionary is a new instance. Without the fingerprint the page would feed on its own + // writes: rows written → filter "changes" → rows written again, without end. + var filter = host.GetDataStream(InboxRows.FilterId).Select(f => f ?? new InboxFilter()) + .DistinctUntilChanged(InboxRows.Fingerprint, StringComparer.Ordinal); + + // What each leg last answered: its rows, and whether that answer was a failure. A leg that + // cannot be read contributes no rows, is LOGGED and is NAMED above the grid — one provider's + // failure must not blank the others, and must not pass for "nothing here" either. It is + // read again when the viewer changes the search or reopens the page, never on a timer: a + // provider that stays broken would otherwise be re-queried, and re-logged, for as long as + // any inbox is open. + IObservable<(ImmutableList Rows, string? Unread)> Leg(InboxSection section, string term) => + Nodes(hub, section, term, owner) + .Select(nodes => (nodes + .Select(node => InboxRows.Of(node, section, zone, + BlockedReason(section.Band, InboxItemHandlers.For(handlers, node.NodeType), running, noHandler), + bands[section.Band].Title, bands[section.Band].rank, labels[section.Source], culture)) + .ToImmutableList(), (string?)null)) + .Catch((Exception ex) => + { + logger?.LogWarning(ex, "Inbox leg {Source} ({Band}) could not be read", section.Source, section.Band); + return Observable.Return((ImmutableList.Empty, (string?)labels[section.Source])); + }); + + IObservable> Merged(string term) => sections + .Select(section => Leg(section, term)) + .CombineLatest() + .Do(legs => + { + host.UpdateData(UnreadId, UnreadNotice(host, legs.Select(leg => leg.Unread))); + // Each leg against ITS OWN bound — a provider may ask for fewer than the page's. + host.UpdateData(CappedId, sections.Zip(legs).Any(leg => leg.Second.Rows.Count >= InboxQueries.EffectiveLimit(leg.First.Query)) + ? host.Localize("inbox.capped") + : string.Empty); + }) + .Select(legs => InboxRows.Merge(legs.Select(leg => leg.Rows))); + + return filter.Select(f => f.Search.Trim()) + .DistinctUntilChanged(StringComparer.Ordinal) + // The first read and a cleared box answer at once; a term waits for the typing to pause, + // and Switch drops the read a newer keystroke superseded. + .Select(term => term.Length == 0 ? Observable.Return(term) : Observable.Return(term).Delay(SearchQuiet)) + .Switch() + .Select(Merged) + .Switch() + .CombineLatest(filter, (rows, asked) => + { + var shown = InboxRows.Apply(rows, asked, culture); + var hidden = InboxRows.HiddenObligations(rows, asked, needsYou); + host.UpdateData(HiddenId, hidden == 0 ? string.Empty : host.Localize("inbox.hidden", hidden)); + return shown; + }); + } + + /// + /// The notice for the sources that could not be read: empty when every leg answered, else the + /// sentence naming each such source once. + /// + internal static string UnreadNotice(LayoutAreaHost host, IEnumerable unread) + { + var names = unread.OfType().Distinct(StringComparer.Ordinal).ToImmutableList(); + return names.IsEmpty ? string.Empty : host.Localize("inbox.unread", string.Join(", ", names)); + } + + /// + /// A source's display name: its catalog text in the viewer's language when the provider gave a + /// key, else its id (right for a name that is the same in every language). + /// + internal static string SourceLabel(LayoutAreaHost host, InboxSection section) + => string.IsNullOrWhiteSpace(section.SourceLabelKey) ? section.Source : host.Localize(section.SourceLabelKey); + + /// + /// The culture a row's date is written in. A language tag the runtime does not know falls back + /// to invariant — an unreadable preference must never fault a render. + /// + internal static CultureInfo ViewerCulture(string? locale) + { + if (string.IsNullOrWhiteSpace(locale)) + return CultureInfo.InvariantCulture; + try + { + return CultureInfo.GetCultureInfo(locale); + } + catch (CultureNotFoundException) + { + return CultureInfo.InvariantCulture; + } + } + + /// + /// One leg's nodes — the newest of them, as a live read of + /// the leg (plus the term, when there is one) that lives exactly as long as the page's + /// subscription: it ends when the term changes or the page closes. + /// + /// 🚨 Never the process-wide synced query (GetQuery): that cache is kept for the + /// process's life per distinct query text, and every leg here is resolved for its OWNER — so it + /// would keep one live subscription and snapshot per leg for every person who ever opened their + /// inbox, growing with cumulative visitors rather than open pages. + /// + /// 🚨 The term is FREE TEXT () and the bound is pinned on the + /// request (, which wins over any limit: in the + /// string): what the viewer types can narrow a leg, never widen it. The read names the viewer as + /// an argument — it runs on a data emission, where the ambient identity is no longer the page's. + /// + internal static IObservable> Nodes(IMessageHub hub, InboxSection section, string term, string owner) + => hub.ServiceProvider.GetRequiredService() + .Query(MeshQueryRequest.FromQuery( + term.Length == 0 ? InboxQueries.Bounded(section.Query) : $"{InboxQueries.Bounded(section.Query)} {SearchText(term)}", + owner) with + { + Limit = InboxQueries.EffectiveLimit(section.Query), + }) + .Scan(ImmutableDictionary.Empty.WithComparers(StringComparer.Ordinal), Fold) + .Select(nodes => (IEnumerable)nodes.Values); + + /// + /// as one quoted free-text phrase. The quote characters are removed + /// first, so nothing the viewer types can close the phrase: inside it, OR, + /// content.status:Archived, limit:all and namespace:… are words to look for, + /// never grammar — a search may narrow a leg, never replace its constraints. + /// + /// The trimmed, non-empty search text. + internal static string SearchText(string term) + => "\"" + new string([.. term.Where(c => c is not ('"' or '\''))]).Trim() + "\""; + + /// + /// A live read answers its first set whole and every later change as a delta; this folds them + /// into "what the leg selects now". + /// + internal static ImmutableDictionary Fold(ImmutableDictionary current, QueryResultChange change) + => change.ChangeType switch + { + QueryChangeType.Removed => current.RemoveRange(change.Items.Select(n => n.Path)), + QueryChangeType.Added or QueryChangeType.Updated => current.SetItems(change.Items.Select(n => KeyValuePair.Create(n.Path, n))), + // Initial — and a reset: the set itself. + _ => ImmutableDictionary.Empty.WithComparers(StringComparer.Ordinal) + .SetItems(change.Items.Select(n => KeyValuePair.Create(n.Path, n))), + }; + + /// + /// Why a row of cannot be selected, or null when it can. A running item + /// is not the viewer's to put away — it leaves the band by finishing — and a type nobody brought + /// a handler for is listed but never written. + /// + internal static string? BlockedReason(string band, IInboxItemHandler? handler, string running, string noHandler) + => string.Equals(band, InboxBand.Running, StringComparison.Ordinal) ? running + : handler is null ? noHandler + : null; + + /// + /// The grid: the selection column, what the row is, who brought it, which band it is in, when — + /// and Open, which hands off to the item's own page. The inbox lists and hands off; it is not a + /// second mail client or chat client. + /// + private static DataGridControl Grid(LayoutAreaHost host, IObservable> rows) + { + var grid = rows.Select(set => (IEnumerable)set) + .BindGrid(InboxRows.GridId, host.Localize("inbox.band.empty"), reason => host.Localize("inbox.listFailed", reason)) + .WithRowSelection(InboxRows.SelectionId, InboxRows.KeyProperty, InboxRows.BlockedReasonProperty); + foreach (var (property, key) in new[] + { + (nameof(InboxRow.Title), "inbox.column.item"), + (nameof(InboxRow.Source), "inbox.column.source"), + (nameof(InboxRow.Band), "inbox.column.status"), + (nameof(InboxRow.When), "inbox.column.when"), + }) + // The order is the select's: a header sort would be a second, unbound ordering. + grid = grid.WithColumn(new PropertyColumnControl { Property = property.ToCamelCase() } + .WithTitle(host.Localize(key)).WithSortable(false)); + return grid + .WithColumn(new TemplateColumnControl( + Controls.Button(host.Localize("inbox.open")).WithAppearance(Appearance.Lightweight) + .WithReactiveClickAction(ctx => + { + if (ctx.RowPath() is { Length: > 0 } path) + ctx.NavigateTo("/" + path.TrimStart('/')); + return Observable.Return(Unit.Default); + })) + .WithTitle(host.Localize("inbox.column.open")).WithSortable(false)) + .Resizable(); + } + + /// + /// Delete: every ticked row that the OWNER's own feed lists right now, confined to the owner's + /// partition, put away by its type's handler under the CLICKER's identity. What was put away + /// becomes the Undo batch; what was not is named in the outcome line. + /// + /// 🚨 The selection is the only thing taken from the client, and only as a set of keys. + /// The rows it is intersected with — and the node type that picks each handler — are + /// , re-read here from the legs: the grid's rows in the data section are + /// two-way, so pruning the client's keys against them would prove nothing. + /// + private static IObservable Delete(UiActionContext ctx, InboxActs acts, string owner, IObservable> rows) + { + var host = ctx.Host; + var hub = host.Hub; + var access = hub.ServiceProvider.GetService(); + // Captured HERE, on the click turn — the writes below are issued after a read, on whatever + // thread that read answers on, where the ambient context is no longer the clicker's. + var caller = access?.Context; + var handlers = hub.ServiceProvider.GetServices().ToImmutableArray(); + return acts.Exclusive(host.Localize("inbox.busy"), ctx.SelectedRowKeys(InboxRows.SelectionId) + .Zip(rows.Take(1), (keys, current) => InboxRows.Targets(current, keys, owner)) + .SelectMany(targets => targets.Count == 0 + ? Observable.Throw(new InvalidOperationException(host.Localize("inbox.nothingSelected"))) + : Observable.Defer(() => + { + // Each item enters the Undo batch the moment it is put away — never at the + // end: a click can be cancelled after some items were written, and those + // must stay undoable. The first one put away starts this click's batch. + acts.BeginDelete(); + return Act(ctx, access, caller, targets.Select(row => new InboxUndoItem(row.Path, row.NodeType)).ToImmutableList(), + handlers, (handler, path) => handler.Dismiss(hub, path), "inbox.progress.deleting", acts.PutAway); + }) + .Do(outcomes => + { + // The rows are gone from the grid; so are their ticks. + host.UpdateData(InboxRows.SelectionId, new DataGridSelectionState()); + ctx.ReportSummary(Summary(host, "inbox.summary.deleted", outcomes)); + }) + .Select(_ => Unit.Default))); + } + + /// + /// Undo: brings back what the last Delete put away, through the same handlers. The batch is the + /// HOST's own record of what this page deleted — never client data — so Undo can only restore + /// what Delete put away. + /// + private static IObservable Undo(UiActionContext ctx, InboxActs acts) + { + var host = ctx.Host; + var hub = host.Hub; + var access = hub.ServiceProvider.GetService(); + var caller = access?.Context; + var handlers = hub.ServiceProvider.GetServices().ToImmutableArray(); + return acts.Exclusive(host.Localize("inbox.busy"), Observable.Defer(() => + { + // Read once the act holds the page — never before, or a Delete that finished in + // between would be overwritten from a stale batch. + var batch = acts.Batch; + return batch is not { Count: > 0 } + ? Observable.Throw(new InvalidOperationException(host.Localize("inbox.nothingToUndo"))) + // Each item leaves the batch the moment it is brought back, so a cancelled Undo leaves + // exactly what is still away; whatever could not be brought back stays undoable. + : Act(ctx, access, caller, batch, handlers, (handler, path) => handler.Restore(hub, path), "inbox.progress.restoring", + acts.BroughtBack) + .Do(outcomes => + { + ctx.ReportSummary(Summary(host, "inbox.summary.restored", outcomes)); + }) + .Select(_ => Unit.Default); + })); + } + + /// + /// Runs for each item in turn, each call opened under the clicker's + /// access context. An item whose type has no handler, or whose handler faults, answers for + /// itself — the batch never stops at the first refusal. is told of each + /// item the moment it is done, so what was written is recorded even if the click is cancelled + /// before the batch finishes. + /// + private static IObservable> Act( + UiActionContext ctx, AccessService? access, AccessContext? caller, ImmutableList items, + ImmutableArray handlers, + Func> act, string progressKey, + Action done) + { + var logger = ctx.Hub.ServiceProvider.GetService()?.CreateLogger(typeof(InboxLayoutArea)); + return items + .Select((item, i) => Observable.Defer(() => + { + ctx.ReportProgress(ctx.Host.Localize(progressKey, i + 1, items.Count), (double)i / items.Count); + if (InboxItemHandlers.For(handlers, item.NodeType) is not { } handler) + return Observable.Return(new InboxItemOutcome(item.Path, false, ctx.Host.Localize("inbox.blocked.noHandler"))); + // 🚨 The scope is opened around the CALL: the handler issues its write inside it + // (IInboxItemHandler). Around the subscription it would be gone by the first hop. + IObservable Faulted(Exception ex) + { + // A package's handler may throw anything; its message is never shown. + logger?.LogWarning(ex, "Inbox: the {NodeType} handler faulted on {Path}", item.NodeType, item.Path); + return Observable.Return(new InboxItemOutcome(item.Path, false)); + } + using (access?.SwitchAccessContext(caller)) + { + // The handler builds its write inside this call (IInboxItemHandler), so it may + // also throw here rather than on the returned stream — either is that item's + // answer, never the batch's. + try + { + return Detached(act(handler, item.Path).Catch((Func>)Faulted), + outcome => + { + if (outcome.Done) + done(item); + }); + } + catch (Exception ex) + { + return Faulted(ex); + } + } + })) + .Concat() + .Aggregate(ImmutableList.Empty, (done, outcome) => done.Add(outcome)); + } + + /// + /// Runs on its OWN subscription, which sees to + /// the end, and hands the click a view of its outcome. A click is cancellable, and cancelling + /// disposes the click's chain — but a write already posted may still be committed by its owner + /// afterwards, and an outcome observed only through the click's chain would then be lost: the + /// item changes state and never enters (or leaves) the Undo batch. Detached, cancelling stops the + /// items not yet started; it never stops the bookkeeping of one that was. /// - internal static IReadOnlyList<(string Band, string Query)> Sections(IEnumerable legs, string owner) + /// One item's write, already built under the clicker's identity. + /// Told of the outcome however the click ends. + internal static IObservable Detached(IObservable write, Action landed) + { + var outcome = new AsyncSubject(); + write.Take(1).Do(landed).Subscribe(outcome); + return outcome; + } + + /// The outcome line: how many, and every item that was not done with its reason. + internal static string Summary(LayoutAreaHost host, string key, IReadOnlyCollection outcomes) { - var seen = new HashSet(StringComparer.Ordinal); - return InboxQueries.Resolve(legs, owner) - .OrderBy(leg => BandOrder.IndexOf(leg.Band) is var i and >= 0 ? i : BandOrder.Length) - .ThenBy(leg => leg.Band, StringComparer.Ordinal) - .ThenBy(leg => leg.Order) - .Where(leg => seen.Add(leg.Query)) - .Select(leg => (leg.Band, leg.Query)) - .ToArray(); + var failed = outcomes.Where(o => !o.Done).ToImmutableList(); + var line = host.Localize(key, outcomes.Count - failed.Count); + return failed.Count == 0 + ? line + : line + " " + host.Localize("inbox.summary.failed", failed.Count, + string.Join("; ", failed.Select(o => + $"{o.Path}: {(string.IsNullOrWhiteSpace(o.Reason) ? host.Localize("inbox.failed.generic") : o.Reason)}"))); } + /// + /// The legs of the page: every enabled leg resolved for , in band + /// order, each distinct query once. Pure — the page's shape is testable without a hub. + /// + internal static ImmutableArray Sections(IEnumerable legs, string owner) + => + [ + .. InboxQueries.Resolve(legs, owner) + .OrderBy(leg => BandOrder.IndexOf(leg.Band) is var i and >= 0 ? i : BandOrder.Length) + .ThenBy(leg => leg.Band, StringComparer.Ordinal) + .ThenBy(leg => leg.Order) + // Each distinct query once, under the first leg that asked for it (GroupBy keeps + // first-occurrence order). + .GroupBy(leg => leg.Query, StringComparer.Ordinal) + .Select(same => same.First()) + .Select(leg => new InboxSection(leg.Band, leg.Source, leg.Order, leg.Query) + { + SourceLabelKey = leg.SourceLabelKey, BandLabelKey = leg.BandLabelKey, + }) + ]; + + /// + /// A band's title in the viewer's language: core's own bands from core's catalog, a provider's + /// band from the key its leg gave, else its identifier. + /// + internal static string BandTitle(LayoutAreaHost host, string band, IEnumerable sections) + => BandOrder.Contains(band) ? host.Localize(BandTitleKey(band)) + : sections.FirstOrDefault(s => s.Band == band && !string.IsNullOrWhiteSpace(s.BandLabelKey))?.BandLabelKey is { } key + ? host.Localize(key) + : band; + /// The localization key of a band's title. internal static string BandTitleKey(string band) => "inbox.band." + band; } + +/// +/// One inbox page's acts: what the last Delete put away (what Undo brings back), and whether a +/// Delete or an Undo is running. Shared by the two buttons, and changed from whichever thread a write +/// completes on — a write still in flight when its click was cancelled can land beside the next +/// click's. +/// +/// 🚨 Every change goes through ONE serialized reactive channel — a subject of changes, folded +/// in arrival order into the state the page reads (Synchronize().Scan(...)). No change is a +/// read-modify-write of shared state, and none is coordinated by hand: each applies on top of the +/// one before, so every item actually put away stays undoable and every item brought back leaves. +/// The fold runs inside the call that pushes the change, so a caller reads its own change at once. +/// +internal sealed class InboxActs : IDisposable +{ + /// The batch, whether the next item put away starts a new one, and whether an act runs. + private sealed record State(ImmutableList Batch, bool Fresh, bool Busy); + + private static readonly State Initial = new(ImmutableList.Empty, false, false); + private readonly Subject> changes = new(); + private readonly BehaviorSubject state = new(Initial); + private readonly IDisposable fold; + + /// Opens the page's channel. + public InboxActs() + => fold = changes.Synchronize().Scan(Initial, (current, change) => change(current)).Subscribe(state); + + /// What the last Delete put away and Undo has not yet brought back. + public ImmutableList Batch => state.Value.Batch; + + /// + /// A Delete is starting: the first item it puts away replaces the previous batch. A Delete that + /// puts nothing away leaves the previous batch undoable. + /// + public void BeginDelete() => Push(s => s with { Fresh = true }); + + /// Records one item a Delete has just put away — called per item, as it happens. + /// The item. + public void PutAway(InboxUndoItem item) + => Push(s => s with { Batch = s.Fresh ? [item] : s.Batch.Add(item), Fresh = false }); + + /// Records one item an Undo has just brought back — called per item, as it happens. + /// The item. + public void BroughtBack(InboxUndoItem item) + => Push(s => s with { Batch = s.Batch.RemoveAll(i => string.Equals(i.Path, item.Path, StringComparison.Ordinal)) }); + + /// + /// Runs only when no other Delete or Undo is running on this page, and + /// refuses it — with a line that says so — when one is. The two are separate buttons, so the + /// click framework would run them side by side. The claim is a change on the same channel, taken + /// on subscription and released however the act ends. + /// + /// What the refused click says, in the viewer's language. + /// The Delete or the Undo. + public IObservable Exclusive(string refusal, IObservable act) + => Observable.Defer(() => + { + var claimed = false; + Push(s => + { + claimed = !s.Busy; + return s.Busy ? s : s with { Busy = true }; + }); + return claimed + ? act.Finally(() => Push(s => s with { Busy = false })) + : Observable.Throw(new InvalidOperationException(refusal)); + }); + + /// + /// Pushes one change. A write can complete after its page was closed; the channel is then + /// COMPLETED, and a completed subject ignores what arrives after — so the late change is dropped, + /// never thrown into the write's own pipeline. (Checking "disposed?" first would be a race.) + /// + private void Push(Func change) => changes.OnNext(change); + + /// + /// Closes the channel: completes it — never disposes it, so a late push is ignored rather than + /// refused — and ends the fold. The last state stays readable. + /// + public void Dispose() + { + changes.OnCompleted(); + fold.Dispose(); + } +} diff --git a/src/MeshWeaver.Graph/InboxQueries.cs b/src/MeshWeaver.Graph/InboxQueries.cs index c3a01409fd..507c56f22f 100644 --- a/src/MeshWeaver.Graph/InboxQueries.cs +++ b/src/MeshWeaver.Graph/InboxQueries.cs @@ -2,6 +2,7 @@ using MeshWeaver.Data; using MeshWeaver.Graph.Configuration; using MeshWeaver.Mesh; +using Microsoft.Extensions.DependencyInjection; namespace MeshWeaver.Graph; @@ -28,6 +29,110 @@ public static class InboxQueries /// public const string RowProjection = "select:path,name,nodeType,icon,lastModified"; + /// + /// How many rows the inbox asks ONE leg for. Every leg sorts newest first, so this is the newest + /// items of each source — what a person works through; anything older is + /// found with the search box, which goes to the query engine. + /// + /// 🚨 Without a bound the page folds every historical row of every source into memory and + /// into the client's payload, on a surface that is open all day. The bound is the page's, not + /// the provider's: a leg that names its own limit: keeps it. + /// + public const int LegLimit = 100; + + /// + /// How many rows is read for: the provider's own limit when it names a + /// POSITIVE one no larger than , else . Read through + /// the query parser, never by spotting a limit: token — limit:all and a + /// non-positive limit are UNBOUNDED to the parser, and a larger one would lift the page's cap. + /// + /// A resolved leg query. + public static int EffectiveLimit(string query) + => new QueryParser().Parse(query).Limit is > 0 and var own && own <= LegLimit ? own : LegLimit; + + /// + /// as the page runs it: bounded to rows and + /// projected onto . Whatever a provider wrote, the page reads only + /// what a row shows — a contributed leg with no select:, or a wider one, would otherwise + /// fetch whole thread and message bodies to render one line each. A term already right is left as + /// it is; otherwise the page's is appended, and the parser takes the LAST one it reads. + /// + /// A resolved leg query. + public static string Bounded(string query) + { + var parsed = new QueryParser().Parse(query); + var limit = EffectiveLimit(query); + var projected = parsed.Select is { } select && select.Order(StringComparer.Ordinal) + .SequenceEqual(RowFields.Order(StringComparer.Ordinal), StringComparer.Ordinal); + return query + + (parsed.Limit == limit ? string.Empty : $" limit:{limit.ToString(System.Globalization.CultureInfo.InvariantCulture)}") + + (projected ? string.Empty : " " + RowProjection); + } + + /// The fields a row reads — as a list. + internal static ImmutableArray RowFields { get; } = [.. RowProjection["select:".Length..].Split(',')]; + + /// + /// Whether a RESOLVED leg reads exactly one partition, and that partition is the + /// 's: its first term is a namespace: or path: whose first + /// segment is the viewer, literally — no wildcard, no alternation — it is the query's ONLY + /// namespace:/path: term, and the path the PARSER resolves (and any path list) lies + /// in the viewer's partition with no partitions:all. A leg that fails is never run ( drops it): an + /// unanchored query is a fan-out over every partition schema, on every render of every inbox. + /// + /// The leg's query with the viewer substituted. + /// The viewer's partition. + public static bool IsAnchored(string resolvedQuery, string viewer) + { + var terms = resolvedQuery.Split(' ', StringSplitOptions.RemoveEmptyEntries); + // The FIRST term is the anchor, on the viewer's own partition — compared to the viewer's id + // itself, never to a character class: partition keys may be any Unicode letters and digits + // (PartitionDefinition.IsValidPartitionSegment), and "müller" must get an inbox. + if (terms.Length == 0 || Anchor(terms[0]) is not { } anchor || !InPartition(anchor, viewer) + // ONE structural anchor: the parser lets a later namespace:/path: replace the first, + // so a leg that names two could pass on its first and run on its second. + || terms.Count(IsStructural) != 1) + return false; + // And what the PARSER will run — not only what the text says first. + var parsed = new QueryParser().Parse(resolvedQuery); + return !parsed.CrossPartition + && parsed.Path is { } path && InPartition(path, viewer) + && (parsed.Paths is null || parsed.Paths.All(p => InPartition(p, viewer))); + } + + private static string? Anchor(string term) + => term.StartsWith("namespace:", StringComparison.OrdinalIgnoreCase) ? term["namespace:".Length..] + : term.StartsWith("path:", StringComparison.OrdinalIgnoreCase) ? term["path:".Length..] + : null; + + private static bool IsStructural(string term) + { + var bare = term.TrimStart('-', '!'); + return bare.StartsWith("namespace:", StringComparison.OrdinalIgnoreCase) + || bare.StartsWith("path:", StringComparison.OrdinalIgnoreCase); + } + + private static bool InPartition(string path, string viewer) + { + var trimmed = path.TrimStart('/'); + return trimmed.Equals(viewer, StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith(viewer + "/", StringComparison.OrdinalIgnoreCase); + } + + /// + /// The enabled legs refuses to run because they are not anchored on + /// 's partition — for the page to LOG, so a provider's mistake is loud + /// rather than a silently missing source. + /// + /// The contributed legs. + /// The viewer's partition. + public static ImmutableArray Refused(IEnumerable legs, string viewer) + => [.. Substituted(legs, viewer).Where(leg => !IsAnchored(leg.Query, viewer))]; + + private static IEnumerable Substituted(IEnumerable legs, string viewer) + => legs.Where(leg => leg.Enabled && !string.IsNullOrWhiteSpace(leg.Query)) + .Select(leg => leg with { Query = leg.Query.Replace(ViewerToken, viewer, StringComparison.Ordinal) }); + /// /// The terminal ActivityStatus values, as query terms. 🚨 Running is the enum /// DEFAULT, and the serializer omits defaults from stored JSON — so a positive @@ -50,14 +155,33 @@ public static class InboxQueries new() { Source = "Notifications", + SourceLabelKey = "inbox.source.Notifications", Band = InboxBand.NeedsYou, Order = 10, + // Not put away. `Dismissed` defaults to false and is therefore OMITTED from stored + // JSON, so the leg negates the stamped `true` — a positive match on `false` would + // select nothing (the same trap as the activity legs below). Query = $"namespace:{ViewerToken}/{NotificationService.SatelliteSegment} " - + $"nodeType:{NotificationNodeType.NodeType} sort:CreatedAt-desc {RowProjection}", + + $"nodeType:{NotificationNodeType.NodeType} -content.dismissed:true " + + $"sort:CreatedAt-desc {RowProjection}", + }, + new() + { + Source = "Mail", + SourceLabelKey = "inbox.source.Mail", + Band = InboxBand.NeedsYou, + Order = 20, + // The viewer's own stored mail, inbound and not archived. Both defaults (Inbound, New) + // are omitted from stored JSON, so the leg negates the two stamped values it excludes. + Query = $"namespace:{ViewerToken}/{EmailNodeType.UserEmailSegment} " + + $"nodeType:{EmailNodeType.NodeType} -content.status:{nameof(EmailStatus.Archived)} " + + $"-content.direction:{nameof(EmailDirection.Outbound)} " + + $"sort:LastModified-desc {RowProjection}", }, new() { Source = "Activities", + SourceLabelKey = "inbox.source.Activities", Band = InboxBand.Running, Order = 10, // Not terminal — every stamped terminal state negated (Running itself is omitted). @@ -68,6 +192,7 @@ public static class InboxQueries new() { Source = "Activities", + SourceLabelKey = "inbox.source.Activities", Band = InboxBand.Recent, Order = 20, // Terminal — any stamped terminal state. @@ -77,6 +202,15 @@ public static class InboxQueries }, ]; + /// + /// Every leg on this mesh: core's own plus each one a package registered as a service + /// (services.AddSingleton(new InboxQueryLeg { … })). The inbox runs what it is handed — + /// it carries no list of the packages it knows. + /// + /// The hub's service provider. + public static ImmutableArray All(IServiceProvider services) + => [.. BuiltIn, .. services.GetServices()]; + /// /// Substitutes the viewer into every enabled leg, ordered by band then . /// @@ -99,8 +233,9 @@ public static ImmutableArray Resolve(IEnumerable l return [ - .. legs.Where(leg => leg.Enabled && !string.IsNullOrWhiteSpace(leg.Query)) - .Select(leg => leg with { Query = leg.Query.Replace(ViewerToken, viewer, StringComparison.Ordinal) }) + // 🚨 A leg that does not read exactly the viewer's own partition is never run. + .. Substituted(legs, viewer) + .Where(leg => IsAnchored(leg.Query, viewer)) .OrderBy(leg => leg.Band, StringComparer.Ordinal) .ThenBy(leg => leg.Order) .ThenBy(leg => leg.Source, StringComparer.Ordinal) diff --git a/src/MeshWeaver.Graph/InboxRows.cs b/src/MeshWeaver.Graph/InboxRows.cs new file mode 100644 index 0000000000..cc8981cb61 --- /dev/null +++ b/src/MeshWeaver.Graph/InboxRows.cs @@ -0,0 +1,257 @@ +using System.Collections.Immutable; +using System.Globalization; +using MeshWeaver.Mesh; +using MeshWeaver.Messaging; + +namespace MeshWeaver.Graph; + +/// One leg of the page, resolved for its owner: which band it feeds, who brought it, and its query. +/// The band the leg feeds. +/// Who brought the leg — the row's source marker. +/// The leg's order within its band. +/// The resolved query, anchored on the owner. +public sealed record InboxSection(string Band, string Source, int Order, string Query) +{ + /// The catalog key of the source's display name, when the provider gave one. + public string? SourceLabelKey { get; init; } + + /// The catalog key of a provider band's display name, when the provider gave one. + public string? BandLabelKey { get; init; } +} + +/// +/// One row of the inbox, whatever brought it — the grid's row record, so its camelCase property +/// names are the column properties. +/// +public sealed record InboxRow +{ + /// The item's node path — the row key, and where Open goes. + public string Path { get; init; } = string.Empty; + + /// What the row says: the node's name, else its id. + public string Title { get; init; } = string.Empty; + + /// Who brought the row, as the viewer reads it — the source's display name. + public string Source { get; init; } = string.Empty; + + /// + /// Who brought the row, as an identifier — the leg's . What + /// the source's switch keys on, never shown. (The by-source order follows the display name.) + /// + public string SourceId { get; init; } = string.Empty; + + /// The item's node type — what picks its . + public string NodeType { get; init; } = string.Empty; + + /// The band the row belongs to, as the viewer reads it ("Needs you", "Running", …). + public string Band { get; init; } = string.Empty; + + /// The band's rank — what needs the viewer first. The importance order, never shown. + public int Rank { get; init; } + + /// When the item last changed, in the VIEWER's time zone. + public string When { get; init; } = string.Empty; + + /// The same instant as a sortable UTC stamp — the row order, never shown. + public string Stamp { get; init; } = string.Empty; + + /// + /// Why the row cannot be selected, or empty when it can — the grid renders a disabled checkbox + /// with this as its tooltip. + /// + public string BlockedReason { get; init; } = string.Empty; +} + +/// What Delete put away — what Undo brings back. +/// The item's node path. +/// Its node type, so Undo finds the same handler. +public sealed record InboxUndoItem(string Path, string NodeType); + +/// +/// The orderings the inbox offers — a CLOSED set, owned by the page: the select lists exactly +/// , and no package adds one. String constants only because the chosen one is +/// STORED in the page's data section and bound to the select. A stored value outside the set (one a +/// later version retired) is read as , so a stale preference can never hide +/// what needs the viewer behind what merely happened last. +/// +public static class InboxOrder +{ + /// What needs the viewer first, then what is running, then what finished — newest first within each. The default. + public const string Importance = "Importance"; + + /// Newest change first, whatever the band. + public const string Newest = "Newest"; + + /// Oldest change first. + public const string Oldest = "Oldest"; + + /// By title, A to Z. + public const string Title = "Title"; + + /// By the source's display name, then newest first. + public const string Source = "Source"; + + /// Every ordering, in the order the select lists them. + public static ImmutableArray All { get; } = [Importance, Newest, Oldest, Title, Source]; +} + +/// +/// What the viewer asked the inbox to show — the page's ONE piece of view state, held in the layout +/// area's data section and bound, field by field, to the controls above the grid: the search box, +/// the order select and one switch per source. The page never keeps a copy of it. +/// +public sealed record InboxFilter +{ + /// The search text. It is handed to the mesh query engine with every leg — not matched against the rows already loaded. + public string Search { get; init; } = string.Empty; + + /// An constant. + public string Order { get; init; } = InboxOrder.Importance; + + /// + /// Which sources are shown, by . A source with no entry is + /// shown: a leg a package brings later must not arrive switched off. + /// + public ImmutableDictionary Sources { get; init; } = ImmutableDictionary.Empty; +} + +/// The rows of the inbox, as pure functions of the nodes the legs returned. +public static class InboxRows +{ + /// The row property that keys a row (camelCase, as serialized). + public const string KeyProperty = "path"; + + /// The row property that carries why a row is not selectable. + public const string BlockedReasonProperty = "blockedReason"; + + /// The data id of the grid's selection. No /: a client write into such an id does not reach the owner. + public const string SelectionId = "inboxSelection"; + + /// The data id of the grid's rows. + public const string GridId = "inboxRows"; + + /// The data id of the the controls above the grid are bound to. + public const string FilterId = "inboxFilter"; + + /// + /// The key of in — and so the last + /// segment of the pointer its switch is bound to. Lower-case letters and digits only: a source is + /// named by its provider, a pointer segment must not contain /, and a serializer that + /// re-cases dictionary keys must not move it. + /// + /// The leg's source. + public static string SourceKey(string source) + // The id itself, hex-encoded: one key per id BY CONSTRUCTION. A readable slug is not unique + // ("Ops / Approvals" and "Ops Approvals" read the same) and a hash suffix only makes a + // collision rarer — two sources sharing a key would share one switch. + => "s" + Convert.ToHexString(System.Text.Encoding.UTF8.GetBytes(source)).ToLowerInvariant(); + + /// + /// by VALUE — what the page compares to tell a change the viewer made + /// from a re-emission of the same state. The record's own equality cannot: its dictionary is + /// compared by reference, and every read of the data section deserializes a new one. + /// + /// The filter. + public static string Fingerprint(InboxFilter filter) + => string.Join('\n', new[] { filter.Search, filter.Order } + .Concat(filter.Sources.OrderBy(s => s.Key, StringComparer.Ordinal).Select(s => $"{s.Key}={s.Value}"))); + + /// + /// The row for , brought by . + /// + /// The node the leg returned (projected — content is not read). + /// The leg that returned it. + /// The viewer's time zone, resolved once on the render turn. + /// Why the row cannot be selected, or null when it can. + /// The band's title as the viewer reads it; the band id when omitted. + /// The band's rank in the importance order. + /// The source's display name; its id when omitted. + /// The viewer's culture, resolved once on the render turn; invariant when omitted. + public static InboxRow Of(MeshNode node, InboxSection section, string? zone, string? blockedReason, + string? band = null, int rank = 0, string? sourceLabel = null, CultureInfo? culture = null) + => new() + { + Path = node.Path, + Title = string.IsNullOrWhiteSpace(node.Name) ? node.Id : node.Name, + Source = sourceLabel ?? section.Source, + SourceId = section.Source, + NodeType = node.NodeType ?? string.Empty, + Band = band ?? section.Band, + Rank = rank, + // A stored instant, shown where the viewer is and the way the viewer writes a date — + // never the container's UTC, never one fixed pattern for every language. + When = DisplayTimeExtensions.ToDisplayTime(node.LastModified, zone) + .ToString("g", culture ?? CultureInfo.InvariantCulture), + Stamp = node.LastModified.UtcDateTime.ToString("O", CultureInfo.InvariantCulture), + BlockedReason = blockedReason ?? string.Empty, + }; + + /// + /// Every leg's rows as one list: each path once — the first leg that brought it wins, and legs + /// arrive in band order, so an item two bands select is listed under the more urgent one. + /// + /// The rows each leg produced, in leg order. + public static ImmutableList Merge(IEnumerable> legs) + => legs.SelectMany(rows => rows) + .GroupBy(row => row.Path, StringComparer.Ordinal) + .Select(group => group.First()) + .ToImmutableList(); + + /// + /// The rows the viewer asked for: the sources switched on, in the chosen order. The search is + /// NOT applied here — it went to the query engine with each leg. + /// + /// The merged rows. + /// What the viewer asked for. + /// + /// The viewer's culture — "by title" and "by source" are alphabetical in the VIEWER's language + /// (in German, Ä sorts with A, not after Z). Invariant when omitted. + /// + public static ImmutableList Apply(IEnumerable rows, InboxFilter filter, CultureInfo? culture = null) + { + var text = StringComparer.Create(culture ?? CultureInfo.InvariantCulture, ignoreCase: true); + var shown = rows.Where(row => !filter.Sources.TryGetValue(SourceKey(row.SourceId), out var on) || on); + var ordered = filter.Order switch + { + InboxOrder.Newest => shown.OrderByDescending(r => r.Stamp, StringComparer.Ordinal), + InboxOrder.Oldest => shown.OrderBy(r => r.Stamp, StringComparer.Ordinal), + InboxOrder.Title => shown.OrderBy(r => r.Title, text) + .ThenByDescending(r => r.Stamp, StringComparer.Ordinal), + // By the name the viewer READS, so the list is alphabetical in their language; the id + // only separates two sources that happen to share a display name. + InboxOrder.Source => shown.OrderBy(r => r.Source, text) + .ThenBy(r => r.SourceId, StringComparer.Ordinal) + .ThenByDescending(r => r.Stamp, StringComparer.Ordinal), + // Importance — and anything unrecognised: an unknown stored order must never hide what + // needs the viewer behind what merely happened last. + _ => shown.OrderBy(r => r.Rank).ThenByDescending(r => r.Stamp, StringComparer.Ordinal), + }; + return ordered.ThenBy(r => r.Path, StringComparer.Ordinal).ToImmutableList(); + } + + /// + /// How many rows of the obligation band the source switches hide — counted BEFORE the switches + /// apply, so the count is never filtered away with the rows. + /// + /// The merged rows. + /// What the viewer asked for. + /// The rank of the "Needs you" band, or null when no leg feeds it. + public static int HiddenObligations(IEnumerable rows, InboxFilter filter, int? needsYouRank) + => needsYouRank is not { } rank + ? 0 + : rows.Count(row => row.Rank == rank + && filter.Sources.TryGetValue(SourceKey(row.SourceId), out var on) && !on); + + /// + /// The rows a bulk act may touch: the selected keys re-checked against the rows as they stand + /// now — a selection is the viewer's claim about what they saw, never a grant — and confined to + /// the OWNER's partition, so a search that reached further can never turn into a write there. + /// + /// The grid's current rows. + /// The keys the client reported. + /// The inbox's owner. + public static ImmutableList Targets(IEnumerable rows, IEnumerable selected, string owner) + => MeshWeaver.Layout.DataGrid.DataGridRowSelection.Prune( + rows.Where(row => row.Path.StartsWith(owner + "/", StringComparison.Ordinal)), + selected, r => r.Path, r => r.BlockedReason); +} diff --git a/src/MeshWeaver.Mesh.Contract/InboxItemHandler.cs b/src/MeshWeaver.Mesh.Contract/InboxItemHandler.cs new file mode 100644 index 0000000000..27241e0c92 --- /dev/null +++ b/src/MeshWeaver.Mesh.Contract/InboxItemHandler.cs @@ -0,0 +1,62 @@ +using MeshWeaver.Messaging; + +namespace MeshWeaver.Mesh; + +/// +/// What became of ONE inbox item an action was asked for. +/// +/// The item's node path. +/// True when the item is now in the state the action asked for. +/// +/// Why not, when is false — shown to the viewer VERBATIM, so it is a refusal +/// the handler chose to state (a rule that applies), never an exception's message. Null when the +/// handler has nothing safe to say: the inbox then reports a generic failure, and the handler has +/// logged the cause. +/// +public sealed record InboxItemOutcome(string Path, bool Done, string? Reason = null); + +/// +/// What the inbox may DO to the items of one node type — the action half of the potluck whose query +/// half is . A provider that brings a leg brings the handler for the type +/// its leg selects; the inbox owns the selection, the buttons and the Undo, and knows nothing about +/// what "put this away" means for a thread, a mail or a notification. +/// +/// Register one as a singleton service per node type. A type with no handler is still LISTED +/// (its leg runs), its rows are simply not selectable — the inbox never guesses a write for a type +/// nobody taught it. +/// +/// 🚨 Dismiss is SOFT, and brings the item BACK INTO ITS LEG. +/// The inbox's Delete is Outlook's: the item leaves the list and can be brought back. A handler +/// therefore moves the item into a state its own leg excludes (a closed thread, an archived mail, a +/// dismissed notification) and never deletes a node. Restore is not promised to be the exact +/// inverse — the inbox keeps no record of the prior state — only to return the item to a state its +/// leg selects again: an archived mail comes back as Read whether it was New or Read before. A governed record — an approval, an instance action — +/// has no handler here at all: it leaves the inbox by being decided. +/// +/// 🚨 Both calls run under the VIEWER's identity, and the write must be issued inside the +/// call — the inbox opens the clicker's access context around the invocation, not around the +/// subscription, because the ambient context does not survive a pool or scheduler hop. A handler +/// that defers its write to a later emission issues it as whoever owns that thread. +/// +public interface IInboxItemHandler +{ + /// The node type whose rows this handles — compared ordinally to the row's own. + string NodeType { get; } + + /// + /// Puts away: afterwards the provider's own leg no longer selects it. + /// Emits one outcome and completes; a refusal is an outcome with a reason, never a fault. + /// + /// The hub the inbox renders on. + /// The item's node path. + IObservable Dismiss(IMessageHub hub, string path); + + /// + /// Brings back: afterwards the provider's leg selects it again — in a + /// state the handler chooses, which need not be the one found. On the same + /// terms as otherwise. + /// + /// The hub the inbox renders on. + /// The item's node path. + IObservable Restore(IMessageHub hub, string path); +} diff --git a/src/MeshWeaver.Mesh.Contract/InboxQueryLeg.cs b/src/MeshWeaver.Mesh.Contract/InboxQueryLeg.cs index 3d700cb7bd..4c0f38b54f 100644 --- a/src/MeshWeaver.Mesh.Contract/InboxQueryLeg.cs +++ b/src/MeshWeaver.Mesh.Contract/InboxQueryLeg.cs @@ -48,9 +48,24 @@ public record InboxQueryLeg /// public string Source { get; init; } = string.Empty; + /// + /// The catalog key of the source's DISPLAY name — what the row's "From" cell and the source's + /// switch show, in the viewer's language. is an identifier (it keys the + /// switch and the order) and is shown only when a provider gives no key, which is right for a + /// name that is the same in every language (a product or a channel) and wrong for a word. + /// + public string? SourceLabelKey { get; init; } + /// An constant, or a band the provider defined itself. public string Band { get; init; } = InboxBand.NeedsYou; + /// + /// The catalog key of a provider-defined band's DISPLAY name, in the viewer's language. Ignored + /// for the constants, which core names itself; a provider band with no + /// key shows its identifier, which is right only for a name that is the same in every language. + /// + public string? BandLabelKey { get; init; } + /// /// The query text, with {viewer} where the viewer's partition belongs. Substituted by /// InboxQueries.Resolve; a leg is stored as a template so it is durable and viewer-agnostic. diff --git a/src/MeshWeaver.Mesh.Contract/Notification.cs b/src/MeshWeaver.Mesh.Contract/Notification.cs index 265a90ce37..6ec3e1b627 100644 --- a/src/MeshWeaver.Mesh.Contract/Notification.cs +++ b/src/MeshWeaver.Mesh.Contract/Notification.cs @@ -123,6 +123,20 @@ public record Notification [Browsable(false)] public bool IsRead { get; init; } + /// + /// Whether the addressee put the notification away in the inbox ("Delete"). SOFT: the node + /// stays until retention removes it, the inbox's leg stops selecting it + /// (-content.dismissed:true), and Undo clears the flag. Distinct from + /// — reading a notification does not remove it from the list, and putting + /// it away does not claim it was read. + /// + /// 🚨 Default false and therefore OMITTED from stored JSON, which is why the leg + /// negates the true value rather than matching false: a positive + /// content.dismissed:false would match no notification ever written. + /// + [Browsable(false)] + public bool Dismissed { get; init; } + /// /// When the notification was created. /// diff --git a/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json b/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json index ccdacc6be0..4cb5014e34 100644 --- a/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json +++ b/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json @@ -1899,6 +1899,39 @@ "inbox.band.NeedsYou": "Braucht Sie", "inbox.band.Running": "Läuft", "inbox.band.Recent": "Kürzlich", + "inbox.capped": "Einige Quellen zeigen nur ihre neuesten Einträge. Ältere finden Sie über die Suche.", + "inbox.hidden": "{0} Eintrag/Einträge, die auf Sie warten, sind durch die Quellen-Schalter ausgeblendet.", + "inbox.unread": "Konnte nicht gelesen werden — Suche ändern oder Seite neu öffnen, um es erneut zu versuchen: {0}", + "inbox.source.Notifications": "Benachrichtigungen", + "inbox.source.Mail": "E-Mail", + "inbox.source.Activities": "Aktivitäten", + "inbox.failed.generic": "konnte nicht geändert werden", + "inbox.search": "Suche", + "inbox.search.placeholder": "Posteingang durchsuchen", + "inbox.order": "Reihenfolge", + "inbox.order.Importance": "Zuerst, was auf Sie wartet", + "inbox.order.Newest": "Neueste zuerst", + "inbox.order.Oldest": "Älteste zuerst", + "inbox.order.Title": "Nach Titel", + "inbox.order.Source": "Nach Quelle", + "inbox.delete": "Löschen", + "inbox.undo": "Rückgängig", + "inbox.open": "Öffnen", + "inbox.band.empty": "Nichts vorhanden.", + "inbox.column.item": "Eintrag", + "inbox.column.source": "Von", + "inbox.column.when": "Letzte Änderung", + "inbox.column.open": "Öffnen", + "inbox.blocked.running": "Läuft noch. Der Eintrag verschwindet aus dieser Liste, sobald er fertig ist.", + "inbox.blocked.noHandler": "Diese Art von Eintrag lässt sich nur auf ihrer eigenen Seite bearbeiten.", + "inbox.nothingSelected": "Zuerst die Zeilen ankreuzen, die gelöscht werden sollen.", + "inbox.nothingToUndo": "Es gibt nichts rückgängig zu machen.", + "inbox.busy": "Ein Löschen oder Rückgängig läuft noch — bitte erneut versuchen, wenn es fertig ist.", + "inbox.progress.deleting": "Lösche {0} von {1}", + "inbox.progress.restoring": "Stelle {0} von {1} wieder her", + "inbox.summary.deleted": "{0} aus dem Posteingang entfernt. Mit Rückgängig kommen sie zurück.", + "inbox.summary.restored": "{0} wieder im Posteingang.", + "inbox.summary.failed": "{0} nicht erledigt: {1}", "adminApp.title": "Administration", "adminApp.overview": "Übersicht", "adminApp.instanceTitle": "Diese Instanz", diff --git a/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json b/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json index ef7c9b4ba5..d74cf2f2e9 100644 --- a/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json +++ b/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json @@ -1899,6 +1899,39 @@ "inbox.band.NeedsYou": "Needs you", "inbox.band.Running": "Running", "inbox.band.Recent": "Recent", + "inbox.capped": "Some sources list only their newest items. Use the search to find older ones.", + "inbox.hidden": "{0} item(s) needing you are hidden by the source switches.", + "inbox.unread": "Could not be read — change the search or reopen the page to try again: {0}", + "inbox.source.Notifications": "Notifications", + "inbox.source.Mail": "Mail", + "inbox.source.Activities": "Activities", + "inbox.failed.generic": "could not be changed", + "inbox.search": "Search", + "inbox.search.placeholder": "Search the inbox", + "inbox.order": "Order", + "inbox.order.Importance": "What needs you first", + "inbox.order.Newest": "Newest first", + "inbox.order.Oldest": "Oldest first", + "inbox.order.Title": "By title", + "inbox.order.Source": "By source", + "inbox.delete": "Delete", + "inbox.undo": "Undo", + "inbox.open": "Open", + "inbox.band.empty": "Nothing here.", + "inbox.column.item": "Item", + "inbox.column.source": "From", + "inbox.column.when": "Last change", + "inbox.column.open": "Open", + "inbox.blocked.running": "Still running. It leaves this list when it finishes.", + "inbox.blocked.noHandler": "This kind of item can only be handled on its own page.", + "inbox.nothingSelected": "Tick the rows you want to delete first.", + "inbox.nothingToUndo": "There is nothing to undo.", + "inbox.busy": "A Delete or Undo is still running — try again when it has finished.", + "inbox.progress.deleting": "Deleting {0} of {1}", + "inbox.progress.restoring": "Bringing back {0} of {1}", + "inbox.summary.deleted": "{0} moved out of the inbox. Undo brings them back.", + "inbox.summary.restored": "{0} back in the inbox.", + "inbox.summary.failed": "{0} not done: {1}", "adminApp.title": "Administration", "adminApp.overview": "Overview", "adminApp.instanceTitle": "This instance", diff --git a/test/MeshWeaver.Graph.Test/InboxAppTest.cs b/test/MeshWeaver.Graph.Test/InboxAppTest.cs index 218bcd1007..7cec918d0e 100644 --- a/test/MeshWeaver.Graph.Test/InboxAppTest.cs +++ b/test/MeshWeaver.Graph.Test/InboxAppTest.cs @@ -128,7 +128,7 @@ public void ThePage_AnchorsEveryLegOnTheOwner_InBandOrder_EachQueryOnce() /// [Fact] public void ThePage_ShowsThreeDistinctBands() - => InboxLayoutArea.Sections(InboxQueries.BuiltIn, "alice").Select(s => s.Band) + => InboxLayoutArea.Sections(InboxQueries.BuiltIn, "alice").Select(s => s.Band).Distinct() .Should().Equal(InboxBand.NeedsYou, InboxBand.Running, InboxBand.Recent); /// diff --git a/test/MeshWeaver.Graph.Test/InboxLegsAreAnchoredTest.cs b/test/MeshWeaver.Graph.Test/InboxLegsAreAnchoredTest.cs index d5ac00f16a..25496745b6 100644 --- a/test/MeshWeaver.Graph.Test/InboxLegsAreAnchoredTest.cs +++ b/test/MeshWeaver.Graph.Test/InboxLegsAreAnchoredTest.cs @@ -91,10 +91,51 @@ public void AnUnanchoredContributedLeg_IsCaughtByTheSameCheck() Query = "nodeType:Notification sort:CreatedAt-desc", }; - var resolved = InboxQueries.Resolve([rogue], Viewer); - - Assert.False(Anchored.IsMatch(resolved.Single().Query), + Assert.False(Anchored.IsMatch(rogue.Query), "the anchor check must be able to FAIL, or it proves nothing about the built-ins"); + Assert.Empty(InboxQueries.Resolve([rogue], Viewer)); + Assert.Equal("Rogue", Assert.Single(InboxQueries.Refused([rogue], Viewer)).Source); + } + + /// + /// The runtime guard, not only the test's regex: a contributed leg is RUN only when it reads + /// exactly the viewer's own partition. Another person's partition, a wildcard, an alternation and + /// a declared fan-out are all refused; the viewer's own partition is not. + /// + [Theory] + [InlineData("namespace:{viewer}/_Thread nodeType:Thread", true)] + [InlineData("path:{viewer}/_Email nodeType:Email", true)] + [InlineData("namespace:someone-else/_Thread nodeType:Thread", false)] + [InlineData("namespace:*/_Thread nodeType:Thread", false)] + [InlineData("namespace:{viewer}|someone-else nodeType:Thread", false)] + [InlineData("namespace:{viewer}/_Thread partitions:all nodeType:Thread", false)] + [InlineData("nodeType:Thread namespace:{viewer}/_Thread", false)] + [InlineData("namespace:{viewer}x/_Thread", false)] + [InlineData("namespace:{viewer}/_Thread namespace:someone-else/_Thread", false)] + [InlineData("namespace:{viewer}/_Thread path:someone-else/_Thread", false)] + [InlineData("path:{viewer}/_Email nodeType:Email path:someone-else/_Email", false)] + public void OnlyALegReadingTheViewersOwnPartition_IsRun(string query, bool run) + => AssertRun(query, run, Viewer); + + /// + /// A partition key may be any Unicode letters and digits — a guard that only knew ASCII would + /// refuse every leg of such a viewer and leave their inbox empty. + /// + [Theory] + [InlineData("müller")] + [InlineData("zoë-ångström")] + public void AUnicodeViewer_GetsEveryBuiltInLeg(string viewer) + { + Assert.Equal(InboxQueries.BuiltIn.Length, InboxQueries.Resolve(InboxQueries.BuiltIn, viewer).Length); + AssertRun("namespace:{viewer}/_Thread nodeType:Thread", true, viewer); + AssertRun("namespace:someone-else/_Thread nodeType:Thread", false, viewer); + } + + private static void AssertRun(string query, bool run, string viewer) + { + var leg = new InboxQueryLeg { Source = "Contributed", Band = InboxBand.NeedsYou, Query = query }; + Assert.Equal(run, InboxQueries.Resolve([leg], viewer).Length == 1); + Assert.Equal(!run, InboxQueries.Refused([leg], viewer).Length == 1); } /// diff --git a/test/MeshWeaver.Graph.Test/InboxSelectAndDeleteTest.cs b/test/MeshWeaver.Graph.Test/InboxSelectAndDeleteTest.cs new file mode 100644 index 0000000000..e583f47c4f --- /dev/null +++ b/test/MeshWeaver.Graph.Test/InboxSelectAndDeleteTest.cs @@ -0,0 +1,842 @@ +using System.Collections.Immutable; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Text.Json; +using System.Text.Json.Nodes; +using MeshWeaver.Layout; +using MeshWeaver.Layout.Client; +using MeshWeaver.Layout.DataGrid; +using MeshWeaver.Data; +using MeshWeaver.Fixture; +using MeshWeaver.Graph.Configuration; +using MeshWeaver.Hosting.Monolith.TestBase; +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Security; +using MeshWeaver.Mesh.Services; +using MeshWeaver.Messaging; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace MeshWeaver.Graph.Test; + +/// +/// The unified inbox's select-many-then-act half: what Delete and Undo do to the two item types core +/// owns, executed against a real mesh and read back THROUGH THE LEG — the leg no longer selecting a +/// put-away item is the behaviour, the flag on the node is only how it is achieved. Plus the pure +/// rules the page is built from: which rows may be ticked, how legs merge, what the outcome line says. +/// +public class InboxSelectAndDeleteTest(ITestOutputHelper output) : MonolithMeshTestBase(output) +{ + private const string DeleteArea = InboxLayoutArea.AreaName + "/Actions/Delete"; + private const string UndoArea = InboxLayoutArea.AreaName + "/Actions/Undo"; + private const string GridArea = InboxLayoutArea.AreaName + "/Grid"; + + /// + protected override MessageHubConfiguration ConfigureClient(MessageHubConfiguration configuration) + => base.ConfigureClient(configuration).AddLayoutClient(); + + /// + /// The page end to end, as its owner: the grid lists three notifications, the viewer ticks TWO, + /// Delete puts exactly those away and says so, and Undo brings them back. The writes are issued + /// by the page under the clicker's identity — nothing here runs as System — and for EVERY item: + /// the second write is subscribed after the first completes, off the click turn, which is where + /// an identity captured too late is gone. + /// + [Fact(Timeout = 120000)] + public async Task ThePage_DeletesTheTickedRow_AndUndoBringsItBack() + { + var ct = TestContext.Current.CancellationToken; + const string owner = "inbox-page"; + await CreateUserAsync(owner, ct); + var ns = $"{owner}/{NotificationService.SatelliteSegment}"; + foreach (var id in new[] { "keep", "bin", "bin2" }) + await CreateAsync(new MeshNode(id, ns) + { + NodeType = NotificationNodeType.NodeType, Name = id, MainNode = owner, State = MeshNodeState.Active, + Content = new Notification { Id = id, Title = id, Recipient = owner }, + }, ct); + TestUsers.DevLogin(Mesh, new AccessContext { ObjectId = owner, Name = owner, Email = $"{owner}@meshweaver.io" }); + + var stream = GetClient().GetWorkspace().GetRemoteStream( + new Address(owner), new LayoutAreaReference(InboxLayoutArea.AreaName)); + var grid = (DataGridControl)(await stream.GetControlStream(GridArea).Should().Within(TestTimeouts.Convergence) + .Match(c => c is DataGridControl, "the inbox renders as one grid", ct))!; + grid.SelectionMode.Should().Be(DataGridRowSelection.Multiple, "the inbox is select-many"); + grid.SelectionKey.Should().Be(InboxRows.KeyProperty); + var rows = stream.GetDataStream( + new JsonPointerReference(LayoutAreaReference.GetDataPointer(InboxRows.GridId))); + Task Listed(string because, params string[] ids) => rows.Should().Within(TestTimeouts.Convergence).Match( + r => r.ValueKind == JsonValueKind.Array + && r.EnumerateArray().Select(x => x.GetProperty("path").GetString()).OrderBy(x => x) + .SequenceEqual(ids.Select(id => $"{ns}/{id}").OrderBy(x => x)), + because, ct); + await Listed("every notification is listed", "keep", "bin", "bin2"); + + var pointer = grid.SelectedItems.Should().BeOfType().Subject.Pointer; + // The selection is the client's claim. Beside the real tick it names a node of the owner's + // that NO leg lists (an already put-away notification): a key outside the owner's own feed + // is never a target, whatever the client says it ticked. + await CreateAsync(new MeshNode("away", ns) + { + NodeType = NotificationNodeType.NodeType, Name = "away", MainNode = owner, State = MeshNodeState.Active, + Content = new Notification { Id = "away", Title = "away", Recipient = owner, Dismissed = true }, + }, ct); + stream.UpdatePointer(new JsonArray(JsonValue.Create($"{ns}/bin"), JsonValue.Create($"{ns}/bin2"), JsonValue.Create($"{ns}/away")), + pointer, new JsonPointerReference("keys")); + await stream.GetDataStream(new JsonPointerReference(pointer)) + .Should().Within(TestTimeouts.Convergence).Match(s => s is { Keys.Count: 3 }, "the ticks reached the owner", ct); + + await Click(stream, DeleteArea, "2 moved out of the inbox. Undo brings them back.", ct); + await Listed("only the unticked row left", "keep"); + (await Content($"{ns}/bin", n => n.Dismissed, ct)).Dismissed.Should().BeTrue(); + (await Content($"{ns}/bin2", n => n.Dismissed, ct)).Dismissed.Should().BeTrue("every ticked row is written, not only the first"); + (await Content($"{ns}/keep", _ => true, ct)).Dismissed.Should().BeFalse("an unticked row is never written"); + + await Click(stream, UndoArea, "2 back in the inbox.", ct); + await Listed("Undo brought them back — and only what Delete put away: the unlisted node stays away", "keep", "bin", "bin2"); + (await Content($"{ns}/away", _ => true, ct)).Dismissed.Should().BeTrue(); + } + + /// + /// Everything above the grid is bound to ONE filter in the page's data section — writing a field + /// there (what the search box, the order select and a source switch do) re-feeds the grid, with + /// no click handler and no re-render. The search goes to the QUERY ENGINE with each leg: a term + /// lists only what the mesh finds for it. + /// + [Fact(Timeout = 120000)] + public async Task TheSearch_TheOrder_AndTheSourceSwitches_AreBoundToTheGrid() + { + var ct = TestContext.Current.CancellationToken; + const string owner = "inbox-bound"; + await CreateUserAsync(owner, ct); + var bell = $"{owner}/{NotificationService.SatelliteSegment}"; + var mail = $"{owner}/{EmailNodeType.UserEmailSegment}"; + await CreateAsync(new MeshNode("alpha", bell) + { + NodeType = NotificationNodeType.NodeType, Name = "Alpha invoice", MainNode = owner, State = MeshNodeState.Active, + Content = new Notification { Id = "alpha", Title = "Alpha invoice", Recipient = owner }, + }, ct); + await CreateAsync(Mail("zulu", mail, owner, new Email { Id = "zulu", From = "a.buyer@acme.example", Subject = "Zulu workshop" }), ct); + TestUsers.DevLogin(Mesh, new AccessContext { ObjectId = owner, Name = owner, Email = $"{owner}@meshweaver.io" }); + + var stream = GetClient().GetWorkspace().GetRemoteStream( + new Address(owner), new LayoutAreaReference(InboxLayoutArea.AreaName)); + var rows = stream.GetDataStream( + new JsonPointerReference(LayoutAreaReference.GetDataPointer(InboxRows.GridId))); + Task Titles(string because, params string[] titles) => rows.Should().Within(TestTimeouts.Convergence).Match( + r => r.ValueKind == JsonValueKind.Array + && r.EnumerateArray().Select(x => x.GetProperty("title").GetString()).SequenceEqual(titles), + because, ct); + var filter = LayoutAreaReference.GetDataPointer(InboxRows.FilterId); + void Set(string field, JsonNode value) => stream.UpdatePointer(value, filter, new JsonPointerReference(field)); + + // Both are in "Needs you"; the mail was created last, so it is the newer one. + await Titles("the default order lists the newest first within a band", "Zulu workshop", "Alpha invoice"); + + // The page must settle: its own row writes re-emit the data section, and a page that read + // those as filter changes would rewrite the rows without end (dozens of writes in this window). + (await rows.Buffer(TimeSpan.FromMilliseconds(600)).FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct)) + .Count.Should().BeLessThanOrEqualTo(2, "an idle inbox writes nothing"); + + Set("order", JsonValue.Create(InboxOrder.Title)); + await Titles("the order select re-orders the grid", "Alpha invoice", "Zulu workshop"); + + Set($"sources/{InboxRows.SourceKey("Mail")}", JsonValue.Create(false)); + await Titles("a source switched off leaves the grid", "Alpha invoice"); + await stream.GetDataStream(new JsonPointerReference(LayoutAreaReference.GetDataPointer(InboxLayoutArea.HiddenId))) + .Should().Within(TestTimeouts.Convergence).Match(t => t != null && t.StartsWith("1 "), + "the switch hides a mail that needs the viewer, and the page says so", ct); + Set($"sources/{InboxRows.SourceKey("Mail")}", JsonValue.Create(true)); + await Titles("and returns when switched on", "Alpha invoice", "Zulu workshop"); + await stream.GetDataStream(new JsonPointerReference(LayoutAreaReference.GetDataPointer(InboxLayoutArea.HiddenId))) + .Should().Within(TestTimeouts.Convergence).Match(t => string.IsNullOrEmpty(t), "nothing is hidden any more", ct); + + Set("search", JsonValue.Create("workshop")); + await Titles("a term is evaluated by the query engine, per leg", "Zulu workshop"); + Set("search", JsonValue.Create("")); + await Titles("clearing the box restores the list", "Alpha invoice", "Zulu workshop"); + } + + private static async Task Click(ISynchronizationStream stream, string area, string summary, CancellationToken ct) + { + var answer = new ReplaySubject(1); + stream.SubmitUserAction(new ClickedEvent(area, stream.StreamId), actingUser: null, + onRefused: answer.OnNext, onAccepted: () => answer.OnNext(null)); + (await answer.Should().Within(TestTimeouts.Convergence).Emit("the click is answered", ct)).Should().BeNull(); + await stream.GetDataStream(new JsonPointerReference(ClickProgress.PointerFor(area))) + .Should().Within(TestTimeouts.Convergence).Match(p => p is { Running: false } && p.Summary == summary, + $"the click settles saying '{summary}'", ct); + } + + /// + /// A notification put away leaves its leg and Undo returns it — and the node is never removed. + /// + [Fact(Timeout = 60000)] + public async Task Delete_PutsANotificationAway_AndUndoBringsItBack() + { + var ct = TestContext.Current.CancellationToken; + const string owner = "inbox-delete-bell"; + await CreateUserAsync(owner, ct); + var path = $"{owner}/{NotificationService.SatelliteSegment}/n1"; + await CreateAsync(new MeshNode("n1", $"{owner}/{NotificationService.SatelliteSegment}") + { + NodeType = NotificationNodeType.NodeType, + Name = "Please look at this", + MainNode = owner, + State = MeshNodeState.Active, + Content = new Notification { Id = "n1", Title = "Please look at this", Recipient = owner }, + }, ct); + var leg = Leg(owner, "Notifications"); + var handler = Handler(NotificationNodeType.NodeType); + + (await Ids(leg, ids => ids.Contains("n1"), ct)).Should().Contain("n1", "the control: the leg selects it before Delete"); + + (await Run(() => handler.Dismiss(Mesh, path), ct)).Should().Be(new InboxItemOutcome(path, true)); + (await Ids(leg, ids => !ids.Contains("n1"), ct)).Should().NotContain("n1", "a put-away notification leaves the list"); + (await Content(path, n => n.Dismissed, ct)).Dismissed.Should().BeTrue("Delete is soft: the node stays, flagged"); + + (await Run(() => handler.Restore(Mesh, path), ct)).Done.Should().BeTrue(); + (await Ids(leg, ids => ids.Contains("n1"), ct)).Should().Contain("n1", "Undo brings it back"); + } + + /// + /// A stored inbound mail is archived by Delete and returned as Read by Undo; reading it never + /// removed it (New and Read are both in the leg). + /// + [Fact(Timeout = 60000)] + public async Task Delete_ArchivesAnInboundMail_AndUndoReturnsItAsRead() + { + var ct = TestContext.Current.CancellationToken; + const string owner = "inbox-delete-mail"; + await CreateUserAsync(owner, ct); + var ns = $"{owner}/{EmailNodeType.UserEmailSegment}"; + await CreateAsync(Mail("m1", ns, owner, new Email { Id = "m1", From = "a.buyer@acme.example", Subject = "Workshop follow-up" }), ct); + await CreateAsync(Mail("out1", ns, owner, new Email + { + Id = "out1", Direction = EmailDirection.Outbound, Status = EmailStatus.Sending, Subject = "Queued reply", + }), ct); + var leg = Leg(owner, "Mail"); + var handler = Handler(EmailNodeType.NodeType); + + (await Ids(leg, ids => ids.Contains("m1"), ct)).Should().Equal(["m1"], "inbound mail is listed; a queued outbound one is not an inbox item"); + + (await Run(() => handler.Dismiss(Mesh, $"{ns}/m1"), ct)).Done.Should().BeTrue(); + (await Ids(leg, ids => !ids.Contains("m1"), ct)).Should().BeEmpty("an archived mail leaves the list"); + (await Content($"{ns}/m1", m => m.Status == EmailStatus.Archived, ct)).Status.Should().Be(EmailStatus.Archived); + + (await Run(() => handler.Restore(Mesh, $"{ns}/m1"), ct)).Done.Should().BeTrue(); + (await Ids(leg, ids => ids.Contains("m1"), ct)).Should().Equal("m1"); + (await Content($"{ns}/m1", m => m.Status == EmailStatus.Read, ct)).Status.Should().Be(EmailStatus.Read, "the viewer has seen it by the time they undo"); + + // An outbound mail's status is its SEND state: Delete must not stop a queued mail leaving — + // and a write that changed nothing is NOT a delete: it answers "not done", so the mail + // never enters the Undo batch. + (await Run(() => handler.Dismiss(Mesh, $"{ns}/out1"), ct)).Should().Be(new InboxItemOutcome($"{ns}/out1", false)); + (await Run(() => handler.Restore(Mesh, $"{ns}/out1"), ct)).Done.Should().BeFalse("nothing was put away, so nothing came back"); + (await Content($"{ns}/out1", _ => true, ct)).Status.Should().Be(EmailStatus.Sending); + } + + /// + /// An item someone ELSE already put away is not this click's to count: Dismiss answers "not + /// done" (so it never enters this page's Undo batch, where Undo would reverse the other + /// writer's act), and Restore of an item that was never put away answers the same. + /// + [Fact(Timeout = 60000)] + public async Task OnlyATransitionThisCallMade_CountsAsDone() + { + var ct = TestContext.Current.CancellationToken; + const string owner = "inbox-raced"; + await CreateUserAsync(owner, ct); + var ns = $"{owner}/{NotificationService.SatelliteSegment}"; + foreach (var (id, dismissed) in new[] { ("gone", true), ("here", false) }) + await CreateAsync(new MeshNode(id, ns) + { + NodeType = NotificationNodeType.NodeType, Name = id, MainNode = owner, State = MeshNodeState.Active, + Content = new Notification { Id = id, Title = id, Recipient = owner, Dismissed = dismissed }, + }, ct); + var handler = Handler(NotificationNodeType.NodeType); + + (await Run(() => handler.Dismiss(Mesh, $"{ns}/gone"), ct)).Should().Be(new InboxItemOutcome($"{ns}/gone", false)); + (await Run(() => handler.Restore(Mesh, $"{ns}/here"), ct)).Should().Be(new InboxItemOutcome($"{ns}/here", false)); + // Control: the same calls on items they DO change answer done. + (await Run(() => handler.Dismiss(Mesh, $"{ns}/here"), ct)).Done.Should().BeTrue(); + (await Run(() => handler.Restore(Mesh, $"{ns}/gone"), ct)).Done.Should().BeTrue(); + } + + /// + /// A handler's write carries the identity of the CALL, not of the subscription. The inbox opens + /// the clicker's scope around the handler call only; it subscribes every item after the first + /// later, on the previous write's completion thread, where that scope is gone. A handler that + /// deferred building its write to subscription would issue it as nobody — so a multi-row Delete + /// would put away its first row and refuse the rest. + /// + [Fact(Timeout = 60000)] + public async Task AHandlersWrite_CarriesTheIdentityOfTheCall_NotOfTheSubscription() + { + var ct = TestContext.Current.CancellationToken; + const string owner = "inbox-identity"; + await CreateUserAsync(owner, ct); + var ns = $"{owner}/{NotificationService.SatelliteSegment}"; + await CreateAsync(new MeshNode("n", ns) + { + NodeType = NotificationNodeType.NodeType, Name = "n", MainNode = owner, State = MeshNodeState.Active, + Content = new Notification { Id = "n", Title = "n", Recipient = owner }, + }, ct); + var access = Mesh.ServiceProvider.GetRequiredService(); + var handler = Handler(NotificationNodeType.NodeType); + + IObservable write; + using (access.SwitchAccessContext(new AccessContext { ObjectId = owner, Name = owner, Email = $"{owner}@meshweaver.io" })) + write = handler.Dismiss(Mesh, $"{ns}/n"); + // Subscribed only now, after the scope closed — as Concat subscribes every later item. + (await write.FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct)).Done.Should().BeTrue(); + + (await Mesh.ServiceProvider.GetRequiredService() + .RunAsSystem(() => Mesh.GetMeshNodeStream($"{ns}/n").Where(n => n.ContentAs(Mesh.JsonSerializerOptions) is { Dismissed: true })) + .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct)) + .LastModifiedBy.Should().Be(owner, "the write is the clicker's, issued under the identity of the call"); + } + + /// + /// The page asks each source for a bounded number of rows — the newest, every leg sorts newest + /// first — and a provider that names its own limit keeps it. + /// + /// + /// The search can narrow a leg, never replace its constraints: whatever is typed — an OR, + /// a field filter, a limit:, a stray quote — reaches the parser as free text, and the + /// leg's own filter, path and bound come out exactly as they went in. + /// + [Theory] + [InlineData("OR content.status:Archived")] + [InlineData("limit:all")] + [InlineData("\" OR content.status:Archived \"")] + [InlineData("') namespace:someone-else/_Email (' scope:subtree")] + [InlineData("-content.direction:Outbound sort:Name-asc")] + public void ATypedTerm_IsFreeText_AndCannotReplaceTheLegsConstraints(string term) + { + var leg = InboxQueries.Bounded(Leg("inbox-search", "Mail")); + var parser = new QueryParser(); + var bare = parser.Parse(leg); + var searched = parser.Parse($"{leg} {InboxLayoutArea.SearchText(term)}"); + + static string Shape(object? part) => part is null ? "null" : JsonSerializer.Serialize(part, part.GetType()); + Shape(searched.Filter).Should().Be(Shape(bare.Filter), "the leg's filter is untouched"); + searched.Path.Should().Be(bare.Path); + searched.Scope.Should().Be(bare.Scope); + searched.Limit.Should().Be(InboxQueries.LegLimit); + Shape(searched.OrderBy).Should().Be(Shape(bare.OrderBy)); + searched.TextSearch.Should().NotBeNullOrWhiteSpace("the term is still searched for, as text"); + } + + /// + /// Delete and Undo hold the page one at a time: a second act while one runs is refused with its + /// own line, and the claim is released however the first one ends — so neither can overwrite the + /// Undo batch the other is writing. + /// + [Fact] + public void ASecondAct_WhileOneRuns_IsRefused_AndTheClaimIsReleased() + { + using var acts = new InboxActs(); + var first = new Subject(); + var firstDone = false; + using var running = acts.Exclusive("busy", first).Subscribe(_ => { }, _ => { }, () => firstDone = true); + + Exception? refused = null; + acts.Exclusive("busy", Observable.Return(System.Reactive.Unit.Default)).Subscribe(_ => { }, ex => refused = ex); + refused.Should().BeOfType().Which.Message.Should().Be("busy"); + + first.OnError(new InvalidOperationException("the first act failed")); + firstDone.Should().BeFalse(); + var ran = false; + acts.Exclusive("busy", Observable.Return(System.Reactive.Unit.Default)).Subscribe(_ => ran = true, _ => { }); + ran.Should().BeTrue("a failed act releases the page too"); + } + + [Fact] + public void ALeg_IsBoundedByThePage_UnlessItNamesItsOwnLimit() + { + var parser = new QueryParser(); + parser.Parse(InboxQueries.Bounded("namespace:alice/_Email nodeType:Email sort:LastModified-desc")) + .Limit.Should().Be(InboxQueries.LegLimit); + parser.Parse(InboxQueries.Bounded("namespace:alice/_X limit:5 sort:LastModified-desc")) + .Limit.Should().Be(5, "a smaller bound of the provider's is kept"); + foreach (var unbounded in new[] { "limit:all", "limit:0", "limit:-1", "limit:100000" }) + { + var leg = $"namespace:alice/_X {unbounded} sort:LastModified-desc"; + parser.Parse(InboxQueries.Bounded(leg)).Limit.Should().Be(InboxQueries.LegLimit, + $"'{unbounded}' would read the source unbounded (or past the page's cap) — it is capped"); + InboxQueries.EffectiveLimit(leg).Should().Be(InboxQueries.LegLimit); + } + InboxQueries.EffectiveLimit("namespace:alice/_X limit:5").Should().Be(5, "the notice judges a leg by its own bound"); + + // The projection is the page's, whatever the provider wrote: none, or a wider one. + foreach (var leg in new[] { "namespace:alice/_Thread nodeType:Thread", "namespace:alice/_Thread select:path,content" }) + parser.Parse(InboxQueries.Bounded(leg)).Select.Should().BeEquivalentTo(InboxQueries.RowFields, new JsonSerializerOptions(), + because: $"'{leg}' must not fetch whole bodies to render one row"); + var built = InboxQueries.BuiltIn.Select(l => l.Query.Replace(InboxQueries.ViewerToken, "alice")).First(); + InboxQueries.Bounded(built).Should().Be($"{built} limit:{InboxQueries.LegLimit}", "a leg already projected is not re-projected"); + InboxQueries.BuiltIn.Should().OnlyContain(leg => leg.Query.Contains("sort:"), + "the bound keeps the NEWEST rows only because every leg sorts newest first"); + } + + /// + /// A source switched off must never silently hide an obligation: the "Needs you" rows it hides + /// are counted before the switches apply. Rows of other bands, and of sources still on, are not. + /// + [Fact] + public void TheSwitches_CountTheObligationsTheyHide() + { + InboxRow Row(string path, string source, int rank) => new() { Path = path, SourceId = source, Source = source, Rank = rank }; + var rows = new[] { Row("a", "Mail", 0), Row("b", "Mail", 0), Row("c", "Mail", 2), Row("d", "Notifications", 0) }; + var off = new InboxFilter { Sources = ImmutableDictionary.Empty.Add(InboxRows.SourceKey("Mail"), false) }; + + InboxRows.HiddenObligations(rows, off, needsYouRank: 0).Should().Be(2, "two Mail rows need the viewer; the finished one is not owed"); + InboxRows.HiddenObligations(rows, new InboxFilter(), needsYouRank: 0).Should().Be(0, "nothing is switched off"); + InboxRows.HiddenObligations(rows, off, needsYouRank: null).Should().Be(0, "no leg feeds the obligation band"); + } + + /// + /// The Undo batch is recorded item by item as each write lands, never only when the whole click + /// finishes: a click cancelled after its first item keeps that item undoable, and an Undo + /// cancelled part-way leaves exactly what is still away. + /// + [Fact] + public void TheUndoBatch_IsRecordedPerItem_SoACancelledClickKeepsWhatItDid() + { + using var acts = new InboxActs(); + InboxUndoItem Item(string p) => new(p, NotificationNodeType.NodeType); + + acts.BeginDelete(); + acts.PutAway(Item("a")); + acts.PutAway(Item("b")); + // A first Delete completed; a second one is cancelled after its first item. + acts.BeginDelete(); + acts.PutAway(Item("c")); + acts.Batch.Select(i => i.Path).Should().Equal(["c"], "the second Delete's batch replaced the first — and holds what it did before the cancel"); + + acts.BeginDelete(); + acts.Batch.Select(i => i.Path).Should().Equal(["c"], "a Delete that puts nothing away leaves the previous batch undoable"); + + acts.BeginDelete(); + acts.PutAway(Item("d")); + acts.PutAway(Item("e")); + // An Undo cancelled after bringing back one item. + acts.BroughtBack(Item("d")); + acts.Batch.Select(i => i.Path).Should().Equal(["e"], "only what is still away stays undoable"); + } + + /// + /// Batch changes from many threads at once — a cancelled click's late write landing beside the + /// next click's — never lose one another: every item put away and not brought back is in the + /// batch afterwards. + /// + [Fact] + public void ConcurrentBatchChanges_NeverLoseOneAnother() + { + using var acts = new InboxActs(); + acts.BeginDelete(); + acts.PutAway(new InboxUndoItem("seed", NotificationNodeType.NodeType)); + Parallel.For(0, 2000, i => + { + var item = new InboxUndoItem($"p{i}", NotificationNodeType.NodeType); + acts.PutAway(item); + if (i % 2 == 0) + acts.BroughtBack(item); + }); + acts.Batch.Select(i => i.Path).Order(StringComparer.Ordinal).Should().Equal( + new[] { "seed" }.Concat(Enumerable.Range(0, 2000).Where(i => i % 2 == 1).Select(i => $"p{i}")).Order(StringComparer.Ordinal)); + } + + /// + /// A write that completes after its page was closed is dropped, not thrown: the channel is + /// completed on close, never disposed, and the last batch stays readable. + /// + [Fact] + public void AChangeAfterThePageClosed_IsDropped_NotThrown() + { + var acts = new InboxActs(); + acts.BeginDelete(); + acts.PutAway(new InboxUndoItem("a", NotificationNodeType.NodeType)); + acts.Dispose(); + + var late = () => acts.PutAway(new InboxUndoItem("b", NotificationNodeType.NodeType)); + late.Should().NotThrow(); + acts.Batch.Select(i => i.Path).Should().Equal(["a"]); + } + + /// + /// A write already posted is seen to the end even when the click that started it is cancelled: + /// its outcome still reaches the bookkeeping, so an item the owner commits after the cancel is + /// still undoable. + /// + [Fact] + public void AWritePostedBeforeACancel_IsStillRecorded() + { + var write = new Subject(); + var recorded = new List(); + var click = InboxLayoutArea.Detached(write, recorded.Add).Subscribe(_ => { }); + + click.Dispose(); + write.OnNext(new InboxItemOutcome("a", true)); + + recorded.Should().Equal([new InboxItemOutcome("a", true)]); + } + + /// + /// "By title" is alphabetical in the VIEWER's language: for a German viewer Ä sorts with A, not + /// after Z as it does by code point. + /// + [Fact] + public void ByTitle_IsAlphabeticalInTheViewersLanguage() + { + var mail = new InboxSection(InboxBand.NeedsYou, "Mail", 20, "q"); + ImmutableList rows = [.. new[] { "Zebra", "Ärger", "apfel" }.Select((title, i) => + InboxRows.Of(new MeshNode("n" + i, "alice/_X") { Name = title }, mail, null, null))]; + + InboxRows.Apply(rows, new InboxFilter { Order = InboxOrder.Title }, InboxLayoutArea.ViewerCulture("de-CH")) + .Select(r => r.Title).Should().Equal("apfel", "Ärger", "Zebra"); + // Control: by code point the umlaut lands last, which is what the comparer replaced. + rows.Select(r => r.Title).OrderBy(t => t, StringComparer.OrdinalIgnoreCase).Should().Equal("apfel", "Zebra", "Ärger"); + } + + /// Only an inbound mail that is New or Read may be filed from the inbox. + [Theory] + [InlineData(EmailDirection.Inbound, EmailStatus.New, true)] + [InlineData(EmailDirection.Inbound, EmailStatus.Read, true)] + [InlineData(EmailDirection.Inbound, EmailStatus.Archived, false)] + [InlineData(EmailDirection.Outbound, EmailStatus.Sending, false)] + [InlineData(EmailDirection.Outbound, EmailStatus.Sent, false)] + [InlineData(EmailDirection.Outbound, EmailStatus.Failed, false)] + public void OnlyAnOpenInboundMail_MayBeFiled(EmailDirection direction, EmailStatus status, bool expected) + => EmailInboxItemHandler.CanFile(new Email { Direction = direction, Status = status }).Should().Be(expected); + + /// Core brings exactly its own two handlers; every other type's arrives with its package. + [Fact] + public void CoreRegisters_TheHandlersOfItsOwnTwoTypes() + => Mesh.ServiceProvider.GetServices().Select(h => h.NodeType) + .Should().Contain([NotificationNodeType.NodeType, EmailNodeType.NodeType]); + + /// A leg a package registered as a service is run beside core's own. + [Fact] + public void ALegRegisteredAsAService_JoinsTheBuiltInOnes() + { + var brought = new InboxQueryLeg { Source = "Threads", Band = InboxBand.NeedsYou, Order = 30, Query = "namespace:{viewer}/_Thread nodeType:Thread" }; + using var services = new ServiceCollection().AddSingleton(brought).BuildServiceProvider(); + + var all = InboxQueries.All(services); + + all.Should().Contain(InboxQueries.BuiltIn).And.Contain(brought); + InboxLayoutArea.Sections(all, "alice").Should().Contain(s => s.Source == "Threads" && s.Query == "namespace:alice/_Thread nodeType:Thread"); + } + + /// + /// A running item is not the viewer's to put away, and a type nobody brought a handler for is + /// listed but never written — each says why in its disabled checkbox. + /// + [Fact] + public void ARow_IsSelectable_OnlyOutsideRunning_AndOnlyWithAHandler() + { + var handler = new NotificationInboxItemHandler(); + + InboxLayoutArea.BlockedReason(InboxBand.NeedsYou, handler, "running", "none").Should().BeNull(); + InboxLayoutArea.BlockedReason(InboxBand.Recent, handler, "running", "none").Should().BeNull(); + InboxLayoutArea.BlockedReason(InboxBand.Running, handler, "running", "none").Should().Be("running"); + InboxLayoutArea.BlockedReason(InboxBand.NeedsYou, null, "running", "none").Should().Be("none"); + InboxItemHandlers.For([handler], "notification").Should().BeNull("a node type is an identifier: ordinal, never case-folded"); + } + + /// A bulk act touches only rows that are still there and still selectable. + [Fact] + public void ABulkAct_ReChecksTheSelection_AgainstTheRowsAsTheyStand() + { + ImmutableList rows = + [ + new() { Path = "alice/_Notification/a", NodeType = "Notification" }, + new() { Path = "alice/_Activity/b", NodeType = "Activity", BlockedReason = "running" }, + ]; + + InboxRows.Targets(rows, ["alice/_Notification/a", "alice/_Activity/b", "alice/_Notification/gone"], "alice") + .Select(r => r.Path).Should().Equal("alice/_Notification/a"); + } + + /// + /// A search may reach beyond the owner's partition (it runs with the viewer's rights); a bulk + /// act never does — a ticked row elsewhere is not a target, and a partition that merely + /// STARTS with the owner's name is elsewhere. + /// + [Fact] + public void ABulkAct_NeverLeavesTheOwnersPartition() + { + ImmutableList rows = + [ + new() { Path = "alice/_Notification/a", NodeType = "Notification" }, + new() { Path = "alicia/_Notification/b", NodeType = "Notification" }, + new() { Path = "bob/_Notification/c", NodeType = "Notification" }, + ]; + + InboxRows.Targets(rows, rows.Select(r => r.Path), "alice").Select(r => r.Path).Should().Equal("alice/_Notification/a"); + } + + /// Legs merge into one list: each path once, under the first leg that brought it. + [Fact] + public void LegsMergeIntoOneList_EachPathOnce_UnderTheFirstLegThatBroughtIt() + { + var mail = new InboxSection(InboxBand.NeedsYou, "Mail", 20, "q1"); + var bell = new InboxSection(InboxBand.NeedsYou, "Notifications", 10, "q2"); + + var merged = InboxRows.Merge( + [ + [InboxRows.Of(Node("old", 1), bell, null, null), InboxRows.Of(Node("both", 2), bell, null, null)], + [InboxRows.Of(Node("both", 2), mail, null, null), InboxRows.Of(Node("new", 3), mail, null, null)], + ]); + + merged.Select(r => r.Title).OrderBy(t => t, StringComparer.Ordinal).Should().Equal("both", "new", "old"); + merged.Single(r => r.Title == "both").Source.Should().Be("Notifications"); + } + + /// + /// Every ordering the select offers — and an order the page does not know falls back to + /// importance, so a stale stored value can never bury what needs the viewer. + /// + [Theory] + [InlineData(InboxOrder.Importance, "urgentNew,urgentOld,doneNewest")] + [InlineData(InboxOrder.Newest, "doneNewest,urgentNew,urgentOld")] + [InlineData(InboxOrder.Oldest, "urgentOld,urgentNew,doneNewest")] + [InlineData(InboxOrder.Title, "doneNewest,urgentNew,urgentOld")] + [InlineData(InboxOrder.Source, "doneNewest,urgentNew,urgentOld")] + [InlineData("SomethingElse", "urgentNew,urgentOld,doneNewest")] + public void TheChosenOrder_ArrangesTheRows(string order, string expected) + { + var needs = new InboxSection(InboxBand.NeedsYou, "Mail", 20, "q1"); + var recent = new InboxSection(InboxBand.Recent, "Activities", 20, "q2"); + ImmutableList rows = + [ + InboxRows.Of(Node("urgentOld", 1), needs, null, null, "Needs you", 0), + InboxRows.Of(Node("doneNewest", 9), recent, null, null, "Recent", 2), + InboxRows.Of(Node("urgentNew", 5), needs, null, null, "Needs you", 0), + ]; + + InboxRows.Apply(rows, new InboxFilter { Order = order }).Select(r => r.Title) + .Should().Equal(expected.Split(',')); + } + + /// + /// A source switched off is not listed; a source with no entry IS — a leg a package brings + /// later must not arrive switched off. + /// + [Fact] + public void ASourceSwitchedOff_IsNotListed_AndAnUnknownSourceIs() + { + ImmutableList rows = + [ + InboxRows.Of(Node("mail", 1), new InboxSection(InboxBand.NeedsYou, "Mail", 20, "q1"), null, null), + InboxRows.Of(Node("wa", 2), new InboxSection(InboxBand.NeedsYou, "WhatsApp", 30, "q2"), null, null), + ]; + var filter = new InboxFilter { Sources = ImmutableDictionary.Empty.Add(InboxRows.SourceKey("Mail"), false) }; + + InboxRows.Apply(rows, filter).Select(r => r.Title).Should().Equal("wa"); + } + + /// + /// The page tells a change the viewer made from a re-emission of the same state BY VALUE: two + /// separately built filters with the same content are the same, and every field moves it. + /// Without this the grid's own row writes read as filter changes and the page feeds on itself. + /// + [Fact] + public void AFiltersFingerprint_IsItsValue_NotItsInstance() + { + InboxFilter Build() => new() + { + Search = "acme", Order = InboxOrder.Title, + Sources = ImmutableDictionary.Empty.Add("mail", true).Add("chat", false), + }; + var one = InboxRows.Fingerprint(Build()); + + InboxRows.Fingerprint(Build()).Should().Be(one); + Build().Should().NotBe(Build(), "the control: the record's own equality compares the dictionary by reference"); + InboxRows.Fingerprint(Build() with { Search = "globex" }).Should().NotBe(one); + InboxRows.Fingerprint(Build() with { Order = InboxOrder.Newest }).Should().NotBe(one); + InboxRows.Fingerprint(Build() with { Sources = Build().Sources.SetItem("chat", true) }).Should().NotBe(one); + } + + /// + /// A live read answers its first set whole and every later change as a delta; folded, it is + /// "what the leg selects now" — a removed row leaves, an updated one is replaced, not doubled. + /// + [Fact] + public void ALiveReadsDeltas_FoldIntoWhatTheLegSelectsNow() + { + static QueryResultChange Change(QueryChangeType type, params MeshNode[] items) + => new() { ChangeType = type, Items = items }; + var set = ImmutableDictionary.Empty; + + set = InboxLayoutArea.Fold(set, Change(QueryChangeType.Initial, Node("a", 1), Node("b", 1))); + set = InboxLayoutArea.Fold(set, Change(QueryChangeType.Added, Node("c", 2))); + set = InboxLayoutArea.Fold(set, Change(QueryChangeType.Updated, Node("a", 3))); + set = InboxLayoutArea.Fold(set, Change(QueryChangeType.Removed, Node("b", 1))); + + set.Keys.OrderBy(k => k).Should().Equal("alice/_X/a", "alice/_X/c"); + set["alice/_X/a"].LastModified.Minute.Should().Be(3, "an update replaces the row"); + InboxLayoutArea.Fold(set, Change(QueryChangeType.Initial, Node("z", 1))).Keys.Should().Equal("alice/_X/z"); + } + + private static MeshNode Node(string id, int minute) => new(id, "alice/_X") + { + Name = id, NodeType = "X", LastModified = new DateTimeOffset(2026, 10, 11, 9, minute, 0, TimeSpan.Zero), + }; + + /// + /// A stored instant is shown in the VIEWER's zone and the viewer's date format — across the + /// date line, and on both sides of a daylight-saving change — while the row ORDER stays on the + /// UTC instant. + /// + [Theory] + [InlineData("2026-01-15T23:30:00Z", "Europe/Zurich", "de-CH", "16.01.2026 00:30")] + [InlineData("2026-07-15T23:30:00Z", "Europe/Zurich", "de-CH", "16.07.2026 01:30")] + [InlineData("2026-07-15T02:30:00Z", "America/New_York", "de-CH", "14.07.2026 22:30")] + [InlineData("2026-07-15T02:30:00Z", null, null, "07/15/2026 02:30")] + public void ARowsTime_IsTheViewers_NotTheContainers(string utc, string? zone, string? locale, string expected) + { + var node = new MeshNode("n", "alice/_X") { LastModified = DateTimeOffset.Parse(utc, System.Globalization.CultureInfo.InvariantCulture) }; + + var row = InboxRows.Of(node, new InboxSection(InboxBand.NeedsYou, "Mail", 20, "q"), zone, null, + culture: InboxLayoutArea.ViewerCulture(locale)); + + row.When.Should().Be(expected); + row.Stamp.Should().StartWith(utc[..19], "the sort key is the UTC instant, whatever the viewer's zone or language"); + } + + /// A language tag the runtime does not know must never fault a render. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not a language tag !!")] + public void AnUnreadableLocale_FallsBackToInvariant(string? locale) + => InboxLayoutArea.ViewerCulture(locale).Should().Be(System.Globalization.CultureInfo.InvariantCulture); + + /// + /// A source's switch key is unique per source id BY CONSTRUCTION — not merely unlikely to + /// collide — and carries nothing a pointer segment cannot. The second pair is one a 32-bit + /// hash of the id maps to the same value. + /// + [Theory] + [InlineData("Ops / Approvals", "Ops Approvals")] + [InlineData("OpsApprov_al sL_o-n/g", "OpsAppro va/l-sL-o-ng")] + [InlineData("Mail", "mail")] + public void ASourcesSwitchKey_IsUniquePerSource_AndPointerSafe(string one, string other) + { + InboxRows.SourceKey(one).Should().NotBe(InboxRows.SourceKey(other)); + InboxRows.SourceKey(one).Should().MatchRegex("^[a-z0-9]+$", "no slash, space or capital"); + InboxRows.SourceKey(one).Should().Be(InboxRows.SourceKey(one), "stable: it is stored in the page's data"); + } + + /// + /// The row shows the source's DISPLAY name and keeps its id apart: the switch and the by-source + /// order key on the id, so a translated label can never detach a row from its switch. Every leg + /// core brings has a catalog key. + /// + [Fact] + public void ASourcesLabel_IsShown_AndItsIdKeysTheSwitch() + { + var mail = new InboxSection(InboxBand.NeedsYou, "Mail", 20, "q"); + var row = InboxRows.Of(Node("m", 1), mail, null, null, sourceLabel: "E-Mail"); + + row.Source.Should().Be("E-Mail"); + row.SourceId.Should().Be("Mail"); + InboxRows.Apply([row], new InboxFilter { Sources = ImmutableDictionary.Empty.Add(InboxRows.SourceKey("Mail"), false) }) + .Should().BeEmpty("the switch is keyed on the id, not on what the cell shows"); + InboxQueries.BuiltIn.Should().OnlyContain(leg => !string.IsNullOrEmpty(leg.SourceLabelKey)); + InboxLayoutArea.Sections(InboxQueries.BuiltIn, "alice").Should().OnlyContain(s => !string.IsNullOrEmpty(s.SourceLabelKey)); + // A provider band carries its own catalog key through to the page. + InboxLayoutArea.Sections([new InboxQueryLeg + { + Source = "Ops", Band = "Approvals", BandLabelKey = "ops.band.approvals", + Query = $"namespace:{InboxQueries.ViewerToken}/_Approval nodeType:Approval", + }], "alice") + .Should().ContainSingle().Which.BandLabelKey.Should().Be("ops.band.approvals"); + } + + /// + /// A write that faults answers for its item with NO reason: an outcome's reason is shown to the + /// viewer verbatim, and a typed-write failure can quote the content it could not read. The cause + /// is logged; the page says only that the item could not be changed. + /// + [Fact(Timeout = 60000)] + public async Task AFaultedWrite_AnswersForItsItem_WithoutQuotingItsCause() + { + var ct = TestContext.Current.CancellationToken; + const string owner = "inbox-fault"; + await CreateUserAsync(owner, ct); + var ns = $"{owner}/{EmailNodeType.UserEmailSegment}"; + // A node the notification handler cannot read AS a notification: content of another shape. + await CreateAsync(new MeshNode("odd", ns) + { + NodeType = EmailNodeType.NodeType, Name = "odd", MainNode = owner, State = MeshNodeState.Active, + Content = "this is not a mail record", + }, ct); + + var outcome = await Run(() => Handler(EmailNodeType.NodeType).Dismiss(Mesh, $"{ns}/odd"), ct); + + outcome.Should().Be(new InboxItemOutcome($"{ns}/odd", false), "no reason: the cause is logged, never shown"); + } + + private IInboxItemHandler Handler(string nodeType) + => InboxItemHandlers.For(Mesh.ServiceProvider.GetServices(), nodeType) + ?? throw new InvalidOperationException($"no inbox handler registered for {nodeType}"); + + private static string Leg(string owner, string source) + => InboxQueries.Resolve(InboxQueries.BuiltIn, owner).Single(l => l.Source == source && l.Band == InboxBand.NeedsYou).Query; + + private static MeshNode Mail(string id, string ns, string owner, Email mail) => new(id, ns) + { + NodeType = EmailNodeType.NodeType, + Name = mail.Subject, + MainNode = owner, + State = MeshNodeState.Active, + Content = mail, + }; + + /// + /// The ids the leg selects, once they satisfy . Read through the SNAPSHOT + /// stream the page itself binds to: a raw live query answers a later change as a delta (a + /// put-away row arrives as Removed [row]), which is not "what the leg selects now" — and a + /// read that raced the write would then wait forever on an initial set that still held the row. + /// + private async Task Ids(string query, Func until, CancellationToken ct) + => await Mesh.GetQuery($"test:{Guid.NewGuid():N}", query) + .Select(nodes => nodes.Select(n => n.Id).ToArray()) + .Where(until) + .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); + + /// + /// The node's content once it satisfies . The first emission of a node + /// read can still be the value from before a write that has already been answered, so content + /// asserted right after a write is WAITED for, never sampled. + /// + private async Task Content(string path, Func until, CancellationToken ct) where T : class + => await Mesh.ServiceProvider.GetService() + .RunAsSystem(() => Mesh.GetMeshNodeStream(path) + .Select(node => node.ContentAs(Mesh.JsonSerializerOptions)) + .OfType() + .Where(until)) + .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); + + private async Task Run(Func> act, CancellationToken ct) + => await Mesh.ServiceProvider.GetService().RunAsSystem(act) + .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); + + private async Task CreateAsync(MeshNode node, CancellationToken ct) + => await Mesh.ServiceProvider.GetService() + .RunAsSystem(() => Mesh.ServiceProvider.GetRequiredService().CreateNode(node)) + .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); + + /// Creates the user's partition root as onboarding does — as System. + private Task CreateUserAsync(string path, CancellationToken ct) + => CreateAsync(MeshNode.FromPath(path) with + { + NodeType = UserNodeType.NodeType, + Name = path, + State = MeshNodeState.Active, + Content = new User { FullName = path }, + }, ct); +}