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