Summary#
use Osysharp.ReplyComposer@0; gives an app the place its people write replies. Point a Composer at a record by its id,
and tell the kit once what sending means for that kind of record:
app.Composer = new ComposerSetup { Targets = [new EnquiryReplies()] };
public class EnquiryReplies : ComposerTarget {
public override string Send(ComposerSend s) { /* write the reply your way */ return ""; }
}
Composer(enquiry.Id, audience: "To " + enquiry.CustomerName);Everything else — saved replies, macros, formatting, drafts, the signature, files — is the kit's, and the app adds to it by overriding a method of the same class.
Signature#
use Osysharp.ReplyComposer@0; // with Osysharp.Identity@0, Osysharp.Storage and Osysharp.Ui
policy ComposesReplies => <who writes here>; // REQUIRED — reads the team's replies and macros, keeps drafts
policy ManagesSavedReplies => <who keeps the team's>; // REQUIRED — everyone else keeps only their own
app.Composer = new ComposerSetup { Targets = [new MyReplies()], MaxFileBytes = 10485760, MaxLength = 20000 };
public class MyReplies : ComposerTarget {
public override string Send(ComposerSend s); // REQUIRED — s.Item, s.Text (markdown, signed), s.Note,
// s.Files, s.After, s.Seen, s.Mentions
public override List<ComposerAfter> After(Guid item); // "Send and set …" choices
public override List<ReplyPlaceholder> Placeholders(); // {{key}}s a saved reply may use
public override string Record { get; } // the record a macro's steps act on: typeof(X).FullName
public override bool Offers(ActionInfo action); // leave an [Action] out of macros (default: all)
public override string Signature(); // the writer's, under every reply (never a note)
public override ComposerFile Keep(UploadedFile upload); // check a file now (default: private, size-checked)
public override void Writing(Guid item, string doing); // "replying" / "writing a note" / "viewing"
public override List<MentionChoice> Mentionable(Guid item); // who a NOTE may name with @ (and who may not, and why)
}
public class MentionChoice { string Key; string Name; string Hint; string Refusal; } // Refusal "": may be named
Composer(Guid item, string kind = "", string audience = "", bool notes = true, long seen = -1,
ComposerKeys? keymap = null, string manageAt = "", string replyLabel = "Reply", …,
ComposerOpening? opened = null) // what OpenComposer(kind, item) answered, when the page asked already
SavedReplySettings(string kind = "") MacroSettings(string kind = "") // the settings pages' content
Guid PrepareDraft(string kind, Guid item, string text, string by, bool note = false) // the seam for an assistantDescription#
Placeholders are typed, not a template the writer can break#
A placeholder is {{customer.first_name}} in a saved reply only because the app DECLARED that key, with a class that
fills it from the record — Fill(Guid item) runs on the server, as the writer, through the app's rules. Three things
follow, each a sentence the writer reads rather than a reply that goes out wrong:
- a reply naming a key nobody declared —
{{customer.frist_name}}— is refused when it is saved, naming every key there is (PlaceholderProblem); so is a{{that is never closed; - a value the record does not have takes the placeholder's
Fallback("Hi there"); with no fallback the placeholder stays in the text, marked, and the send is refused until the writer replaces it — "Hi ," never reaches anyone; - a
{{…}}that is not one of the app's keys is the writer's own business — a reply may be about a template.
Macros stage the app's own actions, and the send runs them#
A macro is a reply (optional) and steps, each one of the app's [Action] functions on the record (Record) — set a
status, add a tag, take it, promise a visit on a day. An action with one choice beside the record is picked from a
menu; one that takes anything else — a date, a number, a list, several values — is set up in a short form, each value
in the input its kind takes, and read as the send will read it before the step is kept ([[function-action#check]]). Applying one puts its reply in the box and lists its steps ("When you send: Set status:
Pending"); the writer reads, edits or takes a step off, and the steps run AFTER the send, in order. A macro with no
reply runs when the writer presses Apply. A step keeps the label it was chosen with, so one whose choice has since gone
(a deleted status) is SAID when the macro is applied, never skipped; one refused after the reply went out is reported
("Sent — but …") and does not unsend it.
Formatting acts on the selection#
The text is markdown. The bar — bold (⌘B), italic (⌘I), code, link, lists, quote — acts on what the writer selected,
through the text box's selectionStart:/selectionEnd: binding, and a second press undoes the first. Preview shows it
as the reader will. Each edit is a pure function (Wrap, Linked, Lines, Insert) — it runs in the browser.
A draft is the writer's own, and survives a reload#
Saved every two seconds while it changes, and when the writer leaves the box — per record and per author, with its
files and its staged steps. Nobody else reads it (SECURITY-INVARIANT composer-draft-is-its-authors). "Draft saved
14:02" is read live from the server, never what the page hopes it sent. Discard can be undone until the writer types.
A draft prepared by someone else — an assistant, a colleague handing over — is written with PrepareDraft. It waits
on the record with no author, is offered in the composer ("Prepared by …", live), and is never sent by itself.
A note names a teammate with @#
Typing @ in a note offers the people the app's Mentionable(item) lists, narrowed as the writer types; picking one
writes @Ines Moreau. A name written out by hand is a mention too: when the note is sent the kit finds every @Name in
it (a whole name, never inside an address) and hands the app their keys in the order written, in
ComposerSend.Mentions — the app seats them, follows the record for them, tells them (The Notifications kit — telling a person that something needs them).
Somebody who may not be named on this record carries a Refusal: the menu shows them greyed with that sentence, and a
note naming them anyway is refused with it, the draft kept. A reply names nobody — it goes to the person on the other
side.
Where do its settings live?#
ComposerSettingsSections announces "Saved replies" and "Macros" to the app's settings area, and the kit serves both
pages (ComposerRepliesSettings, ComposerMacroSettings) in its SettingsFrame. Everyone who composes keeps their own
there; ManagesSavedReplies keeps the team's.
Examples#
A shop answering enquiries#
app Enquiries {
use Osysharp.Identity@0;
use Osysharp.ReplyComposer@0;
use Osysharp.Storage;
use Osysharp.Ui;
model "model/**/*.osy";
}
using Osysharp.Identity;
using Osysharp.ReplyComposer;
[Role] enum ShopRole { Authenticator, Staff, Owner }
policy IsStaff => RoleGrant.Any(g => g.Member == user && (g.Role == ShopRole.Staff || g.Role == ShopRole.Owner));
policy ComposesReplies => IsStaff;
policy ManagesSavedReplies => RoleGrant.Any(g => g.Member == user && g.Role == ShopRole.Owner);
enum EnquiryState { Open, Answered }
entity Enquiry {
[Required, MaxLength(120)] string Subject;
[MaxLength(80)] string CustomerName = "";
EnquiryState State = EnquiryState.Open;
security { allow read, create, update when IsStaff; }
}
entity EnquiryReply {
[Required] Enquiry Enquiry;
[MaxLength(50000)] string Text = "";
bool Note;
security { allow read, create when IsStaff; }
}
app.Composer = new ComposerSetup { Targets = [new EnquiryReplies()] };
public class EnquiryReplies : ComposerTarget {
public override string Send(ComposerSend s) {
var e = Enquiry.Single(x => x.Id == s.Item);
new EnquiryReply { Enquiry = e, Text = s.Text, Note = s.Note };
if (s.After == "answered") { e.State = EnquiryState.Answered; }
UnitOfWork.Commit();
return "";
}
public override List<ComposerAfter> After(Guid item) {
return [new ComposerAfter { Key = "answered", Label = "Answered" }];
}
public override List<ReplyPlaceholder> Placeholders() {
return [new CustomerName { Key = "customer.name", Label = "Customer's name", Fallback = "there" }];
}
// A macro's steps are the shop's own [Action]s on an enquiry.
public override string Record { get { return typeof(Enquiry).FullName; } }
// A note may name any active colleague — `@Sam Berg` reaches s.Mentions as their account id.
public override List<MentionChoice> Mentionable(Guid item) {
return Principal.Where(p => p.IsActive).ToList()
.Select(p => new MentionChoice { Key = p.Id.ToString(), Name = p.DisplayName }).ToList();
}
}
public class CustomerName : ReplyPlaceholder {
public override string Fill(Guid item) { return Enquiry.Single(e => e.Id == item).CustomerName; }
}
/// Offered to macros (and to anything else that runs the app's actions).
[Action("Mark answered")]
public string MarkAnswered(Enquiry e) {
e.State = EnquiryState.Answered;
UnitOfWork.Commit();
return "Marked answered.";
}
[Route("/enquiry/{id}")] [Authorize(IsStaff)] [Render(CSR)]
component EnquiryPage(Guid id) {
live var enquiries = Enquiry.Where(e => e.Id == id).ToList();
render {
foreach (var e in enquiries) {
Stack(gap: 3, p: 5) {
Text(e.Subject, fontWeight: FontWeight.Semibold);
Composer(e.Id, audience: "To " + e.CustomerName, manageAt: "/settings/replies",
keymap: new ComposerKeys { Reply = "R", Note = "N" });
}
}
}
}See also#
- The Conversations kit — one timeline per conversation, across every channel — a timeline whose desk replies are written here, shown formatted (
TextFormat.Markdown) - The WorkQueues kit's screens — the rail, the list, an item's head, the team, the settings — the desk the composer usually sits on
- The Notifications kit — telling a person that something needs them — telling the people a note names
- SettingsArea — an app's settings: sections, explicit save and discard, undo, who changed what — the settings area the two pages are announced to
- Markdown — rendering markdown text — how a reply's markdown is shown
kits/reply-composer/tests/— the front door: every behaviour above as a test, the screens under--pixels