//! Tells the desktop of new mail in an account's Inbox while the window //! is not looked at, and takes the notification back once that mail is //! read. A click on it opens the thread it names, or shows the window; //! its Archive button archives what it names. //! Each backend's session owns one and tells it what arrived; it knows //! nothing of where the mail comes from. const std = @import("std"); const siv = @import("siv"); const Desk = @import("Desk.zig"); const Mailbox = @import("Mailbox.zig"); const mem = std.mem; const unicode = std.unicode; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const ArrayList = std.ArrayList; const Io = std.Io; const log = std.log.scoped(.notifier); const testing = std.testing; const Notifier = @This(); gpa: Allocator, app: siv.App, desk: *Desk, /// What notifications add to their title; empty with one account. account: []const u8, /// The names of the account's notification and of its actions, which /// another account's must not replace; `arena` holds them. notice_id: []const u8, open_thread_name: []const u8, archive_name: []const u8, arena: ArenaAllocator, open_thread: siv.Action, archive: siv.Action, /// The threads the notification names, by their backend's ids, until /// they are read; `announced_arena` holds them. announced: []const []const u8, announced_arena: ArenaAllocator, /// Which account a session is, among those running. pub const Account = struct { /// What notifications add to their title; empty with one account. name: []const u8, index: u8, }; pub fn init(gpa: Allocator, app: siv.App, desk: *Desk, account: Account) !Notifier { var arena: ArenaAllocator = .init(gpa); errdefer arena.deinit(); const a = arena.allocator(); const open_thread_name = try a.print("open-thread-{d}", .{account.index + 1}); const options: siv.ActionDesc.Options = .{ .name = open_thread_name, .label = "Open a conversation" }; const open_thread = try siv.actionCreate(app, options, onOpenThread, null, desk); errdefer siv.actionDestroy(open_thread); const archive_name = try a.print("archive-{d}", .{account.index + 1}); const archive_options: siv.ActionDesc.Options = .{ .name = archive_name, .label = "Archive conversations" }; return .{ .gpa = gpa, .app = app, .desk = desk, .account = try a.dupe(u8, account.name), .notice_id = try a.print("new-mail-{d}", .{account.index + 1}), .open_thread_name = open_thread_name, .archive_name = archive_name, .open_thread = open_thread, .archive = try siv.actionCreate(app, archive_options, onArchive, null, desk), .arena = arena, .announced = &.{}, .announced_arena = .init(gpa), }; } pub fn deinit(self: *Notifier) void { siv.actionDestroy(self.open_thread); siv.actionDestroy(self.archive); self.announced_arena.deinit(); self.arena.deinit(); self.* = undefined; } /// Tells the desktop what came to the Inbox of `mail` since `before`. /// The first mail an account loads is not news; a caller does not /// announce it. pub fn announce(self: *Notifier, mail: *const Mailbox, before: *const Mailbox) void { var arrived: ArrayList(Mailbox.Id) = .empty; defer arrived.deinit(self.gpa); mail.arrivals(before, &arrived) catch |err| return log.warn("finding new mail: {t}", .{err}); self.tell(mail, &arrived); } /// Tells the desktop of `arrived`, newest first, but the thread the /// reader shows, unless the window has the user's attention. One /// conversation is named by its sender and subject; several are summed /// up in one notification. A newer notification replaces the last. pub fn tell(self: *Notifier, mail: *const Mailbox, arrived: *ArrayList(Mailbox.Id)) void { if (self.desk.opened()) |open| if (mail.findThread(open)) |t| { if (mem.findScalar(Mailbox.Id, arrived.items, t)) |i| _ = arrived.orderedRemove(i); }; if (arrived.items.len == 0) return; const states = siv.windowGetStates(self.desk.window) catch return; if (states.activated) return; var title_buf: [256]u8 = undefined; var body_buf: [512]u8 = undefined; const notice = describe(mail, arrived.items, self.account, &title_buf, &body_buf); var plain_buf: [body_buf.len * 3]u8 = undefined; const names = self.joinIds(mail, arrived.items) catch |err| return log.warn("telling of new mail: {t}", .{err}); defer self.gpa.free(names); const archive: siv.NotificationButton = .{ .label = if (arrived.items.len == 1) "Archive" else "Archive all", .label_len = if (arrived.items.len == 1) "Archive".len else "Archive all".len, .action = self.archive_name.ptr, .action_len = self.archive_name.len, .target = names.ptr, .target_len = names.len, }; siv.notificationSend(self.app, .{ .id = self.notice_id, .title = notice.title, .body = plain(&plain_buf, notice.body), // None: the desktop shows the app's own, from its desktop file. .default_action = self.open_thread_name, .default_target = if (arrived.items.len == 1) mail.get(arrived.items[0]).id else "", .buttons = &.{archive}, }) catch |err| return log.warn("telling of new mail: {t}", .{err}); self.remember(mail, arrived.items) catch |err| log.warn("keeping what the notification names: {t}", .{err}); } /// Takes the notification back once `mail` has every thread it names /// read, or out of the Inbox. pub fn withdrawRead(self: *Notifier, mail: *const Mailbox) void { if (self.announced.len == 0 or !allRead(mail, self.announced)) return; siv.notificationWithdraw(self.app, self.notice_id); _ = self.announced_arena.reset(.retain_capacity); self.announced = &.{}; } /// The ids of `arrived` as their backend calls them, for the Archive /// button's action: a comma never appears in Gmail's or JMAP's. fn joinIds(self: *const Notifier, mail: *const Mailbox, arrived: []const Mailbox.Id) Allocator.Error![]u8 { var out: ArrayList(u8) = .empty; errdefer out.deinit(self.gpa); for (arrived, 0..) |t, i| { if (i > 0) try out.append(self.gpa, ','); try out.appendSlice(self.gpa, mail.get(t).id); } return out.toOwnedSlice(self.gpa); } /// Keeps which threads the notification names, to take it back once /// they are read. fn remember(self: *Notifier, mail: *const Mailbox, arrived: []const Mailbox.Id) Allocator.Error!void { _ = self.announced_arena.reset(.retain_capacity); self.announced = &.{}; const a = self.announced_arena.allocator(); const ids = try a.alloc([]const u8, arrived.len); for (arrived, ids) |t, *id| id.* = try a.dupe(u8, mail.get(t).id); self.announced = ids; } const Notice = struct { title: []const u8, body: []const u8 }; /// What a notification of `arrived`, newest first, says, its title /// after `account` in brackets unless that is empty: one thread's newest /// sender and subject, and its snippet; or how many came, and the first /// three by sender and subject. Text past the buffers is cut short, which /// a notification would do anyway. fn describe( mail: *const Mailbox, arrived: []const Mailbox.Id, account: []const u8, title_buf: []u8, body_buf: []u8, ) Notice { const first = mail.get(arrived[0]); var body: Io.Writer = .fixed(body_buf); const shown = @min(arrived.len, 3); write: { if (arrived.len == 1) { body.writeAll(first.snippet) catch break :write; } else for (arrived[0..shown]) |id| { const t = mail.get(id); body.print("{s}: {s}\n", .{ sender(mail, t), t.subject }) catch break :write; } if (arrived.len > shown) body.print("and {d} more", .{arrived.len - shown}) catch break :write; } var title: Io.Writer = .fixed(title_buf); const ellipsis = "…"; title.buffer = title_buf[0 .. title_buf.len - ellipsis.len]; write: { if (account.len > 0) title.print("[{s}] ", .{account}) catch break :write; if (arrived.len > 1) { title.print("{d} new conversations", .{arrived.len}) catch break :write; } else { title.writeAll(sender(mail, first)) catch break :write; if (first.subject.len > 0) title.print(" - {s}", .{first.subject}) catch break :write; } return .{ .title = title.buffered(), .body = mem.trimEnd(u8, body.buffered(), "\n") }; } // Cut short: back off a split character, then mark the cut. var end = title.end; var lead = end; while (lead > 0 and end - lead < 4) { lead -= 1; if (title_buf[lead] & 0xc0 != 0x80) break; } if (lead < end and lead + (unicode.utf8ByteSequenceLength(title_buf[lead]) catch 1) > end) end = lead; @memcpy(title_buf[end..][0..ellipsis.len], ellipsis); return .{ .title = title_buf[0 .. end + ellipsis.len], .body = mem.trimEnd(u8, body.buffered(), "\n") }; } /// Who sent a thread's newest message, by name or else address; who /// wrote in it, as its backend lists them, when its messages were not /// read. fn sender(mail: *const Mailbox, t: *const Mailbox.Thread) []const u8 { if (t.notes.len == 0) return t.participants; const p = mail.person(t.notes[t.notes.len - 1].from); return if (p.name.len > 0) p.name else if (p.address.len > 0) p.address else t.participants; } /// Whether `mail` has each of `ids` read or out of the Inbox. A thread /// it lacks is not read: the copy may not show it yet. fn allRead(mail: *const Mailbox, ids: []const []const u8) bool { for (ids) |id| { const t = mail.findThread(id) orelse return false; if (mail.get(t).unread and mail.has(t, .inbox)) return false; } return true; } /// `text` with what markup would read as tags or entities in look-alikes. /// Brings `window` to the front, as a click on a notification asks, with /// the token the click passed on. Some notification daemons pass none; /// then the window asks the compositor for a token of its own, which /// compositors that check it grant only soon after the user's last input /// in the window. pub fn activate(window: siv.Window, token: []const u8) void { if (token.len > 0) { log.info("showing the window with the token passed on", .{}); siv.windowActivate(window, token) catch |err| log.warn("showing the window: {t}", .{err}); return; } log.info("no token was passed on; asking the compositor for one", .{}); const handle: usize = @intCast(@backingInt(window)); siv.windowRequestActivationToken(window, onToken, null, @ptrFromInt(handle)) catch |err| log.warn("asking for a token to show the window: {t}", .{err}); } fn onToken(user_data: ?*anyopaque, result: *const siv.PortalResult) callconv(.c) void { const window: siv.Window = @fromBackingInt(@intFromPtr(user_data)); const token = if (result.token) |p| p[0..result.token_len] else ""; if (token.len == 0) return log.warn("the compositor gave no token to show the window", .{}); log.info("showing the window with the compositor's token", .{}); siv.windowActivate(window, token) catch |err| log.warn("showing the window: {t}", .{err}); } /// The portal takes a notification's body as plain text, but its GTK /// backend hands it to org.freedesktop.Notifications as it is, and that /// reads it as markup where the server says body-markup, as most do: an /// address in angle brackets vanished. Escaping it instead would show /// "&" where the body is shown plain, as GNOME's is. fn plain(buf: []u8, text: []const u8) []const u8 { var out: Io.Writer = .fixed(buf); for (text) |c| { out.writeAll(switch (c) { '<' => "‹", '>' => "›", '&' => "&", else => &.{c}, }) catch break; } return out.buffered(); } fn onOpenThread(user_data: ?*anyopaque, event: *const siv.ActionEvent) callconv(.c) void { const desk: *Desk = @ptrCast(@alignCast(user_data)); activate(desk.window, if (event.activation_token) |p| p[0..event.activation_token_len] else ""); const thread = if (event.target) |p| p[0..event.target_len] else ""; if (thread.len > 0) desk.reveal(thread) catch |err| log.err("opening the conversation: {t}", .{err}); } /// Archives what the notification names, leaving the window where it is. fn onArchive(user_data: ?*anyopaque, event: *const siv.ActionEvent) callconv(.c) void { const desk: *Desk = @ptrCast(@alignCast(user_data)); const target = if (event.target) |p| p[0..event.target_len] else ""; var names: ArrayList([]const u8) = .empty; defer names.deinit(desk.gpa); var it = mem.splitScalar(u8, target, ','); while (it.next()) |name| { if (name.len > 0) names.append(desk.gpa, name) catch |err| return log.err("archiving: {t}", .{err}); } desk.archiveNamed(names.items) catch |err| log.err("archiving: {t}", .{err}); } test "a notification's text keeps no markup: tags and entities become look-alikes" { var buf: [128]u8 = undefined; try testing.expectEqualStrings( "Q&A with Maya ‹maya@example.com›: &amp; ‹b›", plain(&buf, "Q&A with Maya : & "), ); } test "one new thread is told by its newest sender and subject; several in one summary" { var mail: Mailbox = try .init(testing.allocator); defer mail.deinit(); const maya: Mailbox.Person = .{ .name = "Maya Lin", .address = "maya@example.com" }; const ravi: Mailbox.Person = .{ .name = "", .address = "ravi@example.com" }; const thread: Mailbox.NewThread = .{ .id = "", .participants = "Maya, Ravi", .subject = "", .snippet = "", .date = 100, .messages = 2, .labels = &.{.inbox}, .unread = true, .notes = &.{}, }; // Maya wrote, and Ravi, who gave no name, answered last. var replied = thread; replied.id = "replied"; replied.subject = "Lunch"; replied.snippet = "Noon works"; replied.notes = &.{ .{ .date = 50, .from = maya, .to = &.{}, .cc = &.{} }, .{ .date = 100, .from = ravi, .to = &.{}, .cc = &.{} }, }; const one = try mail.addThread(replied); var title_buf: [64]u8 = undefined; var body_buf: [256]u8 = undefined; var notice = describe(&mail, &.{one}, "", &title_buf, &body_buf); try testing.expectEqualStrings("ravi@example.com - Lunch", notice.title); try testing.expectEqualStrings("Noon works", notice.body); notice = describe(&mail, &.{one}, "Work", &title_buf, &body_buf); try testing.expectEqualStrings("[Work] ravi@example.com - Lunch", notice.title); // A title past its buffer ends in an ellipsis, not half a character. var short_buf: ["[Work] ravi@example.com - L".len + 1 + "…".len]u8 = undefined; mail.threads.items[one].subject = "Lüüunch"; notice = describe(&mail, &.{one}, "Work", &short_buf, &body_buf); try testing.expectEqualStrings("[Work] ravi@example.com - L…", notice.title); var ids: [4]Mailbox.Id = .{ one, 0, 0, 0 }; for (ids[1..], [_][]const u8{ "a", "b", "c" }) |*id, name| { var t = thread; t.id = name; t.subject = name; t.notes = &.{.{ .date = 100, .from = maya, .to = &.{}, .cc = &.{} }}; id.* = try mail.addThread(t); } notice = describe(&mail, &ids, "Work", &title_buf, &body_buf); try testing.expectEqualStrings("[Work] 4 new conversations", notice.title); try testing.expectEqualStrings("ravi@example.com: Lüüunch\nMaya Lin: a\nMaya Lin: b\nand 1 more", notice.body); } test "a notification stays until every thread it names is read or out of the Inbox" { var mail: Mailbox = try .init(testing.allocator); defer mail.deinit(); const thread: Mailbox.NewThread = .{ .id = "", .participants = "Ana", .subject = "", .snippet = "", .date = 100, .messages = 1, .labels = &.{.inbox}, .unread = true, .notes = &.{}, }; var t = thread; t.id = "a"; const a = try mail.addThread(t); t.id = "b"; const b = try mail.addThread(t); const named: []const []const u8 = &.{ "a", "b" }; try testing.expect(!allRead(&mail, named)); mail.setUnread(a, false); try testing.expect(!allRead(&mail, named)); // Archived unread, it is no longer new mail in the Inbox. mail.archive(b); try testing.expect(allRead(&mail, named)); // A copy that lacks one may not show it yet. try testing.expect(!allRead(&mail, &.{ "a", "gone" })); }