//! Writing a message: a new one, or a reply to one of the open thread's //! messages, to its sender or to everyone on it, over a quote of it. What //! is written stays as a draft until it is sent or thrown away, so Esc //! puts it away and c brings it back. The keeper keeps it somewhere safer //! than memory too, Gmail's drafts, a short while after typing pauses and //! as the card closes, so a quit or a crash does not lose it. //! //! Typing an address in To or Cc shows the people who match it, from //! the mail read so far (Contacts), to complete it with. Files chosen in //! the desktop's file chooser go with it, shown as chips that remove //! them. const std = @import("std"); const siv = @import("siv"); const Contacts = @import("Contacts.zig"); const Mailbox = @import("Mailbox.zig"); const kit = @import("kit.zig"); const portable_text = @import("portable_text.zig"); const ascii = std.ascii; const fs = std.fs; const mem = std.mem; const testing = std.testing; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const ArrayList = std.ArrayList; const Io = std.Io; const Addresses = Contacts.Addresses; const bare = Contacts.bare; const mentions = Contacts.mentions; const log = std.log.scoped(.compose); const Compose = @This(); /// A message to send. pub const Outgoing = struct { to: []const u8, cc: []const u8, subject: []const u8, /// The body as text, and as HTML when it was written as rich text; /// empty for text alone. body: []const u8, html: []const u8, /// A reply's thread, and the Message-ID and References it answers; /// empty for a new message. thread: []const u8, in_reply_to: []const u8, references: []const u8, attachments: []const Attachment, }; /// A file that goes with a message. pub const Attachment = struct { name: []const u8, mime: []const u8, data: []const u8, }; /// Keeps the draft and sends it; the desk hears how sending went /// through `sent`. pub const Keeper = struct { context: *anyopaque, /// Keeps the draft as it is now. save: *const fn (context: *anyopaque, message: Outgoing) void, send: *const fn (context: *anyopaque, message: Outgoing) void, /// Throws away what was kept. discard: *const fn (context: *anyopaque) void, }; /// The desk around the card, which shows the body's Vim mode in its /// status line and closes the card. pub const Host = struct { context: *anyopaque, /// The body's Vim mode or status changed, or the keyboard left it. state: *const fn (context: *anyopaque) anyerror!void, close: *const fn (context: *anyopaque, how: Close) anyerror!void, }; /// How the card closes: the draft kept, sent, or thrown away. pub const Close = enum { keep, send, discard }; pub const Kind = enum { new, reply, reply_all, forward }; /// Ctrl+Enter, which sends. pub const send_key: kit.Key = .{ .code = @backingInt(siv.Key.@"return"), .mods = .{ .ctrl = true }, .keycode = 0 }; /// Ctrl+Shift+A, which attaches files. pub const attach_key: kit.Key = .ctrl('A'); /// Ctrl+Shift+C, which shows Cc, as Gmail's does. pub const cc_key: kit.Key = .ctrl('C'); /// The most that can be attached: Gmail sends 25 MB at most. pub const max_attached = 25 << 20; gpa: Allocator, /// Reads the files attached. io: Io, /// Null: the draft stays in memory, and sending only says it cannot. keeper: ?Keeper, /// Null: the card's keys and hints do not close it. host: ?Host, /// The account's own address, which a reply to everyone leaves out. me: ArrayList(u8), /// The signature the account keeps on its server, as text, which a new /// message and a reply end with; empty for none. signature: ArrayList(u8), /// The draft, kept as it is typed. draft: Draft, /// Whether there is a draft to bring back. kept: bool, /// Whether a message is on its way. sending: bool, /// Whether the user changed the draft since the keeper last had it. dirty: bool, /// Counts the drafts started, so a file read for one that is gone goes /// nowhere. generation: u32, /// The files a forward takes along still being read; it waits for them. fetching: u32, /// Until the draft is kept, once typing pauses. timer: ?siv.Timer, /// How long typing pauses before the draft is kept. pause_ms: u32, /// The desktop's file chooser, once it has been asked for. chooser: siv.FileChooser, /// The people to suggest. contacts: Contacts, // On screen while open. layer: siv.Node, card: siv.Node, to_field: siv.Node, cc_field: siv.Node, /// Beside To, which shows Cc while it is hidden: most mail has none. cc_button: siv.Node, subject_field: siv.Node, /// The formatting toolbar's toggles, in `tools`' order. tool_nodes: [tool_count]siv.Node, body_field: siv.Node, /// What Vim in the body shows: keys pending, or the command line. vim_status: [128]u8, vim_status_len: u8, /// A Vim command from the command line, carried out once its keys are /// done. pending_quit: ?Quit, /// The question :q asks, while it is open, and its buttons. asking: siv.Node, closing_buttons: [closings.len]siv.Node, /// The answer, carried out once its button is done with the press. answer: ?Close, /// The attachments' chips, and what went wrong attaching. files: siv.Node, note: siv.Node, suggestions: Suggestions, /// Holds the card's click handlers while it is open. nodes: ArenaAllocator, /// How long typing pauses before the draft is kept. Gmail counts 15 /// quota units an update, so a save each pause costs a few hundred a /// minute at most, of the 6,000. const default_pause_ms = 2000; /// A toolbar toggle: a mark, the link editor, or a kind of block. const Tool = struct { /// Its tooltip and accessible name, and its label without an icon. name: []const u8, /// The editor's key for it, as a keycap shows it. cap: []const u8, /// Empty where the icon theme has none: the name shows instead. icon: []const u8, command: siv.RichTextCommand, /// Whether a gap sets it apart from the one before. group: bool, }; /// The toolbar: marks, the link, then the kinds of block. Adwaita has no /// icon for code or quotes, and its insert-link is no chain. const tools = [_]Tool{ .{ .name = "Bold", .cap = "^B", .icon = "format-text-bold-symbolic", .command = .strong, .group = false }, .{ .name = "Italic", .cap = "^I", .icon = "format-text-italic-symbolic", .command = .em, .group = false }, .{ .name = "Underline", .cap = "^U", .icon = "format-text-underline-symbolic", .command = .underline, .group = false, }, .{ .name = "Strikethrough", .cap = "^⇧X", .icon = "format-text-strikethrough-symbolic", .command = .strike_through, .group = false, }, .{ .name = "Code", .cap = "^E", .icon = "", .command = .code, .group = false }, .{ .name = "Link", .cap = "^K", .icon = "", .command = .link, .group = true }, .{ .name = "Quote", .cap = "^⇧9", .icon = "", .command = .blockquote, .group = true }, .{ .name = "Bulleted list", .cap = "^⇧8", .icon = "view-list-bullet-symbolic", .command = .bullet_list, .group = false, }, .{ .name = "Numbered list", .cap = "^⇧7", .icon = "view-list-ordered-symbolic", .command = .number_list, .group = false, }, }; /// How many formatting commands the toolbar and the palette have. pub const tool_count = tools.len; /// What Vim's commands do to the draft, from its command line or the /// palette: :w keeps it now; :q asks whether to keep it, send it, or /// throw it away; :wq and :x keep it, then ask; :q! throws it away. pub const Quit = enum { write, ask, write_ask, discard }; pub const vim_commands: std.StaticStringMap(Quit) = .initComptime(.{ .{ "w", .write }, .{ "q", .ask }, .{ "wq", .write_ask }, .{ "x", .write_ask }, .{ "q!", .discard }, }); /// The buttons of the question :q asks, by how the card closes. const closings = [_]struct { label: []const u8, mnemonic: u8, style: siv.ButtonStyle, how: Close }{ .{ .label = "Save", .mnemonic = 's', .style = .default, .how = .keep }, .{ .label = "Discard", .mnemonic = 'd', .style = .destructive, .how = .discard }, .{ .label = "Send", .mnemonic = 'n', .style = .primary, .how = .send }, }; /// The most people suggested at once. const max_suggestions = 6; /// The people suggested under To or Cc for the address being typed. const Suggestions = struct { /// siv's list under each field, which the field gives Up, Down, /// Enter, Tab, and Esc while it shows. to: siv.Node = .null, cc: siv.Node = .null, found: [max_suggestions]Contacts.Match = undefined, /// How many of `found` were offered last. count: u8 = 0, /// The field they are for, and the entry being typed there, as byte /// offsets into its text. field: Field = .to, start: u32 = 0, end: u32 = 0, }; const Field = enum { to, cc }; const Draft = struct { to: ArrayList(u8) = .empty, cc: ArrayList(u8) = .empty, subject: ArrayList(u8) = .empty, body: ArrayList(u8) = .empty, thread: ArrayList(u8) = .empty, in_reply_to: ArrayList(u8) = .empty, references: ArrayList(u8) = .empty, /// The body as HTML, once it is formatted; empty while its text says /// all of it. html: ArrayList(u8) = .empty, /// Their names, types, and bytes are the draft's own. attachments: ArrayList(Attachment) = .empty, fn fields(d: *Draft) [7]*ArrayList(u8) { return .{ &d.to, &d.cc, &d.subject, &d.body, &d.thread, &d.in_reply_to, &d.references }; } }; pub fn init(gpa: Allocator, io: Io) Compose { return .{ .gpa = gpa, .io = io, .keeper = null, .host = null, .me = .empty, .signature = .empty, .draft = .{}, .kept = false, .sending = false, .dirty = false, .generation = 0, .fetching = 0, .timer = null, .pause_ms = default_pause_ms, .chooser = .null, .contacts = .init(gpa), .layer = .null, .card = .null, .to_field = .null, .cc_field = .null, .cc_button = .null, .subject_field = .null, .tool_nodes = @splat(.null), .body_field = .null, .vim_status = undefined, .vim_status_len = 0, .pending_quit = null, .asking = .null, .closing_buttons = @splat(.null), .answer = null, .files = .null, .note = .null, .suggestions = .{}, .nodes = .init(gpa), }; } pub fn deinit(self: *Compose) void { self.cancelTimer(); if (self.chooser != .null) siv.fileChooserDestroy(self.chooser); self.dropAttachments(); self.draft.attachments.deinit(self.gpa); for (self.draft.fields()) |f| f.deinit(self.gpa); self.draft.html.deinit(self.gpa); self.me.deinit(self.gpa); self.signature.deinit(self.gpa); self.contacts.deinit(); self.nodes.deinit(); self.* = undefined; } /// The layer the card opens in, hidden until then. pub fn build(self: *Compose, txn: siv.Txn, layers: siv.Node) void { self.layer = siv.stack(txn, layers, .{ .align_x = .center, .align_y = .center }); siv.setLayout(txn, self.layer, .{ .width = kit.whole, .height = kit.whole }); const scrim = siv.box(txn, self.layer); siv.setLayout(txn, scrim, .{ .width = kit.whole, .height = kit.whole }); siv.setVisual(txn, scrim, .{ .background_token = .scrim, .flags = .{ .layer = true } }); siv.setHidden(txn, self.layer, true); } pub fn setMe(self: *Compose, address: []const u8) !void { self.me.clearRetainingCapacity(); try self.me.appendSlice(self.gpa, address); } pub fn setSignature(self: *Compose, text: []const u8) !void { var sig = mem.trim(u8, text, " \r\n"); // Many keep the "-- " line in the signature itself; `start` writes it. const first = mem.findScalar(u8, sig, '\n') orelse sig.len; if (mem.eql(u8, mem.trimEnd(u8, sig[0..first], " \r"), "--")) sig = mem.trim(u8, sig[first..], " \r\n"); self.signature.clearRetainingCapacity(); try self.signature.appendSlice(self.gpa, sig); } /// Starts a draft: a new message keeps the one put away, if any; a reply /// answers `message`, one of thread `thread`'s, and quotes it. pub fn start(self: *Compose, kind: Kind, thread: ?*const Mailbox.Thread, message: ?*const Mailbox.Message) !void { if (kind == .new and self.kept) return; self.clear(); self.kept = true; // Under the lines to type in, and above a reply's quote, after the // "-- " line that tells mail readers a signature follows. if (self.signature.items.len > 0) { try self.draft.body.print(self.gpa, "\n\n-- \n{s}", .{self.signature.items}); } if (kind == .new) return; const t = thread.?; const m = message.?; const gpa = self.gpa; const d = &self.draft; switch (kind) { .new => unreachable, .reply, .reply_all => try self.addressReply(kind, m), // To no one yet. .forward => {}, } const prefix: []const u8 = if (kind == .forward) "Fwd: " else "Re: "; if (!ascii.startsWithIgnoreCase(t.subject, prefix[0 .. prefix.len - 1])) try d.subject.appendSlice(gpa, prefix); try d.subject.appendSlice(gpa, t.subject); // A forward stays in the thread too, as Gmail keeps it; Gmail threads // a message only by these headers. try d.thread.appendSlice(gpa, t.id); if (kind == .forward) try forwarded(gpa, &d.body, t.subject, m) else try quote(gpa, &d.body, m); try d.in_reply_to.appendSlice(gpa, m.message_id); try d.references.appendSlice(gpa, mem.trim(u8, m.references, " ")); if (d.references.items.len > 0) try d.references.append(gpa, ' '); try d.references.appendSlice(gpa, m.message_id); } /// A reply's To, to the one who wrote `m` or to whom the user wrote it; /// everyone else on it in Cc, for a reply to everyone. fn addressReply(self: *Compose, kind: Kind, m: *const Mailbox.Message) !void { const gpa = self.gpa; const d = &self.draft; try d.to.appendSlice(gpa, if (m.sent) m.to else m.reply_to); if (kind != .reply_all) return; for ([_][]const u8{ m.to, m.cc }) |list| { var it: Addresses = .{ .rest = list }; while (it.next()) |entry| { const address = bare(entry); if (ascii.eqlIgnoreCase(address, self.me.items)) continue; if (mentions(d.to.items, address) or mentions(d.cc.items, address)) continue; if (d.cc.items.len > 0) try d.cc.appendSlice(gpa, ", "); try d.cc.appendSlice(gpa, entry); } } } /// Expects `count` files that a forward takes along, which come through /// `forwardedFile`; until they have, it is not sent. pub fn expectFiles(self: *Compose, count: u32) !void { self.fetching = count; if (count > 0) try self.tell("Reading {d} {s} to forward…", .{ count, if (count == 1) "file" else "files" }); } /// A file a forward takes along, read for the draft `generation` /// counted, or null when it could not be read. pub fn forwardedFile(self: *Compose, generation: u32, name: []const u8, mime: []const u8, data: ?[]const u8) !void { if (generation != self.generation or self.fetching == 0) return; self.fetching -= 1; const bytes = data orelse return self.tell("Could not read {s} to forward", .{name}); var total: u64 = 0; for (self.draft.attachments.items) |a| total += a.data.len; if (total + bytes.len > max_attached) return self.tell("{s} is too big: mail takes 25 MB at most", .{name}); try self.addAttachment(name, mime, bytes); try self.attachmentsChanged(); if (self.fetching > 0) return self.expectFiles(self.fetching); try self.tell("", .{}); } /// Starts a draft of a new message, as a mailto: link writes it, unless /// one is put away: error.DraftKept then, so it is not lost. pub fn startTo(self: *Compose, to: []const u8, subject: []const u8, body: []const u8) !void { if (self.kept) return error.DraftKept; self.clear(); self.kept = true; try self.draft.to.appendSlice(self.gpa, to); try self.draft.subject.appendSlice(self.gpa, subject); try self.draft.body.appendSlice(self.gpa, body); } /// Takes back a draft kept on an earlier run, unless there is one /// already or one is being sent. Whether it took it. pub fn restore(self: *Compose, message: Outgoing) !bool { if (self.kept or self.sending) return false; self.clear(); const d = &self.draft; const from = [_][]const u8{ message.to, message.cc, message.subject, message.body, message.thread, message.in_reply_to, message.references, }; for (d.fields(), from) |f, text| try f.appendSlice(self.gpa, text); self.kept = true; for (message.attachments) |a| try self.addAttachment(a.name, a.mime, a.data); return true; } /// Shows the draft in a card. pub fn show(self: *Compose, txn: siv.Txn) !void { _ = self.nodes.reset(.retain_capacity); siv.setHidden(txn, self.layer, false); self.card = siv.card(txn, self.layer, .{ .flags = .{ .accent = true } }); siv.setFlex(txn, self.card, .{ .axis = .column, .gap = 10, .align_items = .stretch }); siv.setLayout(txn, self.card, .{ // As wide as the reader's card, which it often answers. .width = .{ .unit = .fraction, .value = 0.94 }, .max_width = 1100, .height = .{ .unit = .fraction, .value = 0.8 }, .padding = .{ .start = 24, .end = 24, .top = 20, .bottom = 8 }, }); // Told by the subject, which a draft read back still has. const d = &self.draft; const title = if (d.thread.items.len == 0) "New message" else if (ascii.startsWithIgnoreCase(d.subject.items, "fwd:")) "Forward" else "Reply"; siv.setSemantics(txn, self.card, .{ .role = .dialog, .label = title }); _ = kit.text(txn, self.card, title, .{ .size_role = .heading, .weight = .bold, .color_token = .accent }); self.to_field = labeled(txn, self.card, "To", d.to.items); kit.on(Compose, txn, self.to_field, .change, self, onTo); self.cc_button = kit.text(txn, siv.nodeGetRelated(self.to_field, .parent), "Cc", .{ .color_token = .muted_fg }); siv.setCursor(txn, self.cc_button, .pointer); const press = kit.press(Compose); try kit.clickable(Compose, kit.Key, press, self.nodes.allocator(), txn, self.cc_button, self, cc_key, "Add Cc"); self.cc_field = labeled(txn, self.card, "Cc", d.cc.items); kit.on(Compose, txn, self.cc_field, .change, self, onCc); // Cc shows when the draft has one, as a reply to everyone may. siv.setHidden(txn, self.cc_button, d.cc.items.len > 0); siv.setHidden(txn, siv.nodeGetRelated(self.cc_field, .parent), d.cc.items.len == 0); self.subject_field = labeled(txn, self.card, "Subject", d.subject.items); kit.on(Compose, txn, self.subject_field, .change, self, onSubject); self.buildTools(txn); // Rich text, the marks, links, quotes, and lists mail's HTML has, with // Vim's keys; it grows with what is written, so it scrolls. const body = siv.scroll(txn, self.card, .vertical); siv.setLayout(txn, body, .{ .grow = 1, .basis = kit.px(0), .width = kit.whole }); var doc: Io.Writer.Allocating = .init(self.nodes.allocator()); try portable_text.fromText(&doc.writer, d.body.items); self.body_field = siv.richText(txn, body, .{ .schema = .custom, .styles = .{ .blockquote = true }, .marks = .{ .strong = true, .em = true, .underline = true, .strike_through = true, .code = true, .link = true }, .blocks = .{ .bullet_list = true, .number_list = true }, .label = "Message", .document = doc.written(), .flags = .{ .vim = true }, }); siv.setLayout(txn, self.body_field, .{ .width = kit.whole }); kit.on(Compose, txn, self.body_field, .change, self, onBody); kit.on(Compose, txn, self.body_field, .select, self, onBodyState); kit.on(Compose, txn, self.body_field, .focus, self, onBodyFocus); kit.on(Compose, txn, self.body_field, .activate, self, onCommand); self.vim_status_len = 0; self.files = siv.flex(txn, self.card, .{ .gap = 8, .align_items = .center, .flags = .{ .wrap = true } }); self.note = kit.text(txn, self.card, "", .{ .size_role = .caption, .color_token = .muted_fg }); siv.setHidden(txn, self.note, true); try self.fillFiles(txn); const hints = try kit.hintBar(Compose, self.nodes.allocator(), txn, self.card, self, &.{ .{ .key = send_key, .cap = "^⏎", .action = "send" }, .{ .key = attach_key, .cap = "^⇧A", .action = "attach" }, .{ .key = cc_key, .cap = "^⇧C", .action = "cc" }, // Esc too, outside the body, where it is Vim's. .{ .key = .named(.escape), .cap = ":q", .action = "keep as a draft" }, .{ .key = .ctrl('D'), .cap = "^⇧D", .action = "discard" }, }); // On the card's margin, as the fields are, not a bar's own inset. siv.setLayout(txn, hints, .{ .padding = .{ .top = 8, .bottom = 8 } }); // In the layer, after the card, so they show over the fields below. self.suggestions = .{ .to = siv.suggestionList(txn, self.layer, .{ .anchor = self.to_field, .label = "People" }), .cc = siv.suggestionList(txn, self.layer, .{ .anchor = self.cc_field, .label = "People" }), }; kit.on(Compose, txn, self.suggestions.to, .select, self, onPick); kit.on(Compose, txn, self.suggestions.cc, .select, self, onPick); } /// A one-line field with its name to its left, which stays once the /// field is filled, as a placeholder would not. fn labeled(txn: siv.Txn, parent: siv.Node, name: []const u8, text: []const u8) siv.Node { const row = siv.flex(txn, parent, .{ .gap = 12, .align_items = .center }); const caption = kit.text(txn, row, name, .{ .color_token = .muted_fg }); siv.setLayout(txn, caption, .{ .width = kit.px(56) }); const field = siv.textField(txn, row, .{ .text = text, .label = name }); siv.setLayout(txn, field, .{ .grow = 1, .shrink = 1, .basis = kit.px(0) }); return field; } /// The formatting toolbar over the body, its toggles lit as the text at /// the caret is. fn buildTools(self: *Compose, txn: siv.Txn) void { const bar = siv.flex(txn, self.card, .{ .gap = 4, .align_items = .center, .flags = .{ .wrap = true } }); siv.setSemantics(txn, bar, .{ .role = .toolbar, .label = "Formatting" }); for (tools, &self.tool_nodes) |tool, *node| { if (tool.group) siv.setLayout(txn, siv.box(txn, bar), .{ .width = kit.px(12) }); const icon_only: siv.WidgetFlags = .{ .icon_only = true }; node.* = siv.toggleButton(txn, bar, .{ .label = tool.name, .icon = tool.icon, .flags = icon_only }); siv.on(txn, node.*, .change, onTool, null, self); } } /// A toolbar toggle runs its command on the body, which keeps the /// keyboard. fn onTool(user_data: ?*anyopaque, node: siv.Node, _: *const siv.Event) callconv(.c) siv.EventResult { const self: *Compose = @ptrCast(@alignCast(user_data)); const i = mem.findScalar(siv.Node, &self.tool_nodes, node) orelse return .pass; self.runTool(tools[i].command) catch |err| log.err("{t}: {t}", .{ tools[i].command, err }); return .handled; } fn runTool(self: *Compose, command: siv.RichTextCommand) !void { try siv.nodeFocus(self.body_field); try siv.richTextRun(self.body_field, command); try self.syncTools(); } /// Lights the toggles the text at the caret has, rather than what the /// last click said. fn syncTools(self: *Compose) !void { if (self.card == .null) return; const state = try siv.richTextGetState(self.body_field); const txn = try siv.txnBegin(siv.nodeGetWindow(self.layer)); errdefer siv.txnAbort(txn); for (tools, self.tool_nodes) |tool, node| { const on = switch (tool.command) { .strong => state.marks.strong, .em => state.marks.em, .underline => state.marks.underline, .strike_through => state.marks.strike_through, .code => state.marks.code, .link => state.marks.link, .blockquote => state.style == .blockquote, .bullet_list => state.list == .bullet, .number_list => state.list == .number, else => false, }; siv.widgetSetValue(txn, node, if (on) 1 else 0); } try siv.txnCommit(txn); } /// The keys of the card's hints, which the desk gives it too: send, /// attach, Cc, keep the draft and close, and throw it away. pub fn onKey(self: *Compose, key: kit.Key) !bool { // The question :q asks takes them while it is open: Esc goes back. if (self.asking != .null) return false; if (key.isNamed(.escape)) return self.close(.keep); if (key.code == send_key.code and key.mods.ctrl) return self.close(.send); if (key.code == attach_key.code and key.mods.ctrl) { try self.chooseFiles(); return true; } if (key.code == cc_key.code and key.mods.ctrl) { try self.showCc(); return true; } if (key.isCtrl('D')) return self.close(.discard); return false; } fn close(self: *Compose, how: Close) !bool { const host = self.host orelse return false; try host.close(host.context, how); return true; } /// Vim's mode in the body while it has the keyboard, for the status /// line: null elsewhere, where Esc closes the card. pub fn vimMode(self: *const Compose) ?[]const u8 { if (self.card == .null or !kit.focusWithin(siv.nodeGetWindow(self.layer), self.body_field)) return null; const state = siv.richTextGetState(self.body_field) catch return null; return switch (state.mode) { .normal => "NORMAL", .insert => "INSERT", .visual => "VISUAL", .visual_line => "V-LINE", .command => "COMMAND", else => null, }; } /// The keys Vim has pending, or the command line being typed. pub fn vimStatus(self: *const Compose) []const u8 { return if (self.card == .null) "" else self.vim_status[0..self.vim_status_len]; } fn onBodyState(self: *Compose, _: *const siv.Event) !bool { const len = try siv.richTextGetStatus(self.body_field, &self.vim_status); self.vim_status_len = @intCast(@min(len, self.vim_status.len)); try self.syncTools(); try self.tellHost(); return false; } fn onBodyFocus(self: *Compose, _: *const siv.Event) !bool { // Not as the card goes, which takes the keyboard with it. if (self.card != .null) try self.tellHost(); return false; } fn tellHost(self: *Compose) !void { if (self.host) |host| try host.state(host.context); } /// Vim's commands for the draft, typed on its command line, as visual /// mode's : opens it; normal mode's opens the palette instead. fn onCommand(self: *Compose, event: *const siv.Event) !bool { const text = mem.trim(u8, if (event.text) |t| t[0..event.text_len] else "", " "); const quit = vim_commands.get(text) orelse return false; // Once the editor is done with the keys that asked. self.pending_quit = quit; try siv.post(siv.windowGetApp(siv.nodeGetWindow(self.layer)), onQuit, null, self); return true; } fn onQuit(user_data: ?*anyopaque) callconv(.c) void { const self: *Compose = @ptrCast(@alignCast(user_data)); const quit = self.pending_quit orelse return; self.pending_quit = null; if (self.card == .null) return; self.vim(quit) catch |err| log.err("closing: {t}", .{err}); } /// Does what Vim's command `quit` asks of the draft. pub fn vim(self: *Compose, quit: Quit) !void { switch (quit) { .write => { self.putAway(); try self.focusBody(); }, .ask => try self.askClose(), .write_ask => { self.putAway(); try self.askClose(); }, .discard => _ = try self.close(.discard), } } /// Asks whether to keep the draft, send it, or throw it away; Esc goes /// back to writing. pub fn askClose(self: *Compose) !void { if (self.card == .null or self.asking != .null) return; const txn = try siv.txnBegin(siv.nodeGetWindow(self.layer)); errdefer siv.txnAbort(txn); self.asking = siv.dialog(txn, self.layer, .{ .title = "Close the draft?", .text = "Save it to finish later, send it now, or discard it.", }); const actions = siv.widgetGetPart(self.asking, .actions, 0); for (closings, &self.closing_buttons) |c, *button| { button.* = siv.pushButton(txn, actions, .{ .label = c.label, .style = c.style, .mnemonic = c.mnemonic }); siv.on(txn, button.*, .activate, onClosing, null, self); } kit.on(Compose, txn, self.asking, .close, self, onAskClosed); try siv.txnCommit(txn); try siv.nodeFocus(self.closing_buttons[0]); } fn onClosing(user_data: ?*anyopaque, node: siv.Node, _: *const siv.Event) callconv(.c) siv.EventResult { const self: *Compose = @ptrCast(@alignCast(user_data)); const i = mem.findScalar(siv.Node, &self.closing_buttons, node) orelse return .pass; self.answer = closings[i].how; siv.post(siv.windowGetApp(siv.nodeGetWindow(self.layer)), onAnswer, null, self) catch |err| log.err("closing: {t}", .{err}); return .handled; } fn onAnswer(user_data: ?*anyopaque) callconv(.c) void { const self: *Compose = @ptrCast(@alignCast(user_data)); const how = self.answer orelse return; self.answer = null; self.dropQuestion(); _ = self.close(how) catch |err| log.err("closing: {t}", .{err}); } /// Esc or a click beside the question: back to writing. fn onAskClosed(self: *Compose, _: *const siv.Event) !bool { self.asking = .null; self.closing_buttons = @splat(.null); // Once the question is gone, which holds the keyboard till then. try siv.post(siv.windowGetApp(siv.nodeGetWindow(self.layer)), onRefocus, null, self); return false; } fn onRefocus(user_data: ?*anyopaque) callconv(.c) void { const self: *Compose = @ptrCast(@alignCast(user_data)); if (self.card != .null) self.focusBody() catch |err| log.err("focusing the message: {t}", .{err}); } fn dropQuestion(self: *Compose) void { if (self.asking == .null) return; const txn = siv.txnBegin(siv.nodeGetWindow(self.layer)) catch return; siv.remove(txn, self.asking); siv.txnCommit(txn) catch |err| log.err("closing the question: {t}", .{err}); self.asking = .null; self.closing_buttons = @splat(.null); } /// Puts the keyboard in the message. pub fn focusBody(self: *Compose) !void { if (self.card == .null) return; try siv.nodeFocus(self.body_field); // The status line shows Vim's mode again. try self.tellHost(); } /// Whether Vim in the body waits for a command, with none begun: its : /// then opens the palette. pub fn vimIdle(self: *const Compose) bool { if (self.card == .null or !kit.focusWithin(siv.nodeGetWindow(self.layer), self.body_field)) return false; const state = siv.richTextGetState(self.body_field) catch return false; return state.mode == .normal and self.vim_status_len == 0; } /// Formatting command `i`, as the palette lists it: its name and key. pub fn toolName(i: u8) []const u8 { return tools[i].name; } pub fn toolCap(i: u8) []const u8 { return tools[i].cap; } /// Runs formatting command `i` on the message, as its toggle does. pub fn runFormat(self: *Compose, i: u8) !void { try self.runTool(tools[i].command); } /// Shows Cc, if hidden, and types there. pub fn showCc(self: *Compose) !void { const txn = try siv.txnBegin(siv.nodeGetWindow(self.layer)); errdefer siv.txnAbort(txn); siv.setHidden(txn, self.cc_button, true); siv.setHidden(txn, siv.nodeGetRelated(self.cc_field, .parent), false); try siv.txnCommit(txn); try siv.nodeFocus(self.cc_field); } /// Where typing starts: the first field still empty, else the message. pub fn focus(self: *Compose) !void { const d = &self.draft; try siv.nodeFocus(if (d.to.items.len == 0) self.to_field else if (d.subject.items.len == 0) self.subject_field else self.body_field); } pub fn hide(self: *Compose, txn: siv.Txn) void { if (self.card != .null) siv.remove(txn, self.card); self.card = .null; if (self.asking != .null) siv.remove(txn, self.asking); self.asking = .null; self.closing_buttons = @splat(.null); self.tool_nodes = @splat(.null); self.files = .null; self.note = .null; for ([_]siv.Node{ self.suggestions.to, self.suggestions.cc }) |list| { if (list != .null) siv.remove(txn, list); } self.suggestions = .{}; siv.setHidden(txn, self.layer, true); } /// Has the keeper keep what changed in the draft, now rather than once /// typing pauses: as the card closes, or the app quits. pub fn putAway(self: *Compose) void { self.cancelTimer(); self.save(); } /// Whether the draft can go: there is a way to send it, and someone to /// send it to. pub fn ready(self: *const Compose) error{ NoSender, NoRecipient, FilesComing }!void { if (self.keeper == null) return error.NoSender; if (mem.trim(u8, self.draft.to.items, list_ends).len == 0) return error.NoRecipient; if (self.fetching > 0) return error.FilesComing; } /// What may end an address list: a suggestion taken leaves ", " after /// it, for the next. const list_ends = " ,"; /// Sends the draft, once `ready`; it stays until `sent` says it went. pub fn send(self: *Compose) void { const keeper = self.keeper.?; self.cancelTimer(); self.dirty = false; self.sending = true; keeper.send(keeper.context, self.outgoing()); } /// Hears whether the message went; one that went is no draft any more. pub fn sent(self: *Compose, ok: bool) void { self.sending = false; if (ok) self.clear(); } /// Throws the draft away, and what the keeper kept of it. pub fn discard(self: *Compose) void { self.clear(); if (self.keeper) |k| k.discard(k.context); } fn clear(self: *Compose) void { self.generation +%= 1; self.fetching = 0; self.cancelTimer(); self.dropAttachments(); for (self.draft.fields()) |f| f.clearRetainingCapacity(); self.draft.html.clearRetainingCapacity(); self.kept = false; self.dirty = false; } /// The draft as it would go. fn outgoing(self: *Compose) Outgoing { const d = &self.draft; return .{ .to = mem.trim(u8, d.to.items, list_ends), .cc = mem.trim(u8, d.cc.items, list_ends), .subject = d.subject.items, .body = d.body.items, .html = d.html.items, .thread = d.thread.items, .in_reply_to = d.in_reply_to.items, .references = d.references.items, .attachments = d.attachments.items, }; } /// Shows the desktop's file chooser; the files chosen are attached. pub fn chooseFiles(self: *Compose) !void { const window = siv.nodeGetWindow(self.layer); if (self.chooser == .null) self.chooser = try siv.fileChooserCreate(siv.windowGetApp(window), .{ .title = "Attach files", .accept_label = "_Attach", .flags = .{ .multiple = true }, }); siv.fileChooserShow(self.chooser, window, onChosen, null, self) catch |err| switch (err) { // It is showing already. error.Busy => {}, else => return err, }; } fn onChosen(user_data: ?*anyopaque, result: *const siv.PortalResult) callconv(.c) void { const self: *Compose = @ptrCast(@alignCast(user_data)); self.chosen(result) catch |err| log.err("attaching: {t}", .{err}); } fn chosen(self: *Compose, result: *const siv.PortalResult) !void { switch (result.response) { .success => {}, .cancelled => return, .unavailable => return self.tell("There is no file chooser: xdg-desktop-portal is not running", .{}), else => return self.tell("The file chooser failed", .{}), } try self.tell("", .{}); var buf: [Io.Dir.max_path_bytes]u8 = undefined; for (0..result.count) |i| { const len = siv.fileChooserGetPath(self.chooser, @intCast(i), &buf) catch |err| { log.warn("a file chosen: {t}", .{err}); try self.tell("Only local files can be attached", .{}); continue; }; try self.attach(buf[0..len]); } } /// Attaches the file at `path`, unless it cannot be read or would take /// the message past what Gmail sends; the card says so then. pub fn attach(self: *Compose, path: []const u8) !void { const name = fs.path.basename(path); var total: u64 = 0; for (self.draft.attachments.items) |a| total += a.data.len; const room = max_attached -| total; // Read here, on the app's thread: a local file of 25 MB at most. const data = Io.Dir.cwd().readFileAlloc(self.io, path, self.gpa, .limited(room + 1)) catch |err| switch (err) { error.StreamTooLong => return self.tell("{s} is too big: mail takes 25 MB at most", .{name}), error.OutOfMemory => return error.OutOfMemory, else => { log.info("{s}: {t}", .{ path, err }); return self.tell("Could not read {s}: {t}", .{ name, err }); }, }; defer self.gpa.free(data); try self.addAttachment(name, mimeType(name), data); try self.attachmentsChanged(); } fn addAttachment(self: *Compose, name: []const u8, mime: []const u8, data: []const u8) !void { const gpa = self.gpa; try self.draft.attachments.ensureUnusedCapacity(gpa, 1); const owned_name = try gpa.dupe(u8, name); errdefer gpa.free(owned_name); const owned_mime = try gpa.dupe(u8, mime); errdefer gpa.free(owned_mime); const owned_data = try gpa.dupe(u8, data); self.draft.attachments.appendAssumeCapacity(.{ .name = owned_name, .mime = owned_mime, .data = owned_data }); } /// Removes attachment `i`, by its chip's ✕, or Delete on the chip. fn removeAttachment(self: *Compose, i: u32, _: u32) !void { const a = self.draft.attachments.orderedRemove(i); self.freeAttachment(a); try self.attachmentsChanged(); } fn attachmentsChanged(self: *Compose) !void { try self.changed(); if (self.card == .null) return; const txn = try siv.txnBegin(siv.nodeGetWindow(self.layer)); errdefer siv.txnAbort(txn); try self.fillFiles(txn); try siv.txnCommit(txn); } /// A chip for each attachment, with its size. fn fillFiles(self: *Compose, txn: siv.Txn) !void { var child = siv.nodeGetRelated(self.files, .first_child); while (child != .null) { const after = siv.nodeGetRelated(child, .next); siv.remove(txn, child); child = after; } const arena = self.nodes.allocator(); for (self.draft.attachments.items, 0..) |a, i| { var size_buf: [16]u8 = undefined; const label = try arena.print("{s} · {s}", .{ a.name, size(&size_buf, a.data.len) }); const chip = siv.chip(txn, self.files, .{ .label = label, .icon = "mail-attachment-symbolic", .flags = .{ .removable = true }, }); try kit.handle(Compose, u32, removeAttachment, arena, txn, chip, .close, self, @intCast(i)); } siv.setHidden(txn, self.files, self.draft.attachments.items.len == 0); } /// Says under the attachments what went wrong attaching. fn tell(self: *Compose, comptime format: []const u8, args: anytype) !void { var buf: [256]u8 = undefined; const text = mem.print(&buf, format, args) catch format; if (text.len > 0) log.info("{s}", .{text}); if (self.card == .null) return; const txn = try siv.txnBegin(siv.nodeGetWindow(self.layer)); errdefer siv.txnAbort(txn); siv.setText(txn, self.note, text); siv.setHidden(txn, self.note, text.len == 0); try siv.txnCommit(txn); } fn dropAttachments(self: *Compose) void { for (self.draft.attachments.items) |a| self.freeAttachment(a); self.draft.attachments.clearRetainingCapacity(); } fn freeAttachment(self: *Compose, a: Attachment) void { self.gpa.free(a.name); self.gpa.free(a.mime); self.gpa.free(a.data); } /// "812 B", "48 KB", "3.4 MB". fn size(buf: []u8, bytes: usize) []const u8 { const kb = 1024; return if (bytes < kb) mem.print(buf, "{d} B", .{bytes}) catch "?" else if (bytes < kb * kb) mem.print(buf, "{d} KB", .{(bytes + kb / 2) / kb}) catch "?" else mem.print(buf, "{d:.1} MB", .{@as(f64, @floatFromInt(bytes)) / (kb * kb)}) catch "?"; } /// A file's type, by its extension, for the few common ones; any other /// is bytes. fn mimeType(name: []const u8) []const u8 { const dot = mem.findScalarLast(u8, name, '.') orelse return "application/octet-stream"; var buf: [8]u8 = undefined; const ext = name[dot + 1 ..]; if (ext.len > buf.len) return "application/octet-stream"; return mime_types.get(ascii.lowerString(&buf, ext)) orelse "application/octet-stream"; } const mime_types: std.StaticStringMap([]const u8) = .initComptime(.{ .{ "pdf", "application/pdf" }, .{ "zip", "application/zip" }, .{ "json", "application/json" }, .{ "txt", "text/plain" }, .{ "md", "text/markdown" }, .{ "csv", "text/csv" }, .{ "html", "text/html" }, .{ "ics", "text/calendar" }, .{ "png", "image/png" }, .{ "jpg", "image/jpeg" }, .{ "jpeg", "image/jpeg" }, .{ "gif", "image/gif" }, .{ "webp", "image/webp" }, .{ "svg", "image/svg+xml" }, .{ "mp3", "audio/mpeg" }, .{ "mp4", "video/mp4" }, .{ "doc", "application/msword" }, .{ "docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }, .{ "xls", "application/vnd.ms-excel" }, .{ "xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }, .{ "ppt", "application/vnd.ms-powerpoint" }, .{ "pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation" }, }); /// Notes that the user changed the draft, and keeps it once they pause. fn changed(self: *Compose) !void { self.dirty = true; self.cancelTimer(); const app = siv.windowGetApp(siv.nodeGetWindow(self.layer)); self.timer = try siv.timerStart(app, self.pause_ms, onPause, null, self); } fn onPause(user_data: ?*anyopaque) callconv(.c) void { const self: *Compose = @ptrCast(@alignCast(user_data)); self.timer = null; self.save(); } fn save(self: *Compose) void { if (!self.dirty) return; const keeper = self.keeper orelse return; self.dirty = false; keeper.save(keeper.context, self.outgoing()); } fn cancelTimer(self: *Compose) void { if (self.timer) |t| siv.timerCancel(t); self.timer = null; } /// Whether `key` is for the suggestions showing, which their field /// gives them: Up, Down, Enter, Tab, and Esc. The window's keys let it /// through, so Esc closes the list and not the draft. pub fn suggestionKey(self: *const Compose, key: kit.Key) bool { if (key.mods.ctrl or key.mods.alt) return false; const keys_ = [_]siv.Key{ .up, .down, .@"return", .tab, .escape }; for (keys_) |k| { if (!key.isNamed(k)) continue; for ([_]siv.Node{ self.suggestions.to, self.suggestions.cc }) |list| { if (list != .null and (siv.nodeIsShown(list) catch false)) return true; } } return false; } /// The one-line field with the keyboard focus, To, Cc, or Subject; null /// when the body or nothing of the draft's has it. pub fn focusedLine(self: *const Compose) ?siv.Node { const focused = siv.windowGetFocus(siv.nodeGetWindow(self.layer)); for ([_]siv.Node{ self.to_field, self.cc_field, self.subject_field }) |f| { if (f != .null and f == focused) return f; } return null; } /// Moves the suggestions' highlight by `delta`, wrapping, as Down and Up /// do: Ctrl+N and Ctrl+P. False when none show. pub fn moveSuggestion(self: *Compose, delta: i32) !bool { const s = &self.suggestions; if (s.count == 0) return false; const list = self.listNode(s.field); if (!(siv.nodeIsShown(list) catch false)) return false; const at: i32 = @intFromFloat(try siv.widgetGetValue(list)); const count: i32 = s.count; const next = if (at < 0) (if (delta > 0) 0 else count - 1) else @mod(at + delta, count); const txn = try siv.txnBegin(siv.nodeGetWindow(self.layer)); errdefer siv.txnAbort(txn); siv.widgetSetValue(txn, list, @floatFromInt(next)); try siv.txnCommit(txn); return true; } fn onTo(self: *Compose, event: *const siv.Event) !bool { _ = try self.keep(&self.draft.to, event); try self.suggest(.to, event.caret); return false; } fn onCc(self: *Compose, event: *const siv.Event) !bool { _ = try self.keep(&self.draft.cc, event); try self.suggest(.cc, event.caret); return false; } /// Finds the people who match the entry of `field` that the caret at /// byte `caret` is in, as far as the caret. fn suggest(self: *Compose, field: Field, caret: u32) !void { const s = &self.suggestions; const list = self.fieldText(field).items; const at = entryAt(list, caret); const query = mem.trimStart(u8, list[at.start..@max(at.start, @min(caret, at.end))], " \t"); s.field = field; s.start = at.start; s.end = at.end; // Not the user, nor anyone the field names already. const found = if (query.len == 0) s.found[0..0] else self.contacts.suggest(&s.found, query, &.{ list, self.me.items }); s.count = @intCast(found.len); const txn = try siv.txnBegin(siv.nodeGetWindow(self.layer)); errdefer siv.txnAbort(txn); const suggestions = self.listNode(field); siv.suggestionListClear(txn, suggestions); for (found) |m| _ = siv.suggestionListItem(txn, suggestions, .{ .label = if (m.name.len > 0) m.name else m.address, .detail = if (m.name.len > 0) m.address else "", }); try siv.txnCommit(txn); } /// Replaces the entry being typed with suggestion `i`, and closes them. fn take(self: *Compose, i: u8) !void { const gpa = self.gpa; const s = &self.suggestions; const list = self.fieldText(s.field); var taken: ArrayList(u8) = .empty; defer taken.deinit(gpa); const before = mem.trimEnd(u8, list.items[0..s.start], " \t"); const after = list.items[s.end..]; try taken.appendSlice(gpa, before); if (before.len > 0) try taken.append(gpa, ' '); try taken.print(gpa, "{f}", .{s.found[i]}); if (after.len == 0) try taken.appendSlice(gpa, ", "); const caret: u32 = @intCast(taken.items.len); try taken.appendSlice(gpa, after); mem.swap(ArrayList(u8), list, &taken); s.count = 0; try self.changed(); const node = self.fieldNode(s.field); const txn = try siv.txnBegin(siv.nodeGetWindow(self.layer)); errdefer siv.txnAbort(txn); // Neither sends a change event, so the draft has it already. siv.setText(txn, node, list.items); siv.setSelection(txn, node, caret, caret); siv.suggestionListClear(txn, self.listNode(s.field)); try siv.txnCommit(txn); } /// A suggestion chosen by Enter, Tab, or a click. fn onPick(self: *Compose, event: *const siv.Event) !bool { if (event.index < self.suggestions.count) try self.take(@intCast(event.index)); return true; } fn fieldText(self: *Compose, field: Field) *ArrayList(u8) { return switch (field) { .to => &self.draft.to, .cc => &self.draft.cc, }; } fn listNode(self: *const Compose, field: Field) siv.Node { return switch (field) { .to => self.suggestions.to, .cc => self.suggestions.cc, }; } fn fieldNode(self: *const Compose, field: Field) siv.Node { return switch (field) { .to => self.to_field, .cc => self.cc_field, }; } /// The byte range of the entry of an address list that offset `caret` /// is in, between the commas around it. fn entryAt(list: []const u8, caret: u32) struct { start: u32, end: u32 } { var from: usize = 0; while (true) { const end = from + Contacts.entryEnd(list[from..]); if (caret <= end or end == list.len) return .{ .start = @intCast(from), .end = @intCast(end) }; from = end + 1; } } fn onSubject(self: *Compose, event: *const siv.Event) !bool { return self.keep(&self.draft.subject, event); } /// Keeps the body as text, and as HTML once it is formatted. fn onBody(self: *Compose, _: *const siv.Event) !bool { var arena_state: ArenaAllocator = .init(self.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); const doc = try arena.alloc(u8, try siv.richTextGetDocument(self.body_field, &.{})); _ = try siv.richTextGetDocument(self.body_field, doc); const d = &self.draft; d.body.clearRetainingCapacity(); try d.body.appendSlice(self.gpa, try portable_text.toText(arena, doc)); d.html.clearRetainingCapacity(); if (try portable_text.formatted(arena, doc)) try d.html.appendSlice(self.gpa, try portable_text.toHtml(arena, doc)); try self.changed(); return false; } fn keep(self: *Compose, field: *ArrayList(u8), event: *const siv.Event) !bool { field.clearRetainingCapacity(); if (event.text) |text| try field.appendSlice(self.gpa, text[0..event.text_len]); try self.changed(); return false; } /// Starts a reply below an empty line with what it answers, quoted: /// "On Thu, 5 Nov 2026, Ana wrote:" and each line after "> ". The date is /// in UTC. fn quote(gpa: Allocator, body: *ArrayList(u8), m: *const Mailbox.Message) !void { var day_buf: [32]u8 = undefined; try body.print(gpa, "\n\nOn {s}, {s} wrote:\n", .{ dayOf(&day_buf, m.date), m.from }); var lines = mem.splitScalar(u8, m.text, '\n'); while (lines.next()) |line| { const trimmed = mem.trimEnd(u8, line, "\r"); try body.appendSlice(gpa, if (trimmed.len == 0 or trimmed[0] == '>') ">" else "> "); try body.appendSlice(gpa, trimmed); try body.append(gpa, '\n'); } } /// Message `m` of the thread `subject`, whole, under the lines Gmail /// heads a forward with. fn forwarded(gpa: Allocator, body: *ArrayList(u8), subject: []const u8, m: *const Mailbox.Message) !void { var day_buf: [32]u8 = undefined; try body.appendSlice(gpa, "\n\n---------- Forwarded message ---------\n"); if (m.address.len > 0 and !mem.eql(u8, m.address, m.from)) try body.print(gpa, "From: {s} <{s}>\n", .{ m.from, m.address }) else try body.print(gpa, "From: {s}\n", .{m.from}); try body.print(gpa, "Date: {s}\nSubject: {s}\n", .{ dayOf(&day_buf, m.date), subject }); if (m.to.len > 0) try body.print(gpa, "To: {s}\n", .{m.to}); if (m.cc.len > 0) try body.print(gpa, "Cc: {s}\n", .{m.cc}); try body.append(gpa, '\n'); var lines = mem.splitScalar(u8, m.text, '\n'); while (lines.next()) |line| { try body.appendSlice(gpa, mem.trimEnd(u8, line, "\r")); try body.append(gpa, '\n'); } } /// The day of `date`, in seconds since the epoch, as mail writes it in /// UTC: "Thu, 12 Nov 2026". fn dayOf(buf: []u8, date: i64) []const u8 { const epoch = std.time.epoch; const seconds: epoch.EpochSeconds = .{ .secs = @intCast(@max(date, 0)) }; const day = seconds.getEpochDay(); const year_day = day.calculateYearDay(); const month_day = year_day.calculateMonthDay(); const weekdays = [_][]const u8{ "Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed" }; const months = [_][]const u8{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; return mem.print(buf, "{s}, {d} {s} {d}", .{ weekdays[day.day % 7], month_day.day_index + 1, months[@backingInt(month_day.month) - 1], year_day.year, }) catch ""; } test "a new message and a reply end with the signature, above the quote" { var compose: Compose = .init(testing.allocator, testing.io); defer compose.deinit(); try compose.setSignature("Tim\nexample.org\n"); try compose.start(.new, null, null); try testing.expectEqualStrings("\n\n-- \nTim\nexample.org", compose.draft.body.items); var mail: Mailbox = try .init(testing.allocator); defer mail.deinit(); const id = try mail.addThread(.{ .id = "t1", .participants = "Ana", .subject = "Lunch", .snippet = "", .date = 0, .messages = 1, .labels = &.{}, .unread = false, .notes = &.{}, }); const m: Mailbox.Message = .{ .id = "m1", .from = "Ana", .address = "ana@example.com", .html = "", .text = "Friday?", .snippet = "", .attachments = &.{}, .unread = false, .message_id = "", // Thursday 5 November 2026. .date = 1_793_836_800, .references = "", .reply_to = "ana@example.com", .to = "tim@example.com", .cc = "", .unsubscribe = "", .one_click = false, .action = null, .invite = null, .sent = false, }; compose.discard(); try compose.start(.reply, mail.get(id), &m); try testing.expectEqualStrings( "\n\n-- \nTim\nexample.org\n\nOn Thu, 5 Nov 2026, Ana wrote:\n> Friday?\n", compose.draft.body.items, ); // A signature that has its own "-- " line gets no second one. try compose.setSignature("-- \r\nTim\n"); compose.discard(); compose.kept = false; try compose.start(.new, null, null); try testing.expectEqualStrings("\n\n-- \nTim", compose.draft.body.items); } test "a reply to everyone writes to the sender, and copies the rest once each, but not the user" { var compose: Compose = .init(testing.allocator, testing.io); defer compose.deinit(); try compose.setMe("tim@example.com"); var mail: Mailbox = try .init(testing.allocator); defer mail.deinit(); const id = try mail.addThread(.{ .id = "t1", .participants = "Ana, me", .subject = "Lunch, \"Friday\"", .snippet = "", .date = 0, .messages = 2, .labels = &.{}, .unread = false, .notes = &.{}, }); const newest: Mailbox.Message = .{ .id = "m2", .from = "Ana", .address = "ana@example.com", .html = "", .text = "See you\n\nAna", .snippet = "", .attachments = &.{}, .unread = false, .message_id = "", // Thursday 5 November 2026, 13:20 UTC. .date = 1_793_836_800 + 13 * 3600 + 20 * 60, .references = "", .reply_to = "\"Ana, B.\" ", .to = "Tim , bo@example.com", .cc = "Ana , \"Cy, C\" ", .unsubscribe = "", .one_click = false, .action = null, .invite = null, .sent = false, }; try compose.start(.reply_all, mail.get(id), &newest); const d = &compose.draft; try testing.expectEqualStrings("\"Ana, B.\" ", d.to.items); try testing.expectEqualStrings("bo@example.com, \"Cy, C\" ", d.cc.items); try testing.expectEqualStrings("Re: Lunch, \"Friday\"", d.subject.items); try testing.expectEqualStrings("", d.in_reply_to.items); try testing.expectEqualStrings(" ", d.references.items); try testing.expectEqualStrings("\n\nOn Thu, 5 Nov 2026, Ana wrote:\n> See you\n>\n> Ana\n", d.body.items); // A new message after it keeps that draft; once sent, there is none. // One kept on an earlier run does not replace it. try testing.expect(!try compose.restore(.{ .to = "x@example.com", .cc = "", .subject = "", .body = "", .html = "", .thread = "", .in_reply_to = "", .references = "", .attachments = &.{}, })); try compose.start(.new, null, null); try testing.expectEqualStrings("t1", d.thread.items); compose.sent(true); try compose.start(.new, null, null); try testing.expectEqual(0, d.to.items.len + d.thread.items.len); } /// A card in a headless window, which hears keys first as the desk's /// does. const TestHost = struct { app: siv.App, window: siv.Window, compose: Compose, /// How the card last closed. closed: ?Close, fn init(host: *TestHost) !void { host.closed = null; host.app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); errdefer siv.appDestroy(host.app); host.window = try siv.windowCreate(host.app, .{ .title = "Compose", .width = 1200, .height = 760 }); errdefer siv.windowDestroy(host.window); host.compose = .init(testing.allocator, testing.io); errdefer host.compose.deinit(); const txn = try siv.txnBegin(host.window); errdefer siv.txnAbort(txn); const root = siv.windowGetRoot(host.window); const layers = siv.stack(txn, root, .{}); siv.setLayout(txn, layers, .{ .width = kit.whole, .height = kit.whole }); host.compose.build(txn, layers); host.compose.host = .{ .context = host, .state = onState, .close = onClose }; kit.keymap(TestHost, txn, root, host); try siv.txnCommit(txn); } fn onState(_: *anyopaque) !void {} fn onClose(context: *anyopaque, how: Close) !void { const host: *TestHost = @ptrCast(@alignCast(context)); host.closed = how; const txn = try siv.txnBegin(host.window); errdefer siv.txnAbort(txn); host.compose.hide(txn); try siv.txnCommit(txn); } fn deinit(host: *TestHost) void { siv.windowDestroy(host.window); host.compose.deinit(); siv.appDestroy(host.app); host.* = undefined; } /// As the desk's: what is for the suggestions goes on to them, and /// Esc to Vim in the body. pub fn onKey(host: *TestHost, key: kit.Key) !bool { if (host.compose.suggestionKey(key)) return false; if (key.isNamed(.escape) and host.compose.vimMode() != null) return false; return host.compose.onKey(key); } fn open(host: *TestHost) !void { const txn = try siv.txnBegin(host.window); errdefer siv.txnAbort(txn); try host.compose.show(txn); try siv.txnCommit(txn); try host.settle(); } fn typeText(host: *TestHost, s: []const u8) !void { for (s) |c| try siv.testKey(host.window, @fromBackingInt(c), .{}, &.{c}); try host.settle(); } fn press(host: *TestHost, key: siv.Key, times: u32) !void { for (0..times) |_| try siv.testKey(host.window, key, .{}, ""); try host.settle(); } fn settle(host: *TestHost) !void { try testing.expect(try siv.testSettle(host.app, 5000)); } fn find(host: *TestHost, name: []const u8) !?siv.TestNode { return host.findAs(name, .list_item); } fn findAs(host: *TestHost, name: []const u8, role: siv.Role) !?siv.TestNode { var found = false; var n: siv.TestNode = .{}; try siv.testFind(host.window, .{ .name = name, .role = role }, &found, &n); return if (found) n else null; } fn click(host: *TestHost, n: siv.TestNode) !void { try siv.testPointerMove(host.window, n.bounds.x + n.bounds.width / 2, n.bounds.y + n.bounds.height / 2); try siv.testPointerButton(host.window, .primary, true); try siv.testPointerButton(host.window, .primary, false); try host.settle(); } }; /// A keeper that counts what it is asked, and holds the last message. const TestKeeper = struct { saves: u32 = 0, sends: u32 = 0, discards: u32 = 0, last: ?Outgoing = null, fn keeper(k: *TestKeeper) Keeper { return .{ .context = k, .save = onSave, .send = onSend, .discard = onDiscard }; } fn onSave(context: *anyopaque, m: Outgoing) void { const k: *TestKeeper = @ptrCast(@alignCast(context)); k.saves += 1; k.last = m; } fn onSend(context: *anyopaque, m: Outgoing) void { const k: *TestKeeper = @ptrCast(@alignCast(context)); k.sends += 1; k.last = m; } fn onDiscard(context: *anyopaque) void { const k: *TestKeeper = @ptrCast(@alignCast(context)); k.discards += 1; } }; test "To and Cc suggest people as an address is typed, and one taken completes only that address" { var host: TestHost = undefined; try host.init(); defer host.deinit(); const c = &host.compose; try c.setMe("tim@example.com"); try c.contacts.note(.wrote, "Maya Okafor , Tim ", 2000); try c.contacts.note(.heard, "Mark Ito ", 1000); try c.contacts.note(.heard, "Ravi Menon ", 900); // An address amid others: only it is replaced. try c.startTo("ana@x.org, , cy@x.org", "", ""); try host.open(); try siv.nodeFocus(c.to_field); try host.press(.end, 1); try host.press(.left, ", cy@x.org".len); try host.typeText("m"); // The one written to first; never the user. try testing.expect(try host.find("Maya Okafor, maya@example.com") != null); try testing.expect(try host.find("Mark Ito, mark@example.com") != null); try testing.expect(try host.find("Tim, tim@example.com") == null); try testing.expectEqualStrings("Maya Okafor", c.suggestions.found[0].name); try host.typeText("ay"); try testing.expect(try host.find("Mark Ito, mark@example.com") == null); try host.press(.tab, 1); try testing.expectEqualStrings("ana@x.org, Maya Okafor , cy@x.org", c.draft.to.items); try testing.expect(try host.find("Maya Okafor, maya@example.com") == null); // At the end, Down picks the next, Enter takes it, and ", " follows // for one more; Ctrl+Enter still sends, and Esc closes only the list. try host.press(.end, 1); try host.typeText(", m"); try testing.expect(!c.suggestionKey(send_key)); try testing.expectEqualStrings("Mark Ito", c.suggestions.found[0].name); try host.press(.down, 1); try host.press(.@"return", 1); try testing.expectEqualStrings( "ana@x.org, Maya Okafor , cy@x.org, Ravi Menon , ", c.draft.to.items, ); try host.typeText("i"); try testing.expect(try host.find("Mark Ito, mark@example.com") != null); try testing.expect(c.suggestionKey(.named(.escape))); try host.press(.escape, 1); try testing.expect(try host.find("Mark Ito, mark@example.com") == null); try testing.expect(!c.suggestionKey(.named(.escape))); try host.press(.backspace, 1); // In Cc, a click takes one; Maya is in To, which does not count. try c.showCc(); try host.typeText("ma"); try testing.expect(try host.find("Mark Ito, mark@example.com") != null); const maya = (try host.find("Maya Okafor, maya@example.com")).?; try siv.testPointerMove(host.window, maya.bounds.x + maya.bounds.width / 2, maya.bounds.y + maya.bounds.height / 2); try siv.testPointerButton(host.window, .primary, true); try siv.testPointerButton(host.window, .primary, false); try host.settle(); try testing.expectEqualStrings("Maya Okafor , ", c.draft.cc.items); // Leaving the field closes the list. try host.typeText("r"); try testing.expect(try host.find("Ravi Menon, ravi@example.com") != null); try siv.nodeFocus(c.subject_field); try host.settle(); try testing.expect(try host.find("Ravi Menon, ravi@example.com") == null); // What goes leaves off the comma left for one more. var keeper: TestKeeper = .{}; c.keeper = keeper.keeper(); c.send(); const went = keeper.last.?; try testing.expectEqualStrings( "ana@x.org, Maya Okafor , cy@x.org, Ravi Menon ", went.to, ); try testing.expectEqualStrings("Maya Okafor , r", went.cc); } test "the draft is kept once typing pauses, and as the card closes only if it changed" { var host: TestHost = undefined; try host.init(); defer host.deinit(); const c = &host.compose; var keeper: TestKeeper = .{}; c.keeper = keeper.keeper(); c.pause_ms = 500; try c.start(.new, null, null); try host.open(); try c.focus(); try host.typeText("ana@x.org"); // Not while typing: once, after the pause. try testing.expectEqual(0, keeper.saves); var waited: u32 = 0; while (keeper.saves == 0 and waited < 100) : (waited += 1) try siv.appDispatch(host.app, 20); for (0..5) |_| try siv.appDispatch(host.app, 20); try testing.expectEqual(1, keeper.saves); try testing.expectEqualStrings("ana@x.org", keeper.last.?.to); // Nothing changed since: closing keeps nothing more. c.putAway(); try testing.expectEqual(1, keeper.saves); // Changed, it is kept at once, and not again after the pause. try siv.nodeFocus(c.subject_field); try host.typeText("Hi"); c.putAway(); try testing.expectEqual(2, keeper.saves); try testing.expectEqualStrings("Hi", keeper.last.?.subject); for (0..30) |_| try siv.appDispatch(host.app, 20); try testing.expectEqual(2, keeper.saves); c.discard(); try testing.expectEqual(1, keeper.discards); try testing.expect(!c.kept); } test "files attached show as chips that remove them, and go with the draft" { var host: TestHost = undefined; try host.init(); defer host.deinit(); const c = &host.compose; var keeper: TestKeeper = .{}; c.keeper = keeper.keeper(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "Notes.TXT", .data = "hello" }); var path_buf: [128]u8 = undefined; const dir = try mem.print(&path_buf, ".zig-cache/tmp/{s}/", .{tmp.sub_path}); var file_buf: [160]u8 = undefined; try c.start(.new, null, null); try host.open(); try c.attach(try mem.print(&file_buf, "{s}Notes.TXT", .{dir})); try c.attach(try mem.print(&file_buf, "{s}nope.pdf", .{dir})); try host.settle(); try testing.expect(try host.findAs("Notes.TXT · 5 B", .default) != null); try testing.expect(try host.findAs("Could not read nope.pdf: FileNotFound", .default) != null); c.putAway(); const kept = keeper.last.?.attachments; try testing.expectEqual(1, kept.len); try testing.expectEqualStrings("Notes.TXT", kept[0].name); try testing.expectEqualStrings("text/plain", kept[0].mime); try testing.expectEqualStrings("hello", kept[0].data); // Its ✕ removes it, which is a change to keep. try host.click((try host.findAs("Remove", .button)).?); try testing.expect(try host.findAs("Notes.TXT · 5 B", .default) == null); c.putAway(); try testing.expectEqual(2, keeper.saves); try testing.expectEqual(0, keeper.last.?.attachments.len); // The hint asks the desktop for files; headless, there is no portal // to answer, and the card says so. try host.click((try host.findAs("attach", .button)).?); var waited: u32 = 0; const missing = "There is no file chooser: xdg-desktop-portal is not running"; while (try host.findAs(missing, .default) == null and waited < 50) : (waited += 1) { try siv.appDispatch(host.app, 20); try host.settle(); } try testing.expect(try host.findAs(missing, .default) != null); } test "a forward heads the message whole, writes to no one, stays in its thread, and waits for its files" { var host: TestHost = undefined; try host.init(); defer host.deinit(); const c = &host.compose; var keeper: TestKeeper = .{}; c.keeper = keeper.keeper(); var mail: Mailbox = try .init(testing.allocator); defer mail.deinit(); const id = try mail.addThread(.{ .id = "t1", .participants = "Ana", .subject = "Lunch", .snippet = "", .date = 0, .messages = 1, .labels = &.{}, .unread = false, .notes = &.{}, }); const m: Mailbox.Message = .{ .id = "m1", .from = "Ana", .address = "ana@example.com", .html = "", .text = "Friday?\r\n> earlier", .snippet = "", .attachments = &.{}, .unread = false, .message_id = "", // Thursday 5 November 2026. .date = 1_793_836_800, .references = "", .reply_to = "ana@example.com", .to = "tim@example.com", .cc = "bo@x.example", .unsubscribe = "", .one_click = false, .action = null, .invite = null, .sent = false, }; try c.start(.forward, mail.get(id), &m); try host.open(); try testing.expectEqualStrings("Fwd: Lunch", c.draft.subject.items); try testing.expectEqualStrings("", c.draft.to.items); try testing.expectEqualStrings("t1", c.draft.thread.items); try testing.expectEqualStrings("", c.draft.in_reply_to.items); try testing.expectEqualStrings(" ", c.draft.references.items); try testing.expectEqualStrings( "\n\n---------- Forwarded message ---------\nFrom: Ana \nDate: Thu, 5 Nov 2026\n" ++ "Subject: Lunch\nTo: tim@example.com\nCc: bo@x.example\n\nFriday?\n> earlier\n", c.draft.body.items, ); // Its files come one at a time, and it is not sent before they have: // one for a draft gone since goes nowhere, one unread is said. try c.expectFiles(2); try c.draft.to.appendSlice(c.gpa, "bo@x.example"); try host.settle(); try testing.expect(try host.findAs("Reading 2 files to forward…", .default) != null); try testing.expectError(error.FilesComing, c.ready()); try c.forwardedFile(c.generation -% 1, "old.pdf", "application/pdf", "old"); try c.forwardedFile(c.generation, "plan.pdf", "application/pdf", "%PDF"); try testing.expectError(error.FilesComing, c.ready()); try c.forwardedFile(c.generation, "gone.png", "image/png", null); try c.ready(); try host.settle(); try testing.expect(try host.findAs("plan.pdf · 4 B", .default) != null); try testing.expect(try host.findAs("Could not read gone.png to forward", .default) != null); try testing.expectEqual(1, c.draft.attachments.items.len); // A subject that says Fwd: already gets no second. c.discard(); var again = mail.get(id).*; again.subject = "FWD: Lunch"; try c.start(.forward, &again, &m); try testing.expectEqualStrings("FWD: Lunch", c.draft.subject.items); } test "the body is rich text: formatted, it goes as HTML too; with a quote alone it stays text" { var host: TestHost = undefined; try host.init(); defer host.deinit(); const c = &host.compose; var keeper: TestKeeper = .{}; c.keeper = keeper.keeper(); try c.start(.new, null, null); try host.open(); try siv.nodeFocus(c.body_field); try host.typeText("iHi "); try siv.testKey(host.window, @fromBackingInt('b'), .{ .ctrl = true }, ""); try host.typeText("Ana"); try testing.expectEqualStrings("Hi Ana", c.draft.body.items); c.putAway(); const went = keeper.last.?; try testing.expectEqualStrings("Hi Ana", went.body); try testing.expectEqualStrings("
Hi Ana
", went.html); // A reply's quote is a quote in the editor, and "> " in the text. c.discard(); try c.startTo("ana@example.com", "Re: Lunch", "\n> Lunch?"); try host.open(); try siv.nodeFocus(c.body_field); try host.typeText("iYes"); try testing.expectEqualStrings("Yes\n> Lunch?", c.draft.body.items); try testing.expectEqualStrings("", c.draft.html.items); // A web address typed links itself, and the body goes as HTML. try host.typeText(" see https://example.org/plan "); try testing.expectEqualStrings("Yes see https://example.org/plan \n> Lunch?", c.draft.body.items); try testing.expect(mem.find( u8, c.draft.html.items, "https://example.org/plan", ) != null); } test "the body takes Vim's keys, Esc among them, and the toolbar formats what is typed" { var host: TestHost = undefined; try host.init(); defer host.deinit(); const c = &host.compose; var keeper: TestKeeper = .{}; c.keeper = keeper.keeper(); try c.startTo("ana@example.com", "Plan", ""); try host.open(); try c.focus(); try testing.expectEqualStrings("NORMAL", c.vimMode().?); try host.typeText("iHi "); try testing.expectEqualStrings("INSERT", c.vimMode().?); // Out of insert mode, and a second one out of habit: the card stays. try host.press(.escape, 2); try testing.expectEqualStrings("NORMAL", c.vimMode().?); try testing.expect(host.closed == null); // Bold from the toolbar, which leaves the keyboard in the body, lit // while the caret is in bold text. try host.typeText("A"); const bold = (try host.findAs("Bold", .toggle_button)).?; try host.click(bold); try host.typeText("Ana"); try testing.expectEqualStrings("Hi Ana", c.draft.body.items); try testing.expectEqualStrings("
Hi Ana
", c.draft.html.items); try testing.expectEqual(1, try siv.widgetGetValue(c.tool_nodes[0])); try testing.expectEqual(0, try siv.widgetGetValue(c.tool_nodes[1])); try host.click((try host.findAs("Bulleted list", .toggle_button)).?); try testing.expect(mem.find(u8, c.draft.html.items, "
    ") != null); try testing.expectEqual(1, try siv.widgetGetValue(c.tool_nodes[7])); // Esc closes the link editor, not the card. try siv.testKey(host.window, @fromBackingInt('k'), .{ .ctrl = true }, ""); try host.settle(); try host.press(.escape, 1); try testing.expect(host.closed == null); // :q asks; Esc on the question goes back to writing, and Save keeps // the draft and closes the card. try host.press(.escape, 1); try host.typeText(":q"); // The editor reports its status once the key is done. try siv.appDispatch(host.app, 20); try testing.expectEqualStrings(":q", c.vimStatus()); try host.press(.@"return", 1); try siv.appDispatch(host.app, 20); try host.settle(); try testing.expect(try host.findAs("Close the draft?", .dialog) != null); try host.press(.escape, 1); try siv.appDispatch(host.app, 20); try host.settle(); try testing.expect(try host.findAs("Close the draft?", .dialog) == null); try testing.expect(host.closed == null); try testing.expect(kit.focusWithin(host.window, c.body_field)); try c.vim(.ask); try host.settle(); try host.click((try host.findAs("Save", .button)).?); try siv.appDispatch(host.app, 20); try host.settle(); try testing.expectEqual(Close.keep, host.closed.?); try testing.expectEqual(c.card, .null); // :q! throws the draft away; outside the body, Esc keeps it. host.closed = null; try host.open(); try c.focusBody(); try host.typeText(":q!"); try host.press(.@"return", 1); try siv.appDispatch(host.app, 20); try host.settle(); try testing.expectEqual(Close.discard, host.closed.?); host.closed = null; try host.open(); try siv.nodeFocus(c.subject_field); try host.press(.escape, 1); try testing.expectEqual(Close.keep, host.closed.?); }