Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

The ReplyComposer kit — where a person writes a reply, on any record

use Osysharp.ReplyComposer@0; · policy ComposesReplies => …; · policy ManagesSavedReplies => …; · app.Composer = new ComposerSetup { Targets = [new MyReplies()] }; · Composer(item, audience:, seen:, keymap:) · SavedReplySettings() · MacroSettings() · PrepareDraft(kind, item, text, by)

`Osysharp.ReplyComposer` is the box a person writes a reply in — on a ticket, a case, an order, a lead. Saved replies with placeholders the APP declares and fills from the record (a key nobody declared is refused when the reply is saved; one the record cannot fill is refused when it is sent), macros whose steps are the app's own `[Action]`s, run with the send, markdown with a bar and keys that act on the selection, a draft saved per record and per author that survives a reload, the writer's signature, files, and "send and set…". The app says once what sending means, in one class.

preview1 example compiled by CIuikitcomposerreplies

Source on GitHub: Osysharp.ReplyComposer — github.com/osysharp/replycomposer ↗

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 assistant

Description#

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#

Related

The Conversations kit — one timeline per conversation, across every channel

`Osysharp.Conversations` keeps a conversation with a customer as ONE ordered timeline — their messages, the team's…

The WorkQueues kit's screens — the rail, the list, an item's head, the team, the settings

`Osysharp.WorkQueues` ships the screens a team works an inbox from, on the Switchboard floor: its views, queues and…

SettingsArea — an app's settings: sections, explicit save and discard, undo, who changed what

The kit's settings vocabulary: `SettingsArea` lays out an app's settings — its sections in a grouped nav, the current…

Menus, the command menu, and keyboard shortcuts

The kit's menus grow out of the control that opened them and keep a notch pointing back at it, carry icons, key hints…

Markdown — rendering markdown text

`Markdown(text)` renders a markdown string as formatted content — headings, lists, tables, code, links. It is a…

upload

`Upload(onUploaded: Ingest) { … }` is a file picker wearing whatever you put inside it. When someone chooses a file its…

App.Implementations<T>()

One new instance of every class in the app's composition — its own and every package's — that implements the interface…