Summary#
use Osysharp.Notifications@1; gives an app a bell. Tell people with one call from anywhere — WHAT HAPPENED, as the
event the app (or a kit) already raises for it, which says itself:
[Event("Ready to pack")] public sealed class ReadyToPack { public Order Order; } // says its label, or overrides Sentence()
Notify(packer, new ReadyToPack { Order = order }); // from a page, a function, a workflow body or an [On] handler
NotificationBell(); // in the shell's actionsNothing is registered and no words are written for the kit: the event is kept, and said when it is read.
The kit asks nothing else about the record: what it is called, where it opens and who may read it are the record's own
facts — its [[entity-title|[Title]]], the page that [[stdlib-records#opens|[Opens]]] it, its security { } — which
Records answers for any record the app keeps.
Signature#
use Osysharp.Notifications@1; // with Osysharp.Identity@0, Osysharp.Mail@0, Osysharp.EmailTemplates@0,
// Osysharp.Workflow and Osysharp.Ui
policy ReceivesNotifications => <who has a bell>; // REQUIRED — and who has notification preferences
app.Notifications = new NotificationsSetup { // optional — every member has a default
AppName = "", // what a mail is signed with
KeepRead = TimeSpan.FromDays(30), // a read notification is deleted after this
KeepUnread = TimeSpan.FromDays(180), // …an unread one after this
MentionMailAfter = TimeSpan.FromMinutes(5), // a mention waits this long, unread, before it is mailed
DigestHour = 6, // the daily digest goes at the first hourly sweep from this hour, on the
// app's clock (Osysharp.TimeZone — the time zone staff pick)
People = new NotificationPeople(), // override LanguageOf(person) when the account keeps a language
};
// anywhere — `e` is any [Event]; the record it is about is its first record-typed member:
void Notify(Principal to, Event e, Principal? by = null, string key = "", bool mention = false)
void NotifyAll(Principal[] people, Event e, Principal? by = null)
void Mention(Principal to, Entity about, Principal? by = null) // the kit's own words for it
string SaidOf(Notification n) // what it says, to whoever reads it now
NotificationBell(string settingsAt = "/settings/notifications") // the bell and its list, for the shell's actions
NotificationList(string settingsAt, Action onDone) // the list alone
int UnreadNotifications() · List<NotificationLine> MyNotificationLines(int take = 40)
string OpenNotification(Guid id) · MarkNotificationRead(Guid id, bool read = true) · int MarkAllNotificationsRead()Description#
Who is told what#
Notify writes a request to tell somebody, in the caller's unit of work. It is delivered after the commit by a
workflow (a throw is tried again), so nobody hears about something that was rolled back. Nobody is told what they did
themselves (by is them). A person's own request that tells somebody — a note that names a colleague — needs the app's
grant, because the app decides who may tell whom: partial entity Notification { security { allow create when IsAgent; } }.
The app's workflows and timers need none. Whatever the grant, a new notification starts unseen, and nobody signed in
can put another person's name in its by.
Never about a record they may not read#
A notification's About is an Entity — a reference to a row of any type, read as its reader. Delivery
asks Records.MayRead(recipient, about) — after the commit, of the record as stored, under the
recipient's OWN rules: a record in a restricted queue, a private document, a row they lost access to since. One that
fails is withheld and never shown or mailed. The bell reads each notification's record as the person reading it, and
the digest asks Records.Readable once per person before each mail, so a reader who loses access
later finds it gone from the bell — and from the mail. SECURITY-INVARIANT
notifications-only-about-what-the-recipient-may-read.
A notification goes with its record: About is [OnDelete(Remove)] ([OnDelete]).
One unread notification per thing#
A notification's key (by default its record and its kind; a mention's is its record) is what makes two the same.
While one is unread in the bell, a second with its key joins it — it moves to the top, says what happened last and how
many times ("2 times") — rather than adding a row. Once read, the next one is new. The database holds that: one unread
per person and key.
The event, said in the reader's words#
A notification keeps the EVENT — its class (Kind, the full name) and the event itself (Payload) — and who did it
(By). When it is read, the event is read back and says its own [[function-event#sentence|Sentence()]] — in the
bell, in each reader's language, and in a mail, in its recipient's, from the same row (A history in the reader's language — keep what happened, say it when it is read). A sentence
that reads the record (a customer's name) reads it as it is NOW: a renamed record reads with its new name, and an erased
person's name is in no notification. An event whose class is gone since says its label. The line's title is the
record's [[entity-title|[Title]]], and the row opens at its [[stdlib-records#opens|Records.Link]].
A notice the app tells somebody but nobody else should be offered — not a rule's trigger, not a webhook's — is an
internal event: it says itself the same way and stays the app's own business.
What the bell shows, and how it opens#
NotificationBell() is the shell's count button ("Notifications, 2 new" — the count is in its name) with the list
behind it: a panel under the bell on a wide screen, a sheet from the bottom edge on a phone. The list shows the newest
forty, unread ones marked; All / Unread; "Mark all read"; a row opens where it happened and marks it read. It says what
will appear there when there is nothing yet, and that all is caught up when nothing is unread. The count and each row's
read state are live; the words are fetched once as the list opens, and a row that arrives meanwhile fetches its own.
What is mailed, and in which language#
Each person chooses on their own settings page (/settings/notifications, a personal declared section —
[Settings] and [Setting]: a settings section declared on its row, each field drawn by its type): mentions at once, everything else in a daily digest (the default), a daily digest, or
nothing. Mail is only ever about what they have not read in the app: a mention waits MentionMailAfter first, and
is not mailed if they read it meanwhile; the digest goes once a day, of what is unread and not yet mailed — at DigestHour in the team's time zone, the one
staff pick in Settings › Time and date (The TimeZone kit — the time zone an app's days start in, picked from a list), so it arrives in the team's morning in summer and in winter. It is written
in the language they last read the app in (the bell records it), or the one the app's People.LanguageOf answers.
Kept no longer than needed#
An hourly sweep deletes a read notification after KeepRead, an unread one after KeepUnread, and one never shown
after a day. A notification is personal data (its event, Payload, is classified Personal.Content): with Osysharp.Privacy,
Osysharp.Privacy.Notifications arrives by itself (Bridges — a package that arrives when an app uses the two it completes) and a person's notifications and preferences are counted, copied, kept
restricted and erased by their requests — the app's privacy managers read them for that
(partial entity Notification { security { allow read when ManagesPrivacy; } }).
Examples#
A small studio telling writers about drafts#
app Studio {
use Osysharp.Identity@0;
use Osysharp.Notifications@1;
use Osysharp.Mail@0;
use Osysharp.EmailTemplates@0;
use Osysharp.Workflow;
use Osysharp.Ui;
model "model/**/*.osy";
}
using Osysharp.Identity;
using Osysharp.Notifications;
[Role] enum StudioRole { Authenticator, Writer, Editor }
policy IsWriter => RoleGrant.Any(g => g.Member == user && (g.Role == StudioRole.Writer || g.Role == StudioRole.Editor));
policy IsEditor => RoleGrant.Any(g => g.Member == user && g.Role == StudioRole.Editor);
policy ReceivesNotifications => IsWriter;
policy ManagesClock => IsEditor; // who sets the time zone the digest's morning is in
/// What a notification about a draft is called — its line in the bell and in a mail.
[Title(Title)]
entity Draft {
[Required, MaxLength(120)] string Title;
[Required] Principal Writer;
bool Private;
security {
allow read, create, update when IsWriter where !Private || Writer == user;
allow read, create, update when IsWriter && IsEditor;
}
}
// Writers tell each other from their own requests.
partial entity Notification { security { allow create when IsWriter; } }
/// Somebody asked for a read — said in the reader's words, when the notification is read.
[Event("Read asked")]
public sealed class ReadAsked {
public Draft Draft;
public Principal? By;
public override string Sentence() {
var by = By == null ? "Somebody" : By.DisplayName;
return $"{by} asked you to read a draft.";
}
}
app.Notifications = new NotificationsSetup { AppName = "Studio" };
/// "Ask for a read" — tells the other writer, after this commit, if they may read the draft.
public void AskForARead(Draft d, Principal to) {
Notify(to, new ReadAsked { Draft = d, By = Session.CurrentUser }, by: Session.CurrentUser);
UnitOfWork.Commit();
}
/// A comment that names a writer — a mention, in the kit's own words.
public void NameIn(Draft d, Principal to) {
Mention(to, d, by: Session.CurrentUser);
UnitOfWork.Commit();
}
/// Where a notification about a draft opens.
[Route("/drafts/{id}")] [Opens(Draft)] [Authorize(IsWriter)] [Render(CSR)]
component DraftPage(Guid id) {
render { Text("draft"); }
}
[Route("/")] [Authorize(IsWriter)] [Render(CSR)]
component Home() {
render {
Row(gap: 3, align: Align.Center, px: 5, py: 3) {
Text("Studio", fontWeight: FontWeight.Semibold, grow: 1);
NotificationBell();
}
}
}See also#
- [Event] — what happens to a record, declared once — what a notification is about, and how an event says itself
- Records — what any record is called, where it opens, who may read it — what a record is called, where it opens, and who may read it, for any record
- Entity —
Entity, the reference a notification'sAboutis - [OnDelete] — why a notification goes with its record
- Security.MayRead — may THIS person read this row, under their own rules —
Security.MayRead, the rulesRecords.MayReadevaluates - [Settings] and [Setting]: a settings section declared on its row, each field drawn by its type — the personal preferences page (
Owner = Person) - The ReplyComposer kit — where a person writes a reply, on any record — a note that names a teammate (
@Ines Moreau), and who is told - Inbox.Notifications — the people the inbox names, told in their bell — the people the inbox names on its events, told in their bell with nothing written
- [Email] — mail rendered from data — the digest and the mention mail are
[Email]components, in the recipient's language - A history in the reader's language — keep what happened, say it when it is read — what happened kept, said when read
kits/notifications/tests/— every behaviour above as a test, the screens under--pixels