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

> `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.

<!-- id: ui-composer-kit · area: ui · stability: preview · html: https://osysharp.com/reference/ui/composer-kit/ -->

## Summary        {#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:

```osy syntax
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      {#signature}
```osy syntax
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    {#description}

### Placeholders are typed, not a template the writer can break   {#placeholders}
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   {#macros}
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   {#formatting}
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   {#drafts}
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 @   {#mentions}
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](https://osysharp.com/reference/ui/notifications-kit/)).
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?   {#settings}
`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       {#examples}

### A shop answering enquiries   {#example-enquiries}
```osy title="a target, its placeholders and actions, and a composer on a page" test app=ui-composer-kit
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       {#see-also}
- [The Conversations kit — one timeline per conversation, across every channel](https://osysharp.com/reference/ui/conversations-kit/) — 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](https://osysharp.com/reference/ui/inbox-kit/) — the desk the composer usually sits on
- [The Notifications kit — telling a person that something needs them](https://osysharp.com/reference/ui/notifications-kit/) — telling the people a note names
- [SettingsArea — an app's settings: sections, explicit save and discard, undo, who changed what](https://osysharp.com/reference/ui/settings/) — the settings area the two pages are announced to
- [Markdown — rendering markdown text](https://osysharp.com/reference/ui/markdown/) — how a reply's markdown is shown
- **`kits/reply-composer/tests/`** — the front door: every behaviour above as a test, the screens under `--pixels`
