╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Comment.zig ──────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
9 const Key = xitui.input.Key; 10 const Grid = xitui.grid.Grid; 11 const Focus = xitui.focus.Focus; 12 13 pub const page_size = 10; 14 15 pub const CommentWithId = struct { 16 id: [evt.event_id_size * 2]u8, 17 comment: evt.Comment.Record, 18 author: ui.Author = .unknown, 19 parent_author: ?ui.Author = null, 20 }; 21 22 pub const Window = struct { 23 comments: []const CommentWithId, 24 start: usize, 25 count: usize, 26 27 pub const empty: Window = .{ .comments = &.{}, .start = 0, .count = 0 }; 28 }; 29 30 pub const Permalink = struct { 31 selected: CommentWithId, 32 replies: Window, 33 }; 34 35 // read a comment permalink and one window of its immediate replies. 36 pub fn init( 37 comptime hash_kind: hash.HashKind, 38 arena: *std.heap.ArenaAllocator, 39 admin_moment: ?evt.AdminDB.HashMap(.read_only), 40 haxy_moment: evt.EventDB(hash_kind).HashMap(.read_only), 41 thread_id: []const u8, 42 selected_id: []const u8, 43 start: usize, 44 ) !Permalink { 45 const selected = (try readOne(hash_kind, arena, admin_moment, haxy_moment, selected_id)) orelse return error.NotFound; 46 if (!std.mem.eql(u8, &selected.comment.event.thread_id, thread_id)) return error.NotFound; 47 48 return .{ 49 .selected = selected, 50 .replies = try loadWindow(hash_kind, arena, admin_moment, haxy_moment, evt.Comment.parent_id_to_comment_id_set_key, selected_id, start), 51 }; 52 } 53 54 // read one chronological comment window from an id-to-comment-set index. 55 pub fn loadWindow( 56 comptime hash_kind: hash.HashKind, 57 arena: *std.heap.ArenaAllocator, 58 admin_moment: ?evt.AdminDB.HashMap(.read_only), 59 haxy_moment: evt.EventDB(hash_kind).HashMap(.read_only), 60 index_key: []const u8, 61 owner_id: []const u8, 62 start: usize, 63 ) !Window { 64 const DB = evt.EventDB(hash_kind); 65 const index_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, index_key)) orelse return .{ 66 .comments = &.{}, 67 .start = start, 68 .count = 0, 69 }; 70 const index = try DB.HashMap(.read_only).init(index_cursor); 71 const set_cursor = try index.getCursor(hash.hashInt(hash_kind, owner_id)) orelse return .{ 72 .comments = &.{}, 73 .start = start, 74 .count = 0, 75 }; 76 const set = try DB.SortedSet(.read_only).init(set_cursor); 77 const count: usize = @intCast(try set.count()); 78 const end = start + @min(page_size, count -| start); 79 var iter = try set.iteratorFromIndex(start); 80 var comments: std.ArrayList(CommentWithId) = .empty; 81 var i = start; 82 while (i < end) : (i += 1) { 83 var cursor = (try iter.next()) orelse break; 84 const kv = try cursor.readKeyValuePair(); 85 var order_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; 86 _ = try kv.key_cursor.readBytes(&order_key); 87 const id_bytes = order_key[@sizeOf(u64)..].*; 88 const entry = (try readOneBytes(hash_kind, arena, admin_moment, haxy_moment, &id_bytes)) orelse continue; 89 try comments.append(arena.allocator(), entry); 90 } 91 return .{ 92 .comments = comments.items, 93 .start = start, 94 .count = count, 95 }; 96 } 97 98 fn readOne( 99 comptime hash_kind: hash.HashKind, 100 arena: *std.heap.ArenaAllocator, 101 admin_moment: ?evt.AdminDB.HashMap(.read_only), 102 haxy_moment: evt.EventDB(hash_kind).HashMap(.read_only), 103 id: []const u8, 104 ) !?CommentWithId { 105 const bytes = idBytes(id) orelse return null; 106 return readOneBytes(hash_kind, arena, admin_moment, haxy_moment, &bytes); 107 } 108 109 fn readOneBytes( 110 comptime hash_kind: hash.HashKind, 111 arena: *std.heap.ArenaAllocator, 112 admin_moment: ?evt.AdminDB.HashMap(.read_only), 113 haxy_moment: evt.EventDB(hash_kind).HashMap(.read_only), 114 id_bytes: *const [evt.event_id_size]u8, 115 ) !?CommentWithId { 116 const DB = evt.EventDB(hash_kind); 117 const comment = (try evt.Comment.readById(DB, hash_kind, haxy_moment, arena, id_bytes)) orelse return null; 118 const parent_author: ?ui.Author = if (!std.mem.eql(u8, &comment.event.parent_id, &comment.event.thread_id)) blk: { 119 const parent_bytes = idBytes(&comment.event.parent_id) orelse break :blk null; 120 const parent = (try evt.Comment.readById(DB, hash_kind, haxy_moment, arena, &parent_bytes)) orelse break :blk null; 121 break :blk try ui.Author.initFromEmail(admin_moment, arena, parent.author_email); 122 } else null; 123 return .{ 124 .id = std.fmt.bytesToHex(id_bytes.*, .lower), 125 .comment = comment, 126 .author = try ui.Author.initFromEmail(admin_moment, arena, comment.author_email), 127 .parent_author = parent_author, 128 }; 129 } 130 131 fn idBytes(id: []const u8) ?[evt.event_id_size]u8 { 132 if (id.len != evt.event_id_size * 2) return null; 133 var bytes: [evt.event_id_size]u8 = undefined; 134 _ = std.fmt.hexToBytes(&bytes, id) catch return null; 135 if (!std.mem.eql(u8, id, &std.fmt.bytesToHex(bytes, .lower))) return null; 136 return bytes; 137 } 138 139 pub const Item = struct { 140 box: wgt.Box(ui.Widget), 141 142 const metadata_index: usize = 0; 143 const body_index: usize = 1; 144 const gap_index: usize = 2; 145 const metadata_first_link_index: usize = 1; 146 147 pub fn init(allocator: std.mem.Allocator, session: *ui.Session, identity: []const u8, entry: CommentWithId) !Item { 148 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 149 errdefer box.deinit(allocator); 150 151 { 152 var bar = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 153 errdefer bar.deinit(allocator); 154 155 var spacer = try ui.Spacer.init(allocator); 156 errdefer spacer.deinit(allocator); 157 try bar.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 158 159 var author = try ui.authorBox(allocator, session.page_arena, entry.author); 160 errdefer author.deinit(allocator); 161 try bar.children.put(allocator, author.getFocus().id, .{ .widget = .{ .text_box = author }, .rect = null, .min_size = null, .shrink = true }); 162 163 var reply = try linkBox(allocator, session, "new reply", ui.RoutablePage.repoCommentNewRoute(identity, &entry.comment.event.thread_id, &entry.id) orelse return error.RouteTooLong); 164 errdefer reply.deinit(allocator); 165 try bar.children.put(allocator, reply.getFocus().id, .{ .widget = .{ .text_box = reply }, .rect = null, .min_size = .{ .width = "new reply".len + 2, .height = null } }); 166 167 var edit = try linkBox(allocator, session, "edit comment", ui.RoutablePage.repoCommentEditRoute(identity, &entry.comment.event.thread_id, &entry.id) orelse return error.RouteTooLong); 168 errdefer edit.deinit(allocator); 169 try bar.children.put(allocator, edit.getFocus().id, .{ .widget = .{ .text_box = edit }, .rect = null, .min_size = .{ .width = "edit comment".len + 2, .height = null } }); 170 171 var permalink = try linkBox(allocator, session, "permalink", ui.RoutablePage.repoCommentsRoute(identity, &entry.comment.event.thread_id, &entry.id, 0) orelse return error.RouteTooLong); 172 errdefer permalink.deinit(allocator); 173 try bar.children.put(allocator, permalink.getFocus().id, .{ .widget = .{ .text_box = permalink }, .rect = null, .min_size = .{ .width = "permalink".len + 2, .height = null } }); 174 175 if (!std.mem.eql(u8, &entry.comment.event.parent_id, &entry.comment.event.thread_id)) { 176 const parent_text = if (entry.parent_author) |parent_author| switch (parent_author) { 177 .unknown => "", 178 .email, .user_name => |text| text, 179 } else ""; 180 var parent = try wgt.TextBox(ui.Widget).init(allocator, parent_text, .{ 181 .border_style = .single, 182 .rounded_corners = true, 183 .wrap_kind = .none, 184 .label = " replying to ", 185 }); 186 errdefer parent.deinit(allocator); 187 parent.getFocus().focusable = true; 188 const route = ui.RoutablePage.repoCommentsRoute(identity, &entry.comment.event.thread_id, &entry.comment.event.parent_id, 0) orelse return error.RouteTooLong; 189 parent.getFocus().kind = .{ .custom = try std.fmt.allocPrint(session.page_arena.allocator(), "a:{s}", .{try route.toUrl(session.page_arena)}) }; 190 try bar.children.put(allocator, parent.getFocus().id, .{ .widget = .{ .text_box = parent }, .rect = null, .min_size = .{ .width = @max(parent_text.len, " replying to ".len) + 2, .height = null } }); 191 } 192 193 bar.getFocus().child_id = bar.children.keys()[metadata_first_link_index]; 194 try box.children.put(allocator, bar.getFocus().id, .{ .widget = .{ .box = bar }, .rect = null, .min_size = null }); 195 } 196 197 const body_text = if (entry.comment.deleted) "(deleted)" else entry.comment.event.body; 198 var body_box = try wgt.TextBox(ui.Widget).init(allocator, body_text, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .word }); 199 errdefer body_box.deinit(allocator); 200 body_box.getFocus().focusable = true; 201 try box.children.put(allocator, body_box.getFocus().id, .{ .widget = .{ .text_box = body_box }, .rect = null, .min_size = null }); 202 203 var gap = try wgt.Text(ui.Widget).init(allocator, ""); 204 errdefer gap.deinit(allocator); 205 try box.children.put(allocator, gap.getFocus().id, .{ .widget = .{ .text = gap }, .rect = null, .min_size = null }); 206 207 box.getFocus().child_id = box.children.keys()[metadata_index]; 208 209 return .{ .box = box }; 210 } 211 212 fn metadata(self: *Item) *wgt.Box(ui.Widget) { 213 return &self.box.children.values()[metadata_index].widget.box; 214 } 215 216 fn body(self: *Item) *wgt.TextBox(ui.Widget) { 217 return &self.box.children.values()[body_index].widget.text_box; 218 } 219 220 pub fn bodyFocused(self: *Item) bool { 221 return self.box.getFocus().child_id == self.body().getFocus().id; 222 } 223 224 pub fn focusMetadata(self: *Item, root_focus: *Focus) void { 225 const bar = self.metadata(); 226 root_focus.setFocus(bar.getFocus().child_id orelse bar.children.keys()[metadata_first_link_index]); 227 } 228 229 pub fn focusBody(self: *Item, root_focus: *Focus) void { 230 root_focus.setFocus(self.body().getFocus().id); 231 } 232 233 pub fn moveHorizontal(self: *Item, root_focus: *Focus, right: bool) bool { 234 if (self.bodyFocused()) return false; 235 const bar = self.metadata(); 236 const selected = bar.getFocus().child_id orelse return false; 237 const selected_index = bar.children.getIndex(selected) orelse return false; 238 const target = if (right) selected_index + 1 else selected_index -| 1; 239 if (target < metadata_first_link_index or target >= bar.children.count()) return false; 240 root_focus.setFocus(bar.children.keys()[target]); 241 return true; 242 } 243 244 pub fn rowRect(self: *Item, body_row: bool) ?layout.IRect { 245 if (!body_row) return self.box.children.values()[metadata_index].rect; 246 var rect = self.box.children.values()[body_index].rect orelse return null; 247 if (self.box.children.values()[gap_index].rect) |gap| rect.size.height += gap.size.height; 248 return rect; 249 } 250 251 pub fn deinit(self: *Item, allocator: std.mem.Allocator) void { 252 self.box.deinit(allocator); 253 } 254 255 pub fn build(self: *Item, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 256 try self.box.build(allocator, constraint, root_focus); 257 } 258 259 pub fn input(self: *Item, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 260 try self.box.input(allocator, key, root_focus); 261 } 262 263 pub fn clearGrid(self: *Item) void { 264 self.box.clearGrid(); 265 } 266 267 pub fn getGrid(self: Item) ?Grid { 268 return self.box.getGrid(); 269 } 270 271 pub fn getFocus(self: *Item) *Focus { 272 return self.box.getFocus(); 273 } 274 }; 275 276 // append one comment's metadata bar and body. 277 pub fn appendComment(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), session: *ui.Session, identity: []const u8, entry: CommentWithId) !void { 278 var item = try Item.init(allocator, session, identity, entry); 279 errdefer item.deinit(allocator); 280 try box.children.put(allocator, item.getFocus().id, .{ .widget = .{ .repo_comment = item }, .rect = null, .min_size = null }); 281 } 282 283 pub fn appendCount(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), count: usize, singular: []const u8, plural: []const u8, arena: std.mem.Allocator) !void { 284 const text = try std.fmt.allocPrint(arena, "{d} {s}", .{ count, if (count == 1) singular else plural }); 285 var count_box = try wgt.TextBox(ui.Widget).init(allocator, text, .{ .border_style = .hidden, .wrap_kind = .none }); 286 errdefer count_box.deinit(allocator); 287 try box.children.put(allocator, count_box.getFocus().id, .{ .widget = .{ .text_box = count_box }, .rect = null, .min_size = null }); 288 } 289 290 pub fn appendWindowNav(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), session: *ui.Session, identity: []const u8, thread_id: []const u8, selected_id: ?[]const u8, window: Window) !void { 291 const has_prev = window.start > 0; 292 const has_more = window.start < window.count and window.count - window.start > page_size; 293 if (!has_prev and !has_more) return; 294 var row = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 295 errdefer row.deinit(allocator); 296 if (has_prev) { 297 const start = window.start -| page_size; 298 const route = if (selected_id) |id| 299 ui.RoutablePage.repoCommentsRoute(identity, thread_id, id, start) 300 else 301 ui.RoutablePage.repoIssueCommentsRoute(identity, thread_id, start); 302 var previous = try linkBox(allocator, session, "← previous", route orelse return error.RouteTooLong); 303 errdefer previous.deinit(allocator); 304 try row.children.put(allocator, previous.getFocus().id, .{ .widget = .{ .text_box = previous }, .rect = null, .min_size = null }); 305 } 306 if (has_more) { 307 const start = window.start + page_size; 308 const route = if (selected_id) |id| 309 ui.RoutablePage.repoCommentsRoute(identity, thread_id, id, start) 310 else 311 ui.RoutablePage.repoIssueCommentsRoute(identity, thread_id, start); 312 var next = try linkBox(allocator, session, "next →", route orelse return error.RouteTooLong); 313 errdefer next.deinit(allocator); 314 try row.children.put(allocator, next.getFocus().id, .{ .widget = .{ .text_box = next }, .rect = null, .min_size = null }); 315 } 316 row.getFocus().child_id = row.children.keys()[0]; 317 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .box = row }, .rect = null, .min_size = null }); 318 } 319 320 pub fn linkBox(allocator: std.mem.Allocator, session: *ui.Session, text: []const u8, route: ui.RoutablePage) !wgt.TextBox(ui.Widget) { 321 var box = try wgt.TextBox(ui.Widget).init(allocator, text, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 322 errdefer box.deinit(allocator); 323 box.getFocus().focusable = true; 324 box.getFocus().kind = .{ .custom = try std.fmt.allocPrint(session.page_arena.allocator(), "a:{s}", .{try route.toUrl(session.page_arena)}) }; 325 return box; 326 } 327