╭ UserNotifications.zig ────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭ find file ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╮ ╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╮ │ │ │http│ ssh │ │ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╯ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╯
1 const std = @import("std"); 2 const evt = @import("../event.zig"); 3 const notify = @import("../notify.zig"); 4 const ui = @import("../ui.zig"); 5 const inp = @import("./input.zig"); 6 const xit = @import("xit"); 7 const xitui = xit.xitui; 8 const wgt = xitui.widget; 9 const layout = xitui.layout; 10 const Key = xitui.input.Key; 11 const Grid = xitui.grid.Grid; 12 const Focus = xitui.focus.Focus; 13 14 pub const page_size = 20; // how many notifs one window shows 15 16 // a notif as the list shows it 17 pub const Row = struct { 18 title: []const u8, 19 // the thread's url 20 url: []const u8, 21 // the thread's repo, as "owner:name" 22 identity: []const u8, 23 unread: bool, 24 }; 25 26 // the logged-in user's notifications window, empty when logged out 27 rows: []const Row, 28 start: usize, // the window start this page was built with, mirrored into the url 29 next_start: ?usize, // the `start` for the "next" row, or null when this is the last window 30 // the sub tab the route names 31 view: ui.RoutablePage.UserView, 32 ssh_keys: ui.UserKeys = .{}, 33 34 const Self = @This(); 35 36 // also refreshes the session's unread count 37 pub fn init(arena: *std.heap.ArenaAllocator, session: *ui.Session, tab: ui.RoutablePage.UserTab) !Self { 38 var result: Self = .{ .rows = &.{}, .start = tab.start, .next_start = null, .view = tab.view }; 39 // zero until read, so a logout leaves no stale count 40 session.data.unread_notifs = 0; 41 // local mode has no users 42 if (session.data.host_kind != .server) return result; 43 const user_id = session.userId() orelse return result; 44 result.ssh_keys = try ui.UserKeys.init(arena, session, &user_id, tab); 45 const io = session.io orelse return error.NoMoment; 46 const users_dir = session.users_dir orelse return error.NoMoment; 47 const gpa = arena.child_allocator; 48 const aa = arena.allocator(); 49 50 var user_repo = (try evt.openUserRepo(io, gpa, users_dir, &user_id)) orelse return result; 51 defer user_repo.deinit(io, gpa); 52 const moment = (try notify.latestMoment(&user_repo)) orelse return result; 53 session.data.unread_notifs = try notify.unreadCount(moment); 54 55 const items = try notify.readRecent(moment, arena, tab.start, page_size + 1); 56 if (items.len > page_size) result.next_start = tab.start + page_size; 57 const rows = try aa.alloc(Row, @min(items.len, page_size)); 58 for (rows, items[0..rows.len]) |*row, item| { 59 const identity = try aa.print("{s}:{s}", .{ item.notif.owner_name, item.notif.repo_name }); 60 const route = ui.RoutablePage.repoThreadCommentsRoute(item.notif.thread_kind, identity, &std.fmt.bytesToHex(item.notif.thread_id, .lower), 0) orelse return error.RouteTooLong; 61 row.* = .{ .title = item.notif.title, .url = try route.toUrl(arena), .identity = identity, .unread = item.unread }; 62 } 63 result.rows = rows; 64 return result; 65 } 66 67 // a first window without notifs, which shows the empty message 68 fn isEmpty(self: *const Self) bool { 69 return self.rows.len == 0 and self.start == 0; 70 } 71 72 // leave the user tab for the page it belongs to, where the web's logout redirect also lands 73 pub fn logOut(session: *ui.Session) !void { 74 try session.forgetSshKey(); 75 session.logOut(); 76 try session.navigate(session.data.current_page.pageRoot()); 77 } 78 79 const repos_label = "view repos"; 80 const logout_label = "logout"; 81 82 // the notifications, user settings, ssh keys and delete sub tabs over their content, with the logout button across from them 83 pub const View = struct { 84 box: wgt.Box(ui.Widget), 85 data: *const Self, 86 session: *ui.Session, 87 // the sub tabs' ids then the repos and logout buttons', in header order 88 control_ids: [6]usize, 89 // the sub tab whose content shows 90 view_index: usize, 91 92 const header_index = 0; 93 const content_index = 1; 94 // the sub tabs come first in `control_ids` 95 const tab_count = 4; 96 const repos_index = 4; 97 const logout_index = 5; 98 99 // `user_route` is the page's user tab route 100 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session, user_route: ui.RoutablePage) !View { 101 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 102 errdefer box.deinit(allocator); 103 104 var control_ids: [6]usize = undefined; 105 const settings_route = user_route.withUserTab(.{ .view = .settings }); 106 const keys_route = user_route.withUserTab(.{ .view = .keys }); 107 const delete_route = user_route.withUserTab(.{ .view = .delete }); 108 { 109 var header_scroll = try ui.widget.HeaderScroll.init(allocator, session); 110 errdefer header_scroll.deinit(allocator); 111 const header = header_scroll.row(); 112 const on_tab = std.meta.activeTag(session.data.current_page) == std.meta.activeTag(user_route); 113 const routes = [_]ui.RoutablePage{ user_route.withUserTab(.{}), settings_route, keys_route, delete_route }; 114 for ([_]ui.RoutablePage.UserView{ .notifications, .settings, .keys, .delete }, [_][]const u8{ "notifications", "user settings", "ssh keys", "delete user" }, routes, 0..) |view, label, route, index| { 115 const count_label = if (view == .notifications) try ui.UserSettings.notifCountLabel(session) else ""; 116 var tab = try wgt.TextBox.init(allocator, label, .{ .border = .hidden, .round_corners = true, .wrap_kind = .none, .bottom_label = .{ .text = count_label } }); 117 errdefer tab.deinit(allocator); 118 tab.getFocus().mode = .all; 119 tab.getFocus().kind = .{ .custom = try ui.inPageTabLink(session, route, on_tab and data.view == view) }; 120 control_ids[index] = tab.getFocus().id; 121 try header.children.put(allocator, tab.getFocus().id, .{ .widget = .{ .text_box = tab }, .rect = null, .min_size = .{ .width = @max(label.len, count_label.len) + 2, .height = null } }); 122 } 123 // the spacer pushes the buttons to the right edge 124 { 125 var spacer = try ui.widget.Spacer.init(allocator); 126 errdefer spacer.deinit(allocator); 127 try header.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 128 } 129 { 130 var button = try wgt.TextBox.init(allocator, repos_label, .{ .border = .single, .round_corners = true, .wrap_kind = .none }); 131 errdefer button.deinit(allocator); 132 button.getFocus().mode = .all; 133 button.getFocus().kind = .{ .custom = try ui.userLink(session.page_arena, session.data.user_name orelse unreachable) }; 134 control_ids[repos_index] = button.getFocus().id; 135 try header.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = .{ .width = repos_label.len + 2, .height = 3 } }); 136 } 137 { 138 // the form kind makes the web renderer post the button to the logout route 139 header.getFocus().kind = .{ .custom = try session.page_arena.allocator().print("form:{s}/logout", .{try settings_route.toUrl(session.page_arena)}) }; 140 var button = try wgt.TextBox.init(allocator, logout_label, .{ .border = .single, .round_corners = true, .wrap_kind = .none }); 141 errdefer button.deinit(allocator); 142 button.getFocus().mode = .all; 143 button.getFocus().kind = .{ .custom = "submit" }; 144 control_ids[logout_index] = button.getFocus().id; 145 try header.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = .{ .width = logout_label.len + 2, .height = 3 } }); 146 } 147 header.getFocus().child_id = control_ids[@backingInt(data.view)]; 148 // a row taller than the button, leaving a blank line beneath it 149 try box.children.put(allocator, header_scroll.getFocus().id, .{ .widget = .{ .header_scroll = header_scroll }, .rect = null, .min_size = .{ .width = null, .height = 4 } }); 150 } 151 152 { 153 var stack = try wgt.Stack(ui.Widget).init(allocator); 154 errdefer stack.deinit(allocator); 155 // an empty first window has no rows, not even paging ones 156 if (data.isEmpty()) { 157 var center = try ui.widget.initEmpty(allocator); 158 errdefer center.deinit(allocator); 159 try stack.children.put(allocator, center.getFocus().id, .{ .center = center }); 160 } else { 161 var list = try ui.widget.FlowBox.Scroll.init(allocator, .{}, !session.is_terminal); 162 errdefer list.deinit(allocator); 163 164 var arena = std.heap.ArenaAllocator.init(allocator); 165 defer arena.deinit(); 166 const aa = arena.allocator(); 167 168 // a leading "previous" row off the first window, one row per 169 // notif, then a trailing "next" row when more remain 170 var items: std.ArrayList(ui.widget.FlowBox.Item) = .empty; 171 if (data.start > 0) { 172 const prev = user_route.withUserTab(.{ .start = data.start -| page_size }); 173 try items.append(aa, .{ .text = "← previous", .link = try aa.print("a:{s}", .{try prev.toUrl(&arena)}) }); 174 } 175 for (data.rows) |row| try items.append(aa, .{ 176 .text = row.title, 177 .link = try aa.print("a:{s}", .{row.url}), 178 .bottom_label = try aa.print("{s} {s}", .{ if (row.unread) "☐" else "☑", row.identity }), 179 .bold = row.unread, 180 }); 181 if (data.next_start) |next_start| { 182 const next = user_route.withUserTab(.{ .start = next_start }); 183 try items.append(aa, .{ .text = "next →", .link = try aa.print("a:{s}", .{try next.toUrl(&arena)}) }); 184 } 185 try list.setItems(allocator, items.items); 186 try stack.children.put(allocator, list.getFocus().id, .{ .flow_box_scroll = list }); 187 } 188 { 189 var settings = try ui.UserSettings.View.init(allocator, session, settings_route); 190 errdefer settings.deinit(allocator); 191 try stack.children.put(allocator, settings.getFocus().id, .{ .user_settings = settings }); 192 } 193 { 194 var keys = try ui.UserKeys.View.init(allocator, &data.ssh_keys, session, keys_route); 195 errdefer keys.deinit(allocator); 196 try stack.children.put(allocator, keys.getFocus().id, .{ .user_keys = keys }); 197 } 198 { 199 var delete = try ui.Delete.View.init(allocator, .{ .target = .{ .user = session.data.user_name orelse unreachable } }, session, delete_route); 200 errdefer delete.deinit(allocator); 201 try stack.children.put(allocator, delete.getFocus().id, .{ .delete_form = delete }); 202 } 203 stack.getFocus().child_id = stack.children.keys()[@backingInt(data.view)]; 204 try box.children.put(allocator, stack.getFocus().id, .{ .widget = .{ .stack = stack }, .rect = null, .min_size = null }); 205 } 206 box.getFocus().child_id = box.children.keys()[content_index]; 207 return .{ .box = box, .data = data, .session = session, .control_ids = control_ids, .view_index = @backingInt(data.view) }; 208 } 209 210 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 211 self.box.deinit(allocator); 212 } 213 214 fn headerBox(self: *View) *wgt.Box(ui.Widget) { 215 return self.box.children.values()[header_index].widget.header_scroll.row(); 216 } 217 218 fn contentStack(self: *View) *wgt.Stack(ui.Widget) { 219 return &self.box.children.values()[content_index].widget.stack; 220 } 221 222 fn headerActive(self: *View) bool { 223 return self.box.getFocus().child_id == self.box.children.keys()[header_index]; 224 } 225 226 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 227 self.clearGrid(); 228 const header = self.headerBox(); 229 // focusing a sub tab shows its content, while the logout button leaves it 230 if (header.getFocus().child_id) |child_id| if (std.mem.indexOfScalar(usize, self.control_ids[0..tab_count], child_id)) |index| { 231 self.view_index = index; 232 }; 233 for (self.control_ids[0..tab_count], 0..) |id, index| { 234 const tab = header.children.getPtr(id) orelse unreachable; 235 ui.widget.markSelected(&tab.widget.text_box, index == self.view_index); 236 } 237 self.contentStack().getFocus().child_id = self.contentStack().children.keys()[self.view_index]; 238 // clear the incoming min height so the list scrolls within what's left 239 try self.box.build(allocator, .{ 240 .min_size = .{ .width = null, .height = null }, 241 .max_size = constraint.max_size, 242 }, root_focus); 243 } 244 245 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 246 const logout_id = self.control_ids[logout_index]; 247 if (key == .mouse and inp.leftClickOn(root_focus, logout_id, key.mouse)) return logOut(self.session); 248 const direction = inp.vertDirection(key); 249 if (self.headerActive()) { 250 const focused = root_focus.grandchild_id orelse return; 251 if (key == .enter and focused == logout_id) return logOut(self.session); 252 if (direction == .down) { 253 // the empty message has nothing to focus 254 if (self.view_index == @backingInt(ui.RoutablePage.UserView.notifications) and self.data.isEmpty()) return; 255 root_focus.setFocus(self.contentStack().getFocus().id); 256 } else if (std.mem.indexOfScalar(usize, &self.control_ids, focused)) |current| { 257 if (inp.moveTab(key, current, self.control_ids.len)) |next| root_focus.setFocus(self.control_ids[next]); 258 } 259 return; 260 } 261 if (direction == .up) if (self.contentStack().getSelected()) |selected| if (selected.atTop(root_focus)) { 262 root_focus.setFocus(self.control_ids[self.view_index]); 263 return; 264 }; 265 try self.contentStack().input(allocator, key, root_focus); 266 } 267 268 // moving up from the sub header returns to the page's tabs 269 pub fn atTop(self: *View) bool { 270 return self.headerActive(); 271 } 272 273 // arriving from the page's tabs lands on the sub header, not the content 274 pub fn focusHeader(self: *View, root_focus: *Focus) bool { 275 root_focus.setFocus(self.control_ids[self.view_index]); 276 return true; 277 } 278 279 pub fn clearGrid(self: *View) void { 280 self.box.clearGrid(); 281 } 282 283 pub fn getGrid(self: View) ?Grid { 284 return self.box.getGrid(); 285 } 286 287 pub fn getFocus(self: *View) *Focus { 288 return self.box.getFocus(); 289 } 290 }; 291