//! The accounts while the app runs: a desk each in the one window, one //! shown at a time. Each keeps its own mail, filters, selection, open //! thread, and draft while another shows. With more than one, the shown //! desk's header names its account and offers the others with their //! Inbox's unread count, marked while mail has come since the user last //! looked; Ctrl and a digit, a click there, or the palette shows one. //! An account added while the app runs comes last, and the samples' //! account, there while there is none, gives its place up to it. const std = @import("std"); const siv = @import("siv"); const zeit = @import("zeit"); const Desk = @import("Desk.zig"); const JmapSession = @import("JmapSession.zig"); const Mailbox = @import("Mailbox.zig"); const Session = @import("Session.zig"); const Settings = @import("Settings.zig"); const Images = @import("Images.zig"); const Zoom = @import("Zoom.zig"); const kit = @import("kit.zig"); const sample = @import("sample.zig"); const assert = std.debug.assert; const mem = std.mem; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const Io = std.Io; const testing = std.testing; const log = std.log.scoped(.accounts); const Accounts = @This(); gpa: Allocator, io: Io, window: siv.Window, /// Where every account is, so one added leaves the others in place, as /// their desks and sessions point at them. storage: *[Desk.max_accounts]Account, list: []Account, /// The accounts' names. names: ArenaAllocator, /// The node holding a slot per account. stack: siv.Node, /// The account whose desk shows. shown: u8, /// The state directory, which holds each account's. top: ?Settings, /// How large every account's reader shows mail, kept in `top`. zoom: Zoom, images: Images, pub const Account = struct { accounts: *Accounts, index: u8, name: []const u8, settings: ?Settings, mail: Mailbox, /// The node its desk fills, hidden with it: shown, though empty, it /// would take the pointer from the desk under it. slot: siv.Node, desk: ?*Desk, /// What runs it: Gmail's session or a JMAP server's, once signed in. session: ?*Session, jmap: ?*JmapSession, /// When the user last left its desk, in seconds since the epoch: /// Inbox mail since is news. left_at: i64, }; /// The accounts named, in that order, with no mail and no desk yet. Each /// keeps its settings in its own directory under `top`; an empty name is /// the one account there is, as with the samples, and keeps them in /// `top` itself. pub fn create(gpa: Allocator, io: Io, window: siv.Window, names: []const []const u8, top: ?Settings) !*Accounts { assert(names.len > 0); assert(names.len <= Desk.max_accounts); const self = try gpa.create(Accounts); errdefer gpa.destroy(self); const storage = try gpa.create([Desk.max_accounts]Account); errdefer gpa.destroy(storage); self.* = .{ .gpa = gpa, .io = io, .window = window, .storage = storage, .list = storage[0..0], .names = .init(gpa), .stack = .null, .top = top, .shown = 0, .zoom = .load(top), .images = .load(top), }; errdefer self.names.deinit(); for (names) |name| try self.append(name); return self; } /// Makes account `name` the last, with no mail and no desk yet. fn append(self: *Accounts, name: []const u8) !void { const i = self.list.len; const owned = try self.names.allocator().dupe(u8, name); self.storage[i] = .{ .accounts = self, .index = @intCast(i), .name = owned, .settings = if (name.len == 0) self.top else if (self.top) |t| t.account(name) else null, .mail = try .init(self.gpa), .slot = .null, .desk = null, .session = null, .jmap = null, .left_at = Io.Timestamp.now(self.io, .real).toSeconds(), }; self.list = self.storage[0 .. i + 1]; } /// Saves the zoom, and frees the desks and the mail. The sessions must /// be gone, and the window, whose handlers hold the desks. pub fn destroy(self: *Accounts) void { self.zoom.save(); for (self.list) |*a| { assert(a.session == null); assert(a.jmap == null); if (a.desk) |d| d.destroy(); a.mail.deinit(); if (a.name.len > 0) if (a.settings) |s| s.close(); } self.names.deinit(); self.gpa.destroy(self.storage); self.gpa.destroy(self); } /// Builds each account's desk on its mail, the first shown. The desks /// take `zones`, one each. pub fn build(self: *Accounts, zones: []const zeit.TimeZone) !void { const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); self.stack = siv.stack(txn, siv.windowGetRoot(self.window), .{}); siv.setLayout(txn, self.stack, .{ .width = kit.whole, .height = kit.whole }); for (self.list) |*a| { a.slot = siv.stack(txn, self.stack, .{}); siv.setLayout(txn, a.slot, .{ .width = kit.whole, .height = kit.whole }); } try siv.txnCommit(txn); for (self.list, zones) |*a, zone| try self.buildDesk(a, zone); try self.connect(); } fn buildDesk(self: *Accounts, a: *Account, zone: zeit.TimeZone) !void { const window = self.window; a.desk = try Desk.create(self.gpa, self.io, zone, window, a.slot, &a.mail, a.settings, &self.zoom, &self.images); } /// Has each desk switch to the others, when there are others, and shows /// the one `shown` names alone. fn connect(self: *Accounts) !void { if (self.list.len == 1) return; for (self.list) |*a| a.desk.?.switcher = .{ .context = a, .name = a.name, .index = a.index, .others = others, .show = show, .changed = changed, }; for (self.list) |*a| try a.desk.?.show(a.index == self.shown); // Only now: a desk focuses its list as it is made, which a hidden // node cannot take. const hide = try siv.txnBegin(self.window); errdefer siv.txnAbort(hide); for (self.list) |*a| siv.setHidden(hide, a.slot, a.index != self.shown); try siv.txnCommit(hide); } /// Adds account `name` last, with no mail, its desk under `over` (the Add /// account page), and shows it. The samples' account gives its place up; /// it has no session. pub fn add(self: *Accounts, name: []const u8, zone: zeit.TimeZone, over: siv.Node) !*Account { if (self.samples()) try self.dropSamples(); assert(self.list.len < Desk.max_accounts); try self.append(name); const a = &self.list[self.list.len - 1]; errdefer { a.mail.deinit(); if (a.settings) |s| s.close(); self.list = self.storage[0 .. self.list.len - 1]; } const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); a.slot = siv.stack(txn, self.stack, .{}); siv.move(txn, a.slot, self.stack, over); siv.setLayout(txn, a.slot, .{ .width = kit.whole, .height = kit.whole }); try siv.txnCommit(txn); try self.buildDesk(a, zone); self.shown = a.index; try self.connect(); for (self.list) |*other| if (other != a) try other.desk.?.refreshAccounts(); return a; } /// Whether the one account is the samples', which has no name. pub fn samples(self: *const Accounts) bool { return self.list.len == 1 and self.list[0].name.len == 0; } /// Takes the samples' desk and mail out of the window. fn dropSamples(self: *Accounts) !void { const a = &self.list[0]; const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); siv.remove(txn, a.slot); try siv.txnCommit(txn); // Its handlers went with its nodes. a.desk.?.destroy(); a.desk = null; a.mail.deinit(); self.list = self.storage[0..0]; self.shown = 0; } /// The account `index` names, if there is one. pub fn get(self: *Accounts, index: u8) ?*Account { return if (index < self.list.len) &self.list[index] else null; } fn others(context: *anyopaque, buf: []Desk.Other) []const Desk.Other { const account: *Account = @ptrCast(@alignCast(context)); var n: usize = 0; for (account.accounts.list) |*a| { if (a == account) continue; const desk = a.desk orelse continue; const count = inboxCount(desk.mail, a.left_at); buf[n] = .{ .index = a.index, .name = a.name, .unread = count.unread, .fresh = count.fresh }; n += 1; } return buf[0..n]; } const Count = struct { unread: u32, fresh: bool }; /// How many threads in `mail`'s Inbox are unread, and whether one of /// them came after `since`. fn inboxCount(mail: *const Mailbox, since: i64) Count { var count: Count = .{ .unread = 0, .fresh = false }; for (mail.threads.items, 0..) |t, i| { if (!t.unread or !mail.has(@intCast(i), .inbox)) continue; count.unread += 1; if (t.date > since) count.fresh = true; } return count; } fn show(context: *anyopaque, index: u8) void { const account: *Account = @ptrCast(@alignCast(context)); const self = account.accounts; if (index >= self.list.len or index == self.shown) return; const leaving = &self.list[self.shown]; leaving.left_at = Io.Timestamp.now(self.io, .real).toSeconds(); self.shown = index; const coming = &self.list[index]; if (siv.txnBegin(self.window)) |txn| { siv.setHidden(txn, leaving.slot, true); siv.setHidden(txn, coming.slot, false); siv.txnCommit(txn) catch |err| log.err("showing {s}: {t}", .{ coming.name, err }); } else |err| log.err("showing {s}: {t}", .{ coming.name, err }); leaving.desk.?.show(false) catch |err| log.err("hiding {s}: {t}", .{ leaving.name, err }); coming.desk.?.show(true) catch |err| log.err("showing {s}: {t}", .{ coming.name, err }); } /// Another account's mail changed: the shown desk counts it again. fn changed(context: *anyopaque) void { const account: *Account = @ptrCast(@alignCast(context)); const self = account.accounts; if (account.index == self.shown) return; const desk = self.list[self.shown].desk orelse return; desk.refreshAccounts() catch |err| log.err("counting {s}: {t}", .{ account.name, err }); } test "Ctrl and a digit shows another account's desk, which kept its own place; the header counts the others" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); const window = try siv.windowCreate(app, .{ .title = "Parcel", .width = 1200, .height = 760 }); defer siv.windowDestroy(window); const self = try create(testing.allocator, testing.io, window, &.{ "Personal", "Work" }, null); defer self.destroy(); for (self.list) |*a| try sample.load(&a.mail, 1_800_000_000); // Work's Inbox has one thread fewer unread than Personal's. const work = &self.list[1].mail; for (work.threads.items, 0..) |t, i| if (t.unread and work.has(@intCast(i), .inbox)) { work.setUnread(@intCast(i), false); break; }; const unread = inboxCount(&self.list[0].mail, 0).unread; try testing.expect(unread > 1); // An unread thread in the Inbox newer than the user's last look is news. try testing.expect(inboxCount(&self.list[0].mail, 0).fresh); try testing.expect(!inboxCount(&self.list[0].mail, 1_800_000_000).fresh); try self.build(&.{ zeit.utc, zeit.utc }); try testing.expect(try siv.testSettle(app, 5000)); var buf: [64]u8 = undefined; const work_button = try mem.print(&buf, "Work, {d} unread", .{unread - 1}); try testing.expect(try hasButton(window, work_button)); const personal = self.list[0].desk.?; try testing.expect(try personal.onKey(.char('j'))); try testing.expectEqual(1, personal.current.?); try siv.testKey(window, @fromBackingInt('2'), .{ .ctrl = true }, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(1, self.shown); const work_desk = self.list[1].desk.?; try testing.expect(!work_desk.hidden and personal.hidden); try testing.expectEqual(0, work_desk.current.?); var personal_buf: [64]u8 = undefined; const personal_button = try mem.print(&personal_buf, "Personal, {d} unread", .{unread}); try testing.expect(try hasButton(window, personal_button)); // Keys go to the shown desk alone. try siv.testKey(window, @fromBackingInt('j'), .{}, "j"); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(1, work_desk.current.?); try testing.expectEqual(1, personal.current.?); // A click on the other account in the header shows it too. var found = false; var node: siv.TestNode = .{}; try siv.testFind(window, .{ .role = .button, .name = personal_button }, &found, &node); try siv.testPointerMove(window, node.bounds.x + node.bounds.width / 2, node.bounds.y + node.bounds.height / 2); try siv.testPointerButton(window, .primary, true); try siv.testPointerButton(window, .primary, false); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(0, self.shown); try testing.expectEqual(1, personal.current.?); // The first desk takes clicks too, under the hidden one after it. try siv.testFind(window, .{ .role = .button, .name = work_button }, &found, &node); try testing.expect(found); try siv.testPointerMove(window, node.bounds.x + node.bounds.width / 2, node.bounds.y + node.bounds.height / 2); try siv.testPointerButton(window, .primary, true); try siv.testPointerButton(window, .primary, false); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(1, self.shown); } test "an account added takes the samples' place, and a second one gets the switcher" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); const window = try siv.windowCreate(app, .{ .title = "Parcel", .width = 1200, .height = 760 }); defer siv.windowDestroy(window); const self = try create(testing.allocator, testing.io, window, &.{""}, null); defer self.destroy(); try sample.load(&self.list[0].mail, 1_800_000_000); try self.build(&.{zeit.utc}); try testing.expect(self.samples()); const work = try self.add("Work", zeit.utc, .null); try testing.expect(try siv.testSettle(app, 5000)); try testing.expect(!self.samples()); try testing.expectEqual(1, self.list.len); try testing.expectEqualStrings("Work", work.name); try testing.expectEqual(0, work.index); // Its own mail, none yet, not the samples'. try testing.expectEqual(0, work.mail.threads.items.len); try testing.expectEqual(null, work.desk.?.switcher); const home = try self.add("Home", zeit.utc, .null); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(1, home.index); try testing.expectEqual(1, self.shown); try testing.expect(work.desk.?.hidden and !home.desk.?.hidden); // Work's desk keeps its place in storage as Home is added. try testing.expectEqual(work, &self.list[0]); try testing.expect(try hasButton(window, "Work, 0 unread")); try siv.testKey(window, @fromBackingInt('1'), .{ .ctrl = true }, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(0, self.shown); try testing.expect(try hasButton(window, "Home, 0 unread")); } fn hasButton(window: siv.Window, name: []const u8) !bool { var found = false; var node: siv.TestNode = .{}; try siv.testFind(window, .{ .role = .button, .name = name }, &found, &node); return found; }