Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380//! The open thread's messages. They are read when the thread is opened,//! through `fetcher`, kept for as long as the window, and read again only//! when the thread gains a message. The newest message and those unread//! when they were read show whole, each body in a document view; the//! rest fold to a line that a click unfolds, and x unfolds them all. Once//! the reader leaves a thread, its messages count as read. The reader scrolls itself, so//! j, k, Space, and the wheel agree on where it is.//!//! A message's files are listed under it; one clicked, or picked in the//! palette, is saved to the downloads directory and opened. Images its//! body shows by Content-ID come from its parts. Images from the web//! load as the thread opens, unless it is spam or the user turned that//! off (`Images.zig`); then they are blocked, so a sender cannot tell//! the mail was read, until the user shows them for the thread (i). As Gmail does, mail from a list has a//! button that leaves it, a message whose markup offers a page has a//! button to it, and an invitation shows its event. Each message shown//! whole ends in Reply, Reply all, and Forward.//!//! What changes in an open thread changes only its own nodes: a message//! unfolded or folded, one that came, the images, the colors, the//! unsubscribe button. The rest keep their place, their scroll, and the quotes the//! user opened.//!//! The messages zoom (`Zoom.zig`) as a browser zooms a page: their scroll//! node scales them and reflows them, so the text wraps to the reader's//! width at any size.
const std = @import("std");const siv = @import("siv");const zeit = @import("zeit");const Compose = @import("Compose.zig");const Mailbox = @import("Mailbox.zig");const Avatars = @import("Avatars.zig");const Images = @import("Images.zig");const Zoom = @import("Zoom.zig");const kit = @import("kit.zig");const ascii = std.ascii;const mem = std.mem;const testing = std.testing;const Allocator = std.mem.Allocator;const ArenaAllocator = std.heap.ArenaAllocator;const Io = std.Io;const log = std.log.scoped(.reader);
const Reader = @This();
/// Reads a thread's messages, which come back through `deliver`, and the/// files they hold, which come back through `Want.receive`.pub const Fetcher = struct { context: *anyopaque, fetch: *const fn (context: *anyopaque, thread: []const u8) void, /// Reads a message's attachment, both by Gmail's ids; null when /// there is nothing to read one from, as with the samples. attachment: ?*const fn (context: *anyopaque, message: []const u8, attachment: []const u8, want: *Want) void, /// Reads an http or https URL, such as an image's, or posts `body` /// to it, with no account's authorization; null when there is no /// network to use, as with the samples. web: ?*const fn (context: *anyopaque, url: []const u8, body: ?[]const u8, want: *Want) void,};
/// The view the reader is in.pub const Host = struct { context: *anyopaque, /// Draws again, as when messages arrive. redraw: *const fn (context: *anyopaque) void, /// Tells the user something in the status line. say: *const fn (context: *anyopaque, text: []const u8) void, /// Opens a draft of a message to send, as a mailto: link writes it. compose: *const fn (context: *anyopaque, to: []const u8, subject: []const u8, body: []const u8) void, /// Starts a reply to the shown thread's `message`th message, counting /// from 0, as a Reply or Reply all button under it asks. reply: *const fn (context: *anyopaque, message: u32, kind: Compose.Kind) void, /// A file a forward takes along, read for the draft `draft` counts, /// or null when it could not be read. attach: *const fn (context: *anyopaque, draft: u32, name: []const u8, mime: []const u8, bytes: ?[]const u8) void, /// The shown thread's messages came, so the user sees them: it is /// read. Not before, so Gmail still said which were unread. read: *const fn (context: *anyopaque, thread: []const u8) void, /// A thread's messages came, shown or not, or could not be read. came: *const fn (context: *anyopaque, thread: []const u8, ok: bool) void,};
/// A file being read for the reader.pub const Want = struct { reader: *Reader, what: union(enum) { /// A document asked for it, and it goes there. supply: struct { node: siv.Node, request: u32 }, /// The user opened the file of this name: it is saved and opened. open: []const u8, /// The user left the list of this sender: the reply says how it went. unsubscribe: []const u8, /// The sender's photo, from Gravatar. avatar: Avatars.Hash, /// A file a forward takes along, for the draft `draft` counts. forward: Forwarded, },
/// Takes the bytes asked for, or null when they could not be read. /// `want` is gone once this returns. pub fn receive(want: *Want, bytes: ?[]const u8) void { const self = want.reader; if (mem.findScalar(*Want, self.wants.items, want)) |i| _ = self.wants.swapRemove(i); defer self.free(want); switch (want.what) { // A document shown no more ignores it. .supply => |s| siv.docviewSupply(s.node, s.request, bytes orelse "") catch |err| log.debug("supplying a document: {t}", .{err}), .open => |path| if (bytes) |b| self.saveAndOpen(path, b) else self.tell("Could not read {s}", .{path}), .unsubscribe => |from| if (bytes != null) self.tell("Unsubscribed from {s}", .{from}) else self.tell("Could not unsubscribe from {s}", .{from}), .avatar => |h| self.avatars.came(h, bytes), .forward => |f| self.host.attach(self.host.context, f.draft, f.name, f.mime, bytes), } }};
/// A file a forward takes along, by its name and type.pub const Forwarded = struct { draft: u32, name: []const u8, mime: []const u8 };
/// How to leave the mailing list a message came from.pub const Unsubscribe = union(enum) { /// RFC 8058: a POST to this https URL does it, with no page to see. one_click: []const u8, /// A message to send, as mailto: writes it. mail: struct { to: []const u8, subject: []const u8, body: []const u8 }, /// A page to open in the browser. page: []const u8,};
/// A message's file the user can open, and the message.pub const File = struct { message: *const Mailbox.Message, attachment: *const Mailbox.Attachment,};
/// Why the reader makes what it shows again rather than update it.const Rebuild = enum { opened, came };
/// How long the selection must rest on a thread before it is read: reading/// one costs a fair share of Gmail's quota, and j and k pass through many.const settle_ms = 250;
/// The card's margin at each side. The column keeps the end one inside its/// scroll node, so the scrollbar floats over it, not over the messages.pub const margin = 24;
/// How much further in a message's own document sits than the lines about/// it, at each side.const inset = 12;
/// The avatars by a message shown whole and by one folded to a line.const avatar_size = 28;const folded_avatar_size = 22;
/// No margin of the mail's own, and the system's face, sans-serif, for/// mail that sets none, as mail is written for and other clients show it./// Monospace runs wider than sans-serif at one size, so code and/// preformatted text are made smaller, as browsers make them, but once,/// not again inside each other.const css = \\body { margin: 0; font-family: system-ui, sans-serif; line-height: 1.45 } \\pre, code, kbd, samp, tt { font-size: 0.875em } \\:is(pre, code, kbd, samp, tt) :is(pre, code, kbd, samp, tt) { font-size: 1em }++ quote_css;
/// Quoted replies, each level in a card of its own hue, over the mail's/// own gray rules: blue, green, and red, then round again, as Fastmail/// shows them. The text keeps the quote's muted color, which a dark/// theme's made colors read in too.const quote_css = \\blockquote { margin: 0.6em 0 !important; padding: 0.5em 0.9em !important; border: none !important; \\ border-left: 3px solid rgb(99 124 230) !important; border-radius: 6px !important; \\ background: rgb(99 124 230 / 0.12) !important }++ level("blockquote blockquote", "46 160 90") ++ level("blockquote blockquote blockquote", "220 90 70") ++ level("blockquote blockquote blockquote blockquote", "99 124 230") ++ level("blockquote blockquote blockquote blockquote blockquote", "46 160 90") ++ level("blockquote blockquote blockquote blockquote blockquote blockquote", "220 90 70");
fn level(comptime selector: []const u8, comptime rgb: []const u8) []const u8 { return "\n" ++ selector ++ " { border-left-color: rgb(" ++ rgb ++ ") !important; background: rgb(" ++ rgb ++ " / 0.12) !important }";}
gpa: Allocator,io: Io,window: siv.Window,host: Host,/// Null shows each thread's snippet alone, as with the sample mail.fetcher: ?Fetcher,/// Where opened files are saved; empty when there is nowhere.downloads: []const u8,/// What opens a saved file, given its path; tests open nothing.opener: []const u8,/// Files being read.wants: std.ArrayList(*Want),/// Holds the threads' messages and ids, for as long as the window.arena_state: ArenaAllocator,conversations: std.array_hash_map.String(Conversation),/// Threads asked for whose messages have not come.asked: std.array_hash_map.String(void),/// The thread to read once the selection rests, and its timer.want: ?[]const u8,timer: ?siv.Timer,
// What is shown: the thread, and how.shown: []const u8,shown_buf: [64]u8,/// Why what is shown must be made again; null while it need not be.rebuild: ?Rebuild,/// Whether the shown thread's messages could not be read.failed: bool,/// Every message unfolded (x).all: bool,/// Whether images from the web show in the open thread (i).images: bool,/// Whether they show as a thread opens, shared with the other accounts'/// readers.web_images: *Images,/// The senders' photos, asked of the web only where images may load.avatars: Avatars,/// Whether the messages show in their own colors (v), rather than made/// dark to suit a dark theme.original: bool,/// Whether the unsubscribe button was clicked once, and asks to be/// clicked again.armed: bool,/// Says images were blocked, and shows them when clicked; hidden until/// a document blocks one.bar: siv.Node,/// Messages unfolded by a click.unfolded: std.bit_set.Dynamic,/// Holds what the shown messages' nodes need, such as click handlers.nodes: ArenaAllocator,scroll: siv.Node,column: siv.Node,/// How large the messages show, shared with the other accounts' readers.zoom: *Zoom,/// The scale last given to the scroll node, and the one it last said it/// has: a change it says is a pinch or Ctrl+wheel, which `zoom` takes.zoom_asked: f32,zoom_reported: f32,/// The shown messages' nodes, in order, as they were made.parts: std.ArrayList(Part),/// The unsubscribe button, the message it is in, and whether it was/// made armed.leave: siv.Node,leave_at: u32,leave_armed: bool,
const Conversation = struct { messages: []Mailbox.Message,};
/// A shown message's node, whether it shows whole, and its document/// view (none when folded); its avatar, whether that shows the sender's/// photo yet, and the sender's address as Gravatar names it, if any.const Part = struct { node: siv.Node, whole: bool, doc: siv.Node, face: siv.Node, photo: bool, hash: ?Avatars.Hash,};
pub fn init(gpa: Allocator, io: Io, window: siv.Window, host: Host, zoom: *Zoom, web_images: *Images) Reader { return .{ .gpa = gpa, .io = io, .window = window, .host = host, .fetcher = null, .downloads = "", .opener = "xdg-open", .wants = .empty, .arena_state = .init(gpa), .conversations = .empty, .asked = .empty, .want = null, .timer = null, .shown = "", .shown_buf = undefined, .rebuild = .opened, .failed = false, .all = false, .images = false, .web_images = web_images, .avatars = .init(gpa, io, window, .{ .context = host.context, .call = host.redraw }), .original = false, .armed = false, .bar = .null, .unfolded = .{}, .nodes = .init(gpa), .scroll = .null, .column = .null, .zoom = zoom, .zoom_asked = 1, .zoom_reported = 1, .parts = .empty, .leave = .null, .leave_at = 0, .leave_armed = false, };}
pub fn deinit(self: *Reader) void { if (self.timer) |t| siv.timerCancel(t); for (self.wants.items) |w| self.free(w); self.wants.deinit(self.gpa); self.conversations.deinit(self.gpa); self.asked.deinit(self.gpa); self.unfolded.deinit(self.gpa); self.parts.deinit(self.gpa); self.nodes.deinit(); self.avatars.deinit(); self.arena_state.deinit(); self.* = undefined;}
/// Shows thread `t`'s messages in `slot`, made again only when they or/// how they show changed, so the documents keep their place.pub fn render( self: *Reader, txn: siv.Txn, slot: siv.Node, t: *const Mailbox.Thread, zone: *const zeit.TimeZone, now: i64,) !void { if (!mem.eql(u8, self.shown, t.id)) { self.seen(self.shown); const n = @min(t.id.len, self.shown_buf.len); @memcpy(self.shown_buf[0..n], t.id[0..n]); self.shown = self.shown_buf[0..n]; self.all = false; // Spam's would tell its sender the address reads mail. self.images = self.web_images.auto and !t.labels.isSet(@backingInt(Mailbox.Label.spam)); self.original = false; self.armed = false; self.failed = false; self.unfolded.unsetAll(); self.rebuild = .opened; } const held = self.conversations.getPtr(t.id); if (held == null or held.?.messages.len != t.messages) try self.ask(t.id); if (self.rebuild == null and self.scroll != .null) { // Another account's reader may have zoomed while this one hid. self.fit(txn); if (held) |c| try self.update(txn, c, zone, now); return; } // Kept in the log: each time costs the documents their place and // their images, which load again. const why = if (self.rebuild) |r| switch (r) { .opened => "opened", .came => "its messages came", } else "the reader opened again"; log.info("showing thread {s} anew, {d} messages: {s}", .{ t.id, t.messages, why }); self.rebuild = null; if (self.scroll != .null) siv.remove(txn, self.scroll); _ = self.nodes.reset(.retain_capacity); self.parts.clearRetainingCapacity(); self.leave = .null; self.scroll = siv.scroll(txn, slot, .vertical); siv.setLayout(txn, self.scroll, .{ .width = kit.whole, .height = kit.whole }); self.column = siv.flex(txn, self.scroll, .{ .axis = .column, .align_items = .stretch }); // The margin the scrollbar floats over, inside what scrolls. siv.setLayout(txn, self.column, .{ .width = kit.whole, .padding = .{ .end = margin } }); siv.setScrollZoomRange(txn, self.scroll, Zoom.min, Zoom.max); siv.setScrollFlags(txn, self.scroll, .{ .reflow = true }); kit.on(Reader, txn, self.scroll, .viewport, self, onViewport); self.zoom_asked = 1; self.zoom_reported = 1; self.fit(txn); if (held) |c| { try self.renderBar(txn); try self.unfolded.resize(self.gpa, c.messages.len, false); try self.parts.ensureTotalCapacity(self.gpa, c.messages.len); for (c.messages, 0..) |*m, i| { self.parts.appendAssumeCapacity(try self.renderMessage(txn, m, @intCast(i), c.messages.len, zone, now)); } } else { _ = kit.text(txn, self.column, t.snippet, .{ .color_token = .view_fg, .line_spacing = 1.45 }); const note = if (self.failed) "Could not read this conversation." else if (self.fetcher != null) "Reading the conversation…" else ""; if (note.len > 0) { const line = siv.flex(txn, self.column, .{ .gap = 8, .align_items = .center }); siv.setLayout(txn, line, .{ .padding = .{ .top = 12 } }); if (!self.failed) _ = siv.spinner(txn, line, .{ .label = "Reading the conversation", .diameter = 14 }); _ = kit.text(txn, line, note, .{ .size_role = .caption, .color_token = .muted_fg }); } }}
/// The reader closed; its nodes went with it.pub fn close(self: *Reader) void { self.seen(self.shown); if (self.timer) |t| siv.timerCancel(t); self.timer = null; self.want = null; self.scroll = .null; self.column = .null; self.bar = .null; self.parts.clearRetainingCapacity(); self.leave = .null; self.shown = "";}
/// Takes a thread's messages, or null when they could not be read.pub fn deliver(self: *Reader, thread: []const u8, messages: ?[]const Mailbox.Message) !void { if (self.asked.fetchSwapRemove(thread) == null) return; const showing = mem.eql(u8, thread, self.shown); // Messages that only add to those shown are added; others remake all. const added = showing and messages != null and self.adds(self.conversation(thread), messages.?); if (messages) |list| { const a = self.arena_state.allocator(); const copy = try a.alloc(Mailbox.Message, list.len); for (list, copy) |m, *out| out.* = .{ .id = try a.dupe(u8, m.id), .from = try a.dupe(u8, m.from), .address = try a.dupe(u8, m.address), .date = m.date, .html = try a.dupe(u8, m.html), .text = try a.dupe(u8, m.text), .snippet = try a.dupe(u8, m.snippet), .attachments = try dupeAttachments(a, m.attachments), .unread = m.unread, .message_id = try a.dupe(u8, m.message_id), .references = try a.dupe(u8, m.references), .reply_to = try a.dupe(u8, m.reply_to), .to = try a.dupe(u8, m.to), .cc = try a.dupe(u8, m.cc), .sent = m.sent, .unsubscribe = try a.dupe(u8, m.unsubscribe), .one_click = m.one_click, .action = if (m.action) |act| .{ .name = try a.dupe(u8, act.name), .url = try a.dupe(u8, act.url), } else null, .invite = if (m.invite) |inv| .{ .summary = try a.dupe(u8, inv.summary), .when = try a.dupe(u8, inv.when), .location = try a.dupe(u8, inv.location), .organizer = try a.dupe(u8, inv.organizer), .cancelled = inv.cancelled, .ics = try a.dupe(u8, inv.ics), } else null, }; const entry = try self.conversations.getOrPut(self.gpa, try self.name(thread)); entry.value_ptr.* = .{ .messages = copy }; } else if (showing) { self.failed = true; } if (showing) { if (added) { // What showed whole stays whole, so the user keeps reading it. try self.unfolded.resize(self.gpa, messages.?.len, false); for (self.parts.items, 0..) |p, i| if (p.whole) self.unfolded.set(i); } else self.rebuild = self.rebuild orelse .came; if (messages != null) self.host.read(self.host.context, thread); self.host.redraw(self.host.context); } self.host.came(self.host.context, thread, messages != null);}
/// Whether `next` is the messages shown, `before`, and more after them.fn adds(self: *const Reader, before: []const Mailbox.Message, next: []const Mailbox.Message) bool { if (self.parts.items.len != before.len or next.len <= before.len or before.len == 0) return false; for (before, next[0..before.len]) |a, b| if (!mem.eql(u8, a.id, b.id)) return false; return true;}
/// Asks for thread `thread`'s messages now, shown or not, as a reply to/// one not opened needs them; `Host.came` says when they come. False/// when there is nothing to read them from.pub fn fetchNow(self: *Reader, thread: []const u8) !bool { const f = self.fetcher orelse return false; if (self.asked.contains(thread)) return true; try self.asked.put(self.gpa, try self.name(thread), {}); f.fetch(f.context, thread); return true;}
/// Whether the reader has thread `thread` as it is, with all `count` of/// its messages, so opening it shows them at once.pub fn holds(self: *const Reader, thread: []const u8, count: u32) bool { return self.conversation(thread).len == count;}
/// The user has seen thread `thread`'s messages: shown again, they fold/// as read ones do.fn seen(self: *Reader, thread: []const u8) void { const c = self.conversations.getPtr(thread) orelse return; for (c.messages) |*m| m.unread = false;}
/// A thread's newest message, once read.pub fn newest(self: *const Reader, thread: []const u8) ?*const Mailbox.Message { const list = self.conversation(thread); return if (list.len > 0) &list[list.len - 1] else null;}
/// A thread's messages, oldest first, once read; none until then.pub fn conversation(self: *const Reader, thread: []const u8) []const Mailbox.Message { const c = self.conversations.getPtr(thread) orelse return &.{}; return c.messages;}
/// The `item`th file of a thread's messages that their bodies do not/// show, counting from 0, once they are read.pub fn file(self: *const Reader, thread: []const u8, item: u32) ?File { const c = self.conversations.getPtr(thread) orelse return null; var n: u32 = 0; for (c.messages) |*m| { for (m.attachments) |*a| { if (!listed(a)) continue; if (n == item) return .{ .message = m, .attachment = a }; n += 1; } } return null;}
/// Saves a thread's `item`th file (as `file` counts) to the downloads/// directory and opens it, once read from Gmail if need be.pub fn open(self: *Reader, thread: []const u8, item: u32) !void { const f = self.file(thread, item) orelse return; const a = f.attachment; if (a.id.len == 0) return self.saveAndOpen(a.name, a.data); const fetcher = self.fetcher orelse return; const read = fetcher.attachment orelse return self.tell("Cannot read {s} without Gmail", .{a.name}); const want = try self.wanted(.{ .open = a.name }); self.tell("Reading {s}…", .{a.name}); read(fetcher.context, f.message.id, a.id, want);}
/// How many of `m`'s files a forward of it takes along.pub fn fileCount(m: *const Mailbox.Message) u32 { var n: u32 = 0; for (m.attachments) |*a| n += @intFromBool(listed(a)); return n;}
/// Reads `m`'s files for a forward of it, the draft `draft` counts: each/// comes to the host's `attach`, those `m` holds at once.pub fn forwardFiles(self: *Reader, m: *const Mailbox.Message, draft: u32) !void { const fetcher = self.fetcher; const read = if (fetcher) |f| f.attachment else null; for (m.attachments) |*a| { if (!listed(a)) continue; if (a.id.len == 0 or read == null) { self.host.attach(self.host.context, draft, a.name, a.mime, if (a.id.len == 0) a.data else null); continue; } const want = try self.wanted(.{ .forward = .{ .draft = draft, .name = a.name, .mime = a.mime } }); read.?(fetcher.?.context, m.id, a.id, want); }}
/// The newest message of a thread that came from a mailing list, once/// read.pub fn fromList(self: *const Reader, thread: []const u8) ?*const Mailbox.Message { return self.newestWhere(thread, struct { fn has(m: *const Mailbox.Message) bool { return m.unsubscribe.len > 0 and !m.sent; } }.has);}
/// The newest message of a thread whose markup offers a page.pub fn withAction(self: *const Reader, thread: []const u8) ?*const Mailbox.Message { return self.newestWhere(thread, struct { fn has(m: *const Mailbox.Message) bool { return m.action != null; } }.has);}
/// The newest message of a thread that invites to an event.pub fn withInvite(self: *const Reader, thread: []const u8) ?*const Mailbox.Message { return self.newestWhere(thread, struct { fn has(m: *const Mailbox.Message) bool { return m.invite != null; } }.has);}
/// Opens the page a thread's newest offer leads to, in the browser.pub fn followAction(self: *Reader, thread: []const u8) void { const m = self.withAction(thread) orelse return; self.spawnOpener(m.action.?.url);}
/// Hands a thread's newest invitation to the calendar app, as a file.pub fn addEvent(self: *Reader, thread: []const u8) void { const m = self.withInvite(thread) orelse return; self.saveAndOpen("invite.ics", m.invite.?.ics);}
fn newestWhere( self: *const Reader, thread: []const u8, comptime has: fn (*const Mailbox.Message) bool,) ?*const Mailbox.Message { const c = self.conversations.getPtr(thread) orelse return null; var i = c.messages.len; while (i > 0) { i -= 1; if (has(&c.messages[i])) return &c.messages[i]; } return null;}
/// Leaves the mailing list a thread came from, the way its sender/// prefers: in one click, by a message the user sends, or on its page.pub fn unsubscribe(self: *Reader, thread: []const u8) !void { self.armed = false; const m = self.fromList(thread) orelse return; var arena_state: ArenaAllocator = .init(self.gpa); defer arena_state.deinit(); const way = try unsubscribeWay(arena_state.allocator(), m) orelse return self.tell("{s} gives no way to unsubscribe that parcel can use", .{m.from}); switch (way) { .one_click => |url| { const fetcher = self.fetcher orelse return; const post = fetcher.web orelse return self.tell("Cannot unsubscribe without Gmail", .{}); const want = try self.wanted(.{ .unsubscribe = m.from }); self.tell("Unsubscribing from {s}…", .{m.from}); post(fetcher.context, url, "List-Unsubscribe=One-Click", want); }, .mail => |mail| self.host.compose(self.host.context, mail.to, mail.subject, mail.body), .page => |url| { self.spawnOpener(url); self.tell("Opened {s}’s page to unsubscribe", .{m.from}); }, }}
/// Shows the open thread's images from the web (i).pub fn showImages(self: *Reader) void { if (self.images) return; self.images = true; // The documents shown load theirs where they are; those made later // load them from the start. for (self.parts.items) |p| if (p.doc != .null) { siv.docviewLoadImages(p.doc) catch |err| log.err("loading images: {t}", .{err}); };}
/// Shows the messages in their own colors, or made dark again (v).pub fn toggleOriginal(self: *Reader) void { self.original = !self.original; // Only the colors change: each document keeps its place and quotes. for (self.parts.items) |p| if (p.doc != .null) { siv.docviewSetFlags(p.doc, self.docFlags()) catch |err| log.err("changing colors: {t}", .{err}); };}
/// How a message's document shows, as the user has it now.fn docFlags(self: *const Reader) siv.DocviewFlags { return .{ .untrusted = true, .collapse_quotes = true, .remote_images = self.images, // Mail in light colors of its own is made dark in a dark theme, // as Outlook does, unless the user asks for it as sent. .convert_colors = !self.original, };}
/// Unfolds every message, or folds them back (x).pub fn toggleAll(self: *Reader) void { self.all = !self.all;}
/// Scrolls by `lines` of siv's lines (j, k), as an arrow key scrolls,/// and on while `keycode` is held; which always takes the key.pub fn step(self: *Reader, lines: f32, keycode: u32) bool { self.scrollBy(.line, lines, keycode); return true;}
/// Scrolls by `pages` screens (Space, Shift+Space), and on while/// `keycode` is held.pub fn page(self: *Reader, pages: f32, keycode: u32) void { self.scrollBy(.page, pages, keycode);}
/// Asks for a thread's messages once the selection rests on it.fn ask(self: *Reader, thread: []const u8) !void { if (self.fetcher == null or self.asked.contains(thread)) return; if (self.want) |w| if (mem.eql(u8, w, thread)) return; self.want = try self.name(thread); if (self.timer) |t| siv.timerCancel(t); self.timer = try siv.timerStart(siv.windowGetApp(self.window), settle_ms, onSettled, null, self);}
fn onSettled(user_data: ?*anyopaque) callconv(.c) void { const self: *Reader = @ptrCast(@alignCast(user_data)); self.timer = null; const thread = self.want orelse return; self.want = null; // Only the thread still open is worth its quota. if (!mem.eql(u8, thread, self.shown)) return; self.asked.put(self.gpa, thread, {}) catch |err| return log.err("asking for messages: {t}", .{err}); const f = self.fetcher.?; f.fetch(f.context, thread);}
/// A copy of a thread id that lives as long as the window, made once.fn name(self: *Reader, thread: []const u8) ![]const u8 { if (self.conversations.getKey(thread)) |k| return k; if (self.asked.getKey(thread)) |k| return k; return self.arena_state.allocator().dupe(u8, thread);}
/// Remakes what changed in the shown thread `c`: the messages whose/// folding changed, those that came, the images bar, the unsubscribe/// button. The rest keep their nodes.fn update(self: *Reader, txn: siv.Txn, c: *const Conversation, zone: *const zeit.TimeZone, now: i64) !void { if (self.parts.items.len == 0) return; if (self.images and self.bar != .null) { siv.remove(txn, self.bar); self.bar = .null; } try self.unfolded.resize(self.gpa, c.messages.len, false); // A message made again or added may take the button from another. const leave = self.leave; var leave_kept = true; for (c.messages, 0..) |*m, n| { const i: u32 = @intCast(n); if (n == self.parts.items.len) { try self.parts.append(self.gpa, try self.renderMessage(txn, m, i, c.messages.len, zone, now)); continue; } const old = &self.parts.items[n]; if (old.whole == self.isWhole(m, i, c.messages.len)) { self.showPhoto(txn, old, m.from); continue; } if (leave != .null and n == self.leave_at) leave_kept = false; const next = siv.nodeGetRelated(old.node, .next); siv.remove(txn, old.node); const made = try self.renderMessage(txn, m, i, c.messages.len, zone, now); siv.move(txn, made.node, self.column, next); self.parts.items[n] = made; } if (leave != .null and leave_kept and self.leave != leave) siv.remove(txn, leave); // Its message folded, and the button went with it. if (!leave_kept and self.leave == leave) self.leave = .null; if (self.leave != .null and self.leave_armed != self.armed) { const m = self.fromList(self.shown) orelse return; const old = self.leave; const head = siv.nodeGetRelated(old, .parent); const next = siv.nodeGetRelated(old, .next); siv.remove(txn, old); try self.renderUnsubscribe(txn, head, m); siv.move(txn, self.leave, head, next); }}
/// Whether message `i` of `count` shows whole: the newest, one unread/// when read, and those unfolded.fn isWhole(self: *const Reader, m: *const Mailbox.Message, i: u32, count: usize) bool { return self.all or i + 1 == count or m.unread or self.unfolded.isSet(i);}
/// Message `i` of `count` in a node of its own, at the column's end.fn renderMessage( self: *Reader, txn: siv.Txn, m: *const Mailbox.Message, i: u32, count: usize, zone: *const zeit.TimeZone, now: i64,) !Part { const whole = self.isWhole(m, i, count); const node = siv.flex(txn, self.column, .{ .axis = .column, .align_items = .stretch }); if (i > 0) { const rule = siv.box(txn, node); siv.setLayout(txn, rule, .{ .height = kit.px(1) }); siv.setVisual(txn, rule, .{ .background_token = .separator }); } const box = siv.flex(txn, node, .{ .axis = .column, .gap = 10, .align_items = .stretch }); siv.setLayout(txn, box, .{ .padding = .{ .top = if (whole) 14 else 8, .bottom = if (whole) 14 else 8 } }); const head = siv.flex(txn, box, .{ .gap = 10, .align_items = .center }); const diameter: f32 = if (whole) avatar_size else folded_avatar_size; var made: Part = .{ .node = node, .whole = whole, .doc = .null, .face = siv.box(txn, head), .photo = false, .hash = if (m.address.len > 0) Avatars.hash(m.address) else null, }; siv.setLayout(txn, made.face, .{ .width = kit.px(diameter), .height = kit.px(diameter), .shrink = 0 }); const round = diameter / 2; siv.setVisual(txn, made.face, .{ .radius = .{ .top_start = round, .top_end = round, .bottom_end = round, .bottom_start = round }, .flags = .{ .clip = true }, }); self.showPhoto(txn, &made, m.from); if (!made.photo) _ = siv.avatar(txn, made.face, .{ .name = m.from, .diameter = diameter }); const from = kit.text(txn, head, m.from, .{ .weight = if (whole) .bold else .regular, .color_token = if (m.sent) .muted_fg else .accent, .flags = kit.one_line, }); // A folded line keeps the name whole, up to a point, and the age; // the snippet takes what is left. siv.setLayout(txn, from, .{ .shrink = 0, .max_width = 220 }); if (!whole) { const snippet = kit.text(txn, head, m.snippet, .{ .color_token = .muted_fg, .flags = kit.one_line }); siv.setLayout(txn, snippet, .{ .grow = 1, .shrink = 1, .basis = kit.px(0) }); } else { const gap = siv.box(txn, head); siv.setLayout(txn, gap, .{ .grow = 1 }); } if (whole and self.fromList(self.shown) == m) { try self.renderUnsubscribe(txn, head, m); self.leave_at = i; } var age_buf: [64]u8 = undefined; const when = kit.age(&age_buf, siv.windowGetApp(self.window), zone, now, m.date); const age = kit.text(txn, head, when, .{ .size_role = .caption, .color_token = .muted_fg }); siv.setLayout(txn, age, .{ .shrink = 0 }); if (!whole) { try kit.clickable(Reader, u32, unfold, self.nodes.allocator(), txn, box, self, i, m.from); return made; } // What the message says spans the card, from the avatar's edge. const said = siv.flex(txn, box, .{ .axis = .column, .gap = 10, .align_items = .stretch }); made.doc = try self.renderSaid(txn, said, m, i); return made;}
/// Shows the photo of `sender` in `shown`'s avatar, in place of the/// initials, once there is one; asks the web for it where images may/// load, as a sender cannot learn of it but Gravatar can.fn showPhoto(self: *Reader, txn: siv.Txn, shown: *Part, sender: []const u8) void { if (shown.photo) return; const h = shown.hash orelse return; const photo = self.avatars.image(h) orelse { if (self.images and self.avatars.wanted(h)) self.askPhoto(h); return; }; const initials = siv.nodeGetRelated(shown.face, .first_child); if (initials != .null) siv.remove(txn, initials); const view = siv.imageView(txn, shown.face, .{ .image = photo, .fit = .cover }); siv.setLayout(txn, view, .{ .width = kit.whole, .height = kit.whole }); var label_buf: [128]u8 = undefined; const label = mem.print(&label_buf, "Photo of {s}", .{sender}) catch sender; siv.setSemantics(txn, view, .{ .label = label, .role = .image }); shown.photo = true;}
fn askPhoto(self: *Reader, h: Avatars.Hash) void { const fetcher = self.fetcher orelse return; const get = fetcher.web orelse return; var url_buf: [128]u8 = undefined; const url = self.avatars.ask(h, &url_buf) catch return; const want = self.wanted(.{ .avatar = h }) catch return; get(fetcher.context, url, null, want);}
/// A message shown whole: its event, its action, its body, its files, and/// the buttons that answer it. Returns its document view.fn renderSaid(self: *Reader, txn: siv.Txn, box: siv.Node, m: *const Mailbox.Message, i: u32) !siv.Node { if (m.invite) |*event| try self.renderInvite(txn, box, event, i); if (m.action) |act| { // In a row of its own, so it is as wide as its label. const row = siv.flex(txn, box, .{}); const go: siv.ButtonDesc.Options = .{ .label = act.name, .style = .primary }; _ = try kit.button(Reader, u32, actionClicked, self.nodes.allocator(), txn, row, go, act.name, self, i); } const nodes = self.nodes.allocator(); const doc = try nodes.create(Document); doc.* = .{ .reader = self, .message = m }; // The mail itself sits further in than the lines about it, at each side. const body = siv.flex(txn, box, .{ .axis = .column, .align_items = .stretch }); siv.setLayout(txn, body, .{ .padding = .{ .start = inset, .end = inset } }); const view = siv.docview(txn, body, .{ .source = m.html, .user_css = css, // Mail that asks for the system's face (-apple-system and the like) // means a sans-serif one, not the app's monospace. .system_font = "sans-serif", .flags = self.docFlags(), .load = onLoad, .on_event = onDocument, .user_data = doc, }); try self.renderFiles(txn, box, m); try self.renderReplies(txn, box, m, i); return view;}
/// Reply, Reply all, and Forward under message `i`, as Gmail puts them,/// so any message can be answered; r, a, and f answer the newest.fn renderReplies(self: *Reader, txn: siv.Txn, box: siv.Node, m: *const Mailbox.Message, i: u32) !void { const nodes = self.nodes.allocator(); const row = siv.flex(txn, box, .{ .gap = 8 }); const reply: siv.ButtonDesc.Options = .{ .label = "Reply", .icon = "mail-reply-sender-symbolic" }; const to_one = try nodes.print("Reply to {s}", .{m.from}); const one: Answer = .{ .message = i, .kind = .reply }; _ = try kit.button(Reader, Answer, replyClicked, nodes, txn, row, reply, to_one, self, one); const all: siv.ButtonDesc.Options = .{ .label = "Reply all", .icon = "mail-reply-all-symbolic" }; const to_all = try nodes.print("Reply all to {s}", .{m.from}); const everyone: Answer = .{ .message = i, .kind = .reply_all }; _ = try kit.button(Reader, Answer, replyClicked, nodes, txn, row, all, to_all, self, everyone); const forward: siv.ButtonDesc.Options = .{ .label = "Forward", .icon = "mail-forward-symbolic" }; const forward_name = try nodes.print("Forward {s}’s message", .{m.from}); const onward: Answer = .{ .message = i, .kind = .forward }; _ = try kit.button(Reader, Answer, replyClicked, nodes, txn, row, forward, forward_name, self, onward);}
/// The button that leaves the list `m` came from, which asks to be/// clicked again first.fn renderUnsubscribe(self: *Reader, txn: siv.Txn, head: siv.Node, m: *const Mailbox.Message) !void { const nodes = self.nodes.allocator(); const label = if (self.armed) try nodes.print("Unsubscribe from {s}? Click again", .{m.from}) else "Unsubscribe"; const desc: siv.ButtonDesc.Options = .{ .label = label, .style = if (self.armed) .destructive else .default }; self.leave = try kit.button(Reader, u32, unsubscribeClicked, nodes, txn, head, desc, label, self, 0); self.leave_armed = self.armed; siv.setLayout(txn, self.leave, .{ .shrink = 1 });}
/// The event a message invites to, and a button that adds it to the/// calendar.fn renderInvite(self: *Reader, txn: siv.Txn, box: siv.Node, event: *const Mailbox.Invite, i: u32) !void { const card = siv.flex(txn, box, .{ .gap = 12, .align_items = .center }); siv.setLayout(txn, card, .{ .padding = kit.edges(12) }); siv.setVisual(txn, card, .{ .background_token = .control_bg, .border_token = .border, .border_width = 1, .radius = kit.corners(kit.card_radius), }); _ = siv.imageView(txn, card, .{ .icon = "x-office-calendar-symbolic", .icon_size = 28, .color_token = .accent }); const lines = siv.flex(txn, card, .{ .axis = .column, .gap = 2 }); siv.setLayout(txn, lines, .{ .grow = 1, .shrink = 1, .basis = kit.px(0) }); const nodes = self.nodes.allocator(); const title = if (event.cancelled) try nodes.print("Cancelled: {s}", .{event.summary}) else event.summary; _ = kit.text(txn, lines, title, .{ .weight = .semibold, .color_token = .view_fg }); const quiet: siv.TextStyleDesc.Options = .{ .size_role = .caption, .color_token = .muted_fg }; if (event.when.len > 0) _ = kit.text(txn, lines, event.when, .{ .size_role = .caption, .color_token = .view_fg }); if (event.location.len > 0) _ = kit.text(txn, lines, event.location, quiet); if (event.organizer.len > 0) _ = kit.text(txn, lines, try nodes.print("From {s}", .{event.organizer}), quiet); if (event.cancelled) return; const add: siv.ButtonDesc.Options = .{ .label = "Add to calendar" }; _ = try kit.button(Reader, u32, inviteClicked, nodes, txn, card, add, "Add to calendar", self, i);}
/// The line that says images were blocked, hidden until one is.fn renderBar(self: *Reader, txn: siv.Txn) !void { self.bar = .null; if (self.images) return; self.bar = siv.flex(txn, self.column, .{ .gap = 8, .align_items = .center }); siv.setLayout(txn, self.bar, .{ .padding = .{ .start = 12, .end = 12, .top = 8, .bottom = 8 } }); siv.setVisual(txn, self.bar, .{ .background_token = .control_bg, .radius = kit.corners(4) }); const note = kit.text(txn, self.bar, "Images from the web are hidden, so senders cannot tell you read this.", .{ .size_role = .caption, .color_token = .muted_fg, }); siv.setLayout(txn, note, .{ .grow = 1, .shrink = 1 }); _ = kit.keycap(txn, self.bar, "i"); _ = kit.text(txn, self.bar, "show images", .{ .size_role = .caption, .color_token = .control_fg }); try kit.clickable(Reader, u32, imagesClicked, self.nodes.allocator(), txn, self.bar, self, 0, "Show images"); siv.setHidden(txn, self.bar, true);}
/// The message's files, each a button that opens it.fn renderFiles(self: *Reader, txn: siv.Txn, box: siv.Node, m: *const Mailbox.Message) !void { const c = self.conversations.getPtr(self.shown) orelse return; // Where the message's files start in the thread's count. var item: u32 = 0; for (c.messages) |*other| { if (other == m) break; for (other.attachments) |*a| item += @intFromBool(listed(a)); } var row: siv.Node = .null; for (m.attachments) |*a| { if (!listed(a)) continue; if (row == .null) row = siv.flex(txn, box, .{ .gap = 6, .flags = .{ .wrap = true }, .align_items = .center }); const nodes = self.nodes.allocator(); const desc: siv.ButtonDesc.Options = .{ .label = a.name, .icon = "mail-attachment-symbolic" }; const label = try nodes.print("Open {s}", .{a.name}); _ = try kit.button(Reader, u32, openClicked, nodes, txn, row, desc, label, self, item); item += 1; }}
fn unfold(self: *Reader, i: u32, _: u32) anyerror!void { self.unfolded.set(i); self.host.redraw(self.host.context);}
/// Which message a reply button answers, and to whom.const Answer = struct { message: u32, kind: Compose.Kind,};
fn replyClicked(self: *Reader, answer: Answer, _: u32) anyerror!void { self.host.reply(self.host.context, answer.message, answer.kind);}
fn imagesClicked(self: *Reader, _: u32, _: u32) anyerror!void { self.showImages(); self.host.redraw(self.host.context);}
/// The first click arms the button, the second unsubscribes.fn unsubscribeClicked(self: *Reader, _: u32, _: u32) anyerror!void { if (self.armed) { try self.unsubscribe(self.shown); } else { self.armed = true; } self.host.redraw(self.host.context);}
/// How to leave `m`'s list, from its List-Unsubscribe: one click where/// it offers it, else a message, else a page; null when it gives none of/// these. What a mailto: link names is decoded into `arena`.fn unsubscribeWay(arena: Allocator, m: *const Mailbox.Message) !?Unsubscribe { var mail: ?[]const u8 = null; var site: ?[]const u8 = null; var it = mem.tokenizeScalar(u8, m.unsubscribe, ','); while (it.next()) |entry| { const url = mem.trim(u8, entry, " \t\r\n<>"); if (ascii.startsWithIgnoreCase(url, "https:")) { if (m.one_click) return .{ .one_click = url }; if (site == null) site = url; } else if (ascii.startsWithIgnoreCase(url, "http:")) { if (site == null) site = url; } else if (ascii.startsWithIgnoreCase(url, "mailto:")) { if (mail == null) mail = url["mailto:".len..]; } } if (mail) |link| { const q = mem.findScalar(u8, link, '?') orelse link.len; var subject: []const u8 = "unsubscribe"; var body: []const u8 = ""; var fields = mem.tokenizeScalar(u8, if (q < link.len) link[q + 1 ..] else "", '&'); while (fields.next()) |field| { const eq = mem.findScalar(u8, field, '=') orelse continue; const value = try decoded(arena, field[eq + 1 ..]); if (ascii.eqlIgnoreCase(field[0..eq], "subject")) subject = value; if (ascii.eqlIgnoreCase(field[0..eq], "body")) body = value; } return .{ .mail = .{ .to = try decoded(arena, link[0..q]), .subject = subject, .body = body } }; } return if (site) |url| .{ .page = url } else null;}
fn decoded(arena: Allocator, text: []const u8) ![]const u8 { return std.Uri.percentDecodeInPlace(try arena.dupe(u8, text));}
fn actionClicked(self: *Reader, i: u32, _: u32) anyerror!void { const m = self.message(i) orelse return; self.spawnOpener((m.action orelse return).url);}
fn inviteClicked(self: *Reader, i: u32, _: u32) anyerror!void { const m = self.message(i) orelse return; self.saveAndOpen("invite.ics", (m.invite orelse return).ics);}
/// The shown thread's `i`th message.fn message(self: *const Reader, i: u32) ?*const Mailbox.Message { const c = self.conversations.getPtr(self.shown) orelse return null; return if (i < c.messages.len) &c.messages[i] else null;}
fn openClicked(self: *Reader, item: u32, _: u32) anyerror!void { try self.open(self.shown, item);}
/// Whether a file is listed under its message: the body shows it/// otherwise.fn listed(a: *const Mailbox.Attachment) bool { return a.name.len > 0 and !a.inline_shown;}
/// Keeps track of a file being read; `receive` lets it go.fn wanted(self: *Reader, what: @FieldType(Want, "what")) !*Want { try self.wants.ensureUnusedCapacity(self.gpa, 1); const want = try self.gpa.create(Want); errdefer self.gpa.destroy(want); want.* = .{ .reader = self, .what = what }; switch (want.what) { .supply, .avatar => {}, inline .open, .unsubscribe => |*text| text.* = try self.gpa.dupe(u8, text.*), .forward => |*f| { f.name = try self.gpa.dupe(u8, f.name); errdefer self.gpa.free(f.name); f.mime = try self.gpa.dupe(u8, f.mime); }, } self.wants.appendAssumeCapacity(want); return want;}
fn free(self: *Reader, want: *Want) void { switch (want.what) { .supply, .avatar => {}, inline .open, .unsubscribe => |text| self.gpa.free(text), .forward => |f| { self.gpa.free(f.name); self.gpa.free(f.mime); }, } self.gpa.destroy(want);}
/// What a document of a message is shown with: the message, for its/// parts.const Document = struct { reader: *Reader, message: *const Mailbox.Message,};
/// Answers a document's request for an image or a style sheet: a part/// of its message (cid:), or one from the web, which the document asks/// for only once images show.fn onLoad( user_data: ?*anyopaque, node: siv.Node, request: u32, url_ptr: ?[*]const u8, url_len: usize, _: siv.ResourceKind,) callconv(.c) void { const doc: *Document = @ptrCast(@alignCast(user_data)); const url = if (url_ptr) |p| p[0..url_len] else ""; doc.reader.load(doc.message, node, request, url) catch |err| { log.debug("{s}: {t}", .{ url, err }); siv.docviewSupply(node, request, "") catch |e| log.debug("supplying a document: {t}", .{e}); };}
fn load(self: *Reader, m: *const Mailbox.Message, node: siv.Node, request: u32, url: []const u8) !void { const want: @FieldType(Want, "what") = .{ .supply = .{ .node = node, .request = request } }; const scheme = "cid:"; if (ascii.startsWithIgnoreCase(url, "https:") or ascii.startsWithIgnoreCase(url, "http:")) { const fetcher = self.fetcher orelse return error.Unavailable; const get = fetcher.web orelse return error.Unavailable; return get(fetcher.context, url, null, try self.wanted(want)); } if (!ascii.startsWithIgnoreCase(url, scheme)) return error.Unsupported; const a = part(m, url[scheme.len..]) orelse return error.NotFound; if (a.id.len == 0) return siv.docviewSupply(node, request, a.data); const fetcher = self.fetcher orelse return error.Unavailable; const read = fetcher.attachment orelse return error.Unavailable; read(fetcher.context, m.id, a.id, try self.wanted(want));}
/// The part of `m` with Content-ID `cid`, as a cid: URL writes it.fn part(m: *const Mailbox.Message, cid: []const u8) ?*const Mailbox.Attachment { var buf: [256]u8 = undefined; if (cid.len == 0 or cid.len > buf.len) return null; @memcpy(buf[0..cid.len], cid); const id = std.Uri.percentDecodeInPlace(buf[0..cid.len]); for (m.attachments) |*a| { if (ascii.eqlIgnoreCase(a.content_id, id)) return a; } return null;}
/// Writes a file to the downloads directory and opens it with the/// desktop's application for it.fn saveAndOpen(self: *Reader, file_name: []const u8, bytes: []const u8) void { if (self.downloads.len == 0) return self.tell("Nowhere to save {s}: HOME is not set", .{file_name}); var dir = Io.Dir.cwd().createDirPathOpen(self.io, self.downloads, .{}) catch |err| { log.err("{s}: {t}", .{ self.downloads, err }); return self.tell("Could not save {s}", .{file_name}); }; defer dir.close(self.io); var name_buf: [160]u8 = undefined; const saved = save(self.io, dir, file_name, bytes, &name_buf) catch |err| { log.err("saving {s}: {t}", .{ file_name, err }); return self.tell("Could not save {s}", .{file_name}); }; var path_buf: [Io.Dir.max_path_bytes]u8 = undefined; self.spawnOpener(mem.print(&path_buf, "{s}/{s}", .{ self.downloads, saved }) catch return); self.tell("Saved {s} in {s}", .{ saved, self.downloads });}
/// Opens a file or a URL with the desktop's application for it.fn spawnOpener(self: *Reader, target: []const u8) void { _ = std.process.spawn(self.io, .{ .argv = &.{ self.opener, target }, .stdin = .ignore, .stdout = .ignore, .stderr = .ignore, }) catch |err| log.warn("{s}: {t}", .{ self.opener, err });}
/// Writes `bytes` to a new file in `dir` named for `name`, made safe, or/// "plan (1).pdf" when "plan.pdf" is taken, and so on. Returns the name/// used, in `buf`.fn save(io: Io, dir: Io.Dir, file_name: []const u8, bytes: []const u8, buf: *[160]u8) ![]const u8 { var safe_buf: [128]u8 = undefined; const safe = safeName(file_name, &safe_buf); const dot = mem.findScalarLast(u8, safe, '.') orelse safe.len; var n: u32 = 0; while (n < 100) : (n += 1) { const candidate = if (n == 0) safe else try mem.print(buf, "{s} ({d}){s}", .{ safe[0..dot], n, safe[dot..] }); const out = dir.createFile(io, candidate, .{ .exclusive = true }) catch |err| switch (err) { error.PathAlreadyExists => continue, else => return err, }; defer out.close(io); try out.writeStreamingAll(io, bytes); if (n == 0) @memcpy(buf[0..safe.len], safe); return buf[0..candidate.len]; } return error.PathAlreadyExists;}
/// A file name that stays in its directory: no slashes or control/// characters, no leading dot, and short enough for any file system.fn safeName(file_name: []const u8, buf: *[128]u8) []const u8 { var n: u32 = 0; for (mem.trimStart(u8, file_name, ". ")) |c| { if (n == buf.len) break; buf[n] = if (c == '/' or c < 0x20 or c == 0x7f) '_' else c; n += 1; } return if (n == 0) "attachment" else buf[0..n];}
fn tell(self: *Reader, comptime format: []const u8, args: anytype) void { var buf: [256]u8 = undefined; self.host.say(self.host.context, mem.print(&buf, format, args) catch return);}
/// Opens a clicked link in the browser, and shows the bar once a/// document blocks an image.fn onDocument(user_data: ?*anyopaque, _: siv.Node, event: *const siv.DocviewEvent) callconv(.c) void { const self = @as(*Document, @ptrCast(@alignCast(user_data))).reader; if (event.type == .blocked_images) return self.blocked(); if (event.type != .link) return; const url = (event.url orelse return)[0..event.url_len]; self.spawnOpener(url);}
fn blocked(self: *Reader) void { if (self.bar == .null) return; const txn = siv.txnBegin(self.window) catch return; siv.setHidden(txn, self.bar, false); siv.txnCommit(txn) catch |err| log.err("showing the images bar: {t}", .{err});}
/// Scrolls on from wherever the messages are, so a key adds to the wheel,/// a drag of the scrollbar, or a screen reader rather than undoing them./// A held key scrolls on from its press, not from the desktop's repeat;/// a click on a keycap, with no key, scrolls once.fn scrollBy(self: *Reader, unit: siv.ScrollUnit, y: f32, keycode: u32) void { if (self.scroll == .null) return; const txn = siv.txnBegin(self.window) catch return; if (keycode == 0) siv.scrollBy(txn, self.scroll, unit, 0, y) else siv.scrollWhileHeld(txn, self.scroll, keycode, unit, 0, y); siv.txnCommit(txn) catch |err| log.err("scrolling: {t}", .{err});}
/// Shows the messages at `scale`, as Ctrl+= and Ctrl+- ask.pub fn zoomTo(self: *Reader, scale: f32) void { self.zoom.set(scale); if (self.scroll == .null) return; const txn = siv.txnBegin(self.window) catch return; self.fit(txn); siv.txnCommit(txn) catch |err| log.err("zooming: {t}", .{err});}
/// The messages scrolled, zoomed, or were resized. A scale the scroll/// node has not said before is the user's own, by a pinch or Ctrl+wheel;/// one it said before may be from before Ctrl+= asked for another.fn onViewport(self: *Reader, event: *const siv.Event) !bool { if (event.zoom == self.zoom_reported) return false; self.zoom_reported = event.zoom; self.zoom_asked = event.zoom; self.zoom.set(event.zoom); return false;}
/// Zooms the scroll node to the shared scale; it reflows the messages.fn fit(self: *Reader, txn: siv.Txn) void { if (self.zoom_asked == self.zoom.scale) return; siv.scrollZoomTo(txn, self.scroll, self.zoom.scale); self.zoom_asked = self.zoom.scale;}
fn dupeAttachments(a: Allocator, list: []const Mailbox.Attachment) ![]const Mailbox.Attachment { const out = try a.alloc(Mailbox.Attachment, list.len); for (list, out) |f, *o| o.* = .{ .name = try a.dupe(u8, f.name), .mime = try a.dupe(u8, f.mime), .content_id = try a.dupe(u8, f.content_id), .id = try a.dupe(u8, f.id), .data = try a.dupe(u8, f.data), .inline_shown = f.inline_shown, }; return out;}
test "a saved file keeps to its directory and takes a free name" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const io = testing.io; var buf: [160]u8 = undefined; try testing.expectEqualStrings("plan.pdf", try save(io, tmp.dir, "plan.pdf", "1", &buf)); try testing.expectEqualStrings("plan (1).pdf", try save(io, tmp.dir, "plan.pdf", "2", &buf)); try testing.expectEqualStrings("plan (2).pdf", try save(io, tmp.dir, "plan.pdf", "3", &buf)); try testing.expectEqualStrings("_etc_passwd", try save(io, tmp.dir, "../etc/passwd", "4", &buf)); try testing.expectEqualStrings("attachment", try save(io, tmp.dir, "..", "5", &buf)); try testing.expectEqualStrings("notes", try save(io, tmp.dir, "notes", "6", &buf)); try testing.expectEqualStrings("notes (1)", try save(io, tmp.dir, "notes", "7", &buf)); var read_buf: [8]u8 = undefined; try testing.expectEqualStrings("2", try tmp.dir.readFile(io, "plan (1).pdf", &read_buf)); try testing.expectEqualStrings("1", try tmp.dir.readFile(io, "plan.pdf", &read_buf));}
test "a cid: URL finds its part, percent-encoded or not, ignoring case" { const files = [_]Mailbox.Attachment{ .{ .name = "", .mime = "image/png", .content_id = "logo@x", .id = "", .data = "1", .inline_shown = true }, .{ .name = "a.pdf", .mime = "application/pdf", .content_id = "", .id = "A", .data = "", .inline_shown = false }, }; var m: Mailbox.Message = undefined; m.attachments = &files; try testing.expectEqual(&files[0], part(&m, "logo%40x").?); try testing.expectEqual(&files[0], part(&m, "LOGO@x").?); try testing.expectEqual(null, part(&m, "logo")); try testing.expectEqual(null, part(&m, ""));}
test "unsubscribing takes one click where offered, else a message, else a page" { var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); var m: Mailbox.Message = undefined; m.unsubscribe = "<mailto:leave%2Bme@list.example?subject=Remove%20me&body=bye>, <https://list.example/u?x=1>"; m.one_click = true; try testing.expectEqualStrings("https://list.example/u?x=1", (try unsubscribeWay(arena, &m)).?.one_click);
m.one_click = false; const mail = (try unsubscribeWay(arena, &m)).?.mail; try testing.expectEqualStrings("leave+me@list.example", mail.to); try testing.expectEqualStrings("Remove me", mail.subject); try testing.expectEqualStrings("bye", mail.body);
// One click needs https; a plain address asks with the usual subject. m.unsubscribe = "<http://list.example/u>,<mailto:off@list.example>"; m.one_click = true; const plain = (try unsubscribeWay(arena, &m)).?.mail; try testing.expectEqualStrings("off@list.example", plain.to); try testing.expectEqualStrings("unsubscribe", plain.subject);
m.unsubscribe = "<http://list.example/u>"; try testing.expectEqualStrings("http://list.example/u", (try unsubscribeWay(arena, &m)).?.page); m.unsubscribe = "<ftp://list.example/u>"; try testing.expectEqual(null, try unsubscribeWay(arena, &m));}