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