╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Comment.zig ──────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
128 break :blk try ui.Author.initFromEmail(admin_moment, arena, parent.author_email); 129 } else null; 130 return .{ 131 .id = std.fmt.bytesToHex(id_bytes.*, .lower), 132 .comment = comment, 133 .author = try ui.Author.initFromEmail(admin_moment, arena, comment.author_email), 134 .parent_author = parent_author, 135 .can_modify = if (viewer) |v| v.role.canModify(v.email, comment.author_email) else false, 136 }; 137 } 138 139 fn idBytes(id: []const u8) ?[evt.event_id_size]u8 { 140 if (id.len != evt.event_id_size * 2) return null; 141 var bytes: [evt.event_id_size]u8 = undefined; 142 _ = std.fmt.hexToBytes(&bytes, id) catch return null; 143 if (!std.mem.eql(u8, id, &std.fmt.bytesToHex(bytes, .lower))) return null; 144 return bytes; 145 } 146 147 pub const Item = struct { 148 box: wgt.Box(ui.Widget), 149 150 const metadata_index: usize = 0; 151 const body_index: usize = 1; 152 const gap_index: usize = 2; 153 154 pub fn init(allocator: std.mem.Allocator, session: *ui.Session, identity: []const u8, thread_kind: evt.EventKind, can_reply: bool, entry: CommentWithId) !Item { 155 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 156 errdefer box.deinit(allocator); 157 158 { 159 var bar = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .horiz }); 160 errdefer bar.deinit(allocator); 161 162 // the author sits on the left and the buttons on the right 163 var author = try ui.authorBox(allocator, session.page_arena, entry.author); 164 errdefer author.deinit(allocator); 165 try bar.children.put(allocator, author.getFocus().id, .{ .widget = .{ .text_box = author }, .rect = null, .min_size = null, .flex = .shrink }); 166 167 var spacer = try ui.widget.Spacer.init(allocator); 168 errdefer spacer.deinit(allocator); 169 try bar.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 170 171 if (can_reply) { 172 var reply = try linkBox(allocator, session, "new reply", commentNewRoute(thread_kind, identity, &entry.comment.event.thread_id, &entry.id) orelse return error.RouteTooLong); 173 errdefer reply.deinit(allocator); 174 try bar.children.put(allocator, reply.getFocus().id, .{ .widget = .{ .text_box = reply }, .rect = null, .min_size = .{ .width = "new reply".len + 2, .height = null } }); 175 } 176 177 if (entry.can_modify and !entry.comment.removed) { 178 var edit = try linkBox(allocator, session, "edit comment", commentEditRoute(thread_kind, identity, &entry.comment.event.thread_id, &entry.id) orelse return error.RouteTooLong); 179 errdefer edit.deinit(allocator); 180 try bar.children.put(allocator, edit.getFocus().id, .{ .widget = .{ .text_box = edit }, .rect = null, .min_size = .{ .width = "edit comment".len + 2, .height = null } }); 181 } 182 183 if (!std.mem.eql(u8, &entry.comment.event.parent_id, &entry.comment.event.thread_id)) { 184 const parent_text = if (entry.parent_author) |parent_author| switch (parent_author) { 185 .unknown => "", 186 .email, .user_name => |text| text, 187 } else ""; 188 var parent = try wgt.TextBox.init(allocator, parent_text, .{ 189 .border = .single, 190 .round_corners = true, 191 .wrap_kind = .none, 192 .top_label = .{ .text = " replying to " }, 193 }); 194 errdefer parent.deinit(allocator); 195 parent.getFocus().mode = .all; 196 const route = commentsRoute(thread_kind, identity, &entry.comment.event.thread_id, &entry.comment.event.parent_id, 0) orelse return error.RouteTooLong; 197 parent.getFocus().kind = .{ .custom = try session.page_arena.allocator().print("a:{s}", .{try route.toUrl(session.page_arena)}) }; 198 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 } }); 199 } 200 201 const timestamp = try ui.Repo.Undo.formatTimestamp(session.page_arena.allocator(), std.math.cast(i64, entry.comment.created_timestamp) orelse -1); 202 var permalink = try linkBox(allocator, session, timestamp, commentsRoute(thread_kind, identity, &entry.comment.event.thread_id, &entry.id, 0) orelse return error.RouteTooLong); 203 errdefer permalink.deinit(allocator); 204 permalink.options.top_label.text = " permalink "; 205 try bar.children.put(allocator, permalink.getFocus().id, .{ .widget = .{ .text_box = permalink }, .rect = null, .min_size = .{ .width = @max(timestamp.len, " permalink ".len) + 2, .height = null } }); 206 207 if (entry.can_modify and !entry.comment.removed) { 208 var remove = try linkBox(allocator, session, "✕", removeRoute(thread_kind, identity, &entry.comment.event.thread_id, &entry.id) orelse return error.RouteTooLong); 209 errdefer remove.deinit(allocator); 210 try bar.children.put(allocator, remove.getFocus().id, .{ .widget = .{ .text_box = remove }, .rect = null, .min_size = .{ .width = 3, .height = null } }); 211 } 212 213 bar.getFocus().child_id = bar.children.keys()[0]; 214 try box.children.put(allocator, bar.getFocus().id, .{ .widget = .{ .box = bar }, .rect = null, .min_size = null }); 215 } 216 217 const body_text = if (entry.comment.removed) "(removed)" else entry.comment.event.body; 218 var body_frame = try Markdown.Frame.init(allocator, body_text, "", "", session.page_arena); 219 errdefer body_frame.deinit(allocator); 220 try box.children.put(allocator, body_frame.getFocus().id, .{ .widget = .{ .markdown_frame = body_frame }, .rect = null, .min_size = null }); 221 222 var gap = try wgt.Text.init(allocator, ""); 223 errdefer gap.deinit(allocator); 224 try box.children.put(allocator, gap.getFocus().id, .{ .widget = .{ .text = gap }, .rect = null, .min_size = null }); 225 226 box.getFocus().child_id = box.children.keys()[metadata_index]; 227 228 return .{ .box = box }; 229 } 230 231 fn metadata(self: *Item) *wgt.Box(ui.Widget) { 232 return &self.box.children.values()[metadata_index].widget.box; 233 } 234 235 pub fn body(self: *Item) *Markdown.Frame { 236 return &self.box.children.values()[body_index].widget.markdown_frame; 237 } 238 239 pub fn bodyFocused(self: *Item) bool { 240 return self.box.getFocus().child_id == self.body().getFocus().id; 241 } 242 243 pub fn focusMetadata(self: *Item, root_focus: *Focus) void { 244 const bar = self.metadata(); 245 root_focus.setFocus(bar.getFocus().child_id orelse bar.children.keys()[0]); 246 } 247 248 pub fn focusBody(self: *Item, root_focus: *Focus) void { 249 root_focus.setFocus(self.body().getFocus().id); 250 } 251 252 pub fn moveHorizontal(self: *Item, root_focus: *Focus, right: bool) bool { 253 if (self.bodyFocused()) return false; 254 const bar = self.metadata(); 255 const selected = bar.getFocus().child_id orelse return false; 256 var target = bar.children.getIndex(selected) orelse return false; 257 // step past the spacer between the author and the buttons 258 while (true) { 259 if (right) target += 1 else target = std.math.sub(usize, target, 1) catch return false; 260 if (target >= bar.children.count()) return false; 261 if (bar.children.values()[target].widget != .spacer) break; 262 } 263 root_focus.setFocus(bar.children.keys()[target]); 264 return true; 265 } 266 267 pub fn rowRect(self: *Item, body_row: bool) ?layout.IRect { 268 if (!body_row) return self.box.children.values()[metadata_index].rect; 269 var rect = self.box.children.values()[body_index].rect orelse return null; 270 if (self.box.children.values()[gap_index].rect) |gap| rect.size.height += gap.size.height; 271 return rect; 272 } 273 274 pub fn deinit(self: *Item, allocator: std.mem.Allocator) void { 275 self.box.deinit(allocator); 276 } 277 278 pub fn build(self: *Item, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 279 try self.box.build(allocator, constraint, root_focus); 280 } 281 282 pub fn input(self: *Item, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 283 try self.box.input(allocator, key, root_focus); 284 } 285 286 pub fn clearGrid(self: *Item) void { 287 self.box.clearGrid(); 288 } 289 290 pub fn getGrid(self: Item) ?Grid { 291 return self.box.getGrid(); 292 } 293 294 pub fn getFocus(self: *Item) *Focus { 295 return self.box.getFocus(); 296 } 297 }; 298 299 // append one comment's metadata bar and body. 300 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 { 301 var item = try Item.init(allocator, session, identity, thread_kind, can_reply, entry); 302 errdefer item.deinit(allocator); 303 try box.children.put(allocator, item.getFocus().id, .{ .widget = .{ .repo_comment = item }, .rect = null, .min_size = null }); 304 } 305 306 pub fn appendCount(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), count: usize, singular: []const u8, plural: []const u8) !void { 307 var text_buf: [64]u8 = undefined; 308 const text = try std.fmt.bufPrint(&text_buf, "{d} {s}", .{ count, if (count == 1) singular else plural }); 309 var count_box = try wgt.TextBox.init(allocator, text, .{ .border = .hidden, .wrap_kind = .none }); 310 errdefer count_box.deinit(allocator); 311 try box.children.put(allocator, count_box.getFocus().id, .{ .widget = .{ .text_box = count_box }, .rect = null, .min_size = null }); 312 } 313 314 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 { 315 const has_prev = window.start > 0; 316 const has_more = window.start < window.count and window.count - window.start > page_size; 317 if (!has_prev and !has_more) return; 318 var row = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .horiz }); 319 errdefer row.deinit(allocator); 320 if (has_prev) { 321 const start = window.start -| page_size; 322 const route = commentsRoute(thread_kind, identity, thread_id, selected_id, start); 323 var previous = try linkBox(allocator, session, "← previous", route orelse return error.RouteTooLong); 324 errdefer previous.deinit(allocator); 325 try row.children.put(allocator, previous.getFocus().id, .{ .widget = .{ .text_box = previous }, .rect = null, .min_size = null }); 326 } 327 if (has_more) { 328 const start = window.start + page_size; 329 const route = commentsRoute(thread_kind, identity, thread_id, selected_id, start); 330 var next = try linkBox(allocator, session, "next →", route orelse return error.RouteTooLong); 331 errdefer next.deinit(allocator); 332 try row.children.put(allocator, next.getFocus().id, .{ .widget = .{ .text_box = next }, .rect = null, .min_size = null }); 333 } 334 row.getFocus().child_id = row.children.keys()[0]; 335 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .box = row }, .rect = null, .min_size = null }); 336 } 337 338 fn commentsRoute(kind: evt.EventKind, identity: []const u8, thread_id: []const u8, selected_id: ?[]const u8, start: usize) ?ui.RoutablePage { 339 return if (selected_id) |id| 340 ui.RoutablePage.repoThreadCommentRoute(kind, identity, thread_id, id, start) 341 else 342 ui.RoutablePage.repoThreadCommentsRoute(kind, identity, thread_id, start); 343 } 344 345 fn commentNewRoute(kind: evt.EventKind, identity: []const u8, thread_id: []const u8, parent_id: []const u8) ?ui.RoutablePage { 346 return ui.RoutablePage.repoThreadCommentNewRoute(kind, identity, thread_id, parent_id); 347 } 348 349 fn commentEditRoute(kind: evt.EventKind, identity: []const u8, thread_id: []const u8, comment_id: []const u8) ?ui.RoutablePage { 350 return ui.RoutablePage.repoThreadCommentEditRoute(kind, identity, thread_id, comment_id); 351 } 352 353 fn removeRoute(kind: evt.EventKind, identity: []const u8, thread_id: []const u8, comment_id: []const u8) ?ui.RoutablePage { 354 return ui.RoutablePage.repoThreadRemoveRoute(kind, identity, thread_id, comment_id); 355 } 356 357 pub fn linkBox(allocator: std.mem.Allocator, session: *ui.Session, text: []const u8, route: ui.RoutablePage) !wgt.TextBox { 358 var box = try wgt.TextBox.init(allocator, text, .{ .border = .single, .round_corners = true, .wrap_kind = .none }); 359 errdefer box.deinit(allocator); 360 box.getFocus().mode = .all; 361 box.getFocus().kind = .{ .custom = try session.page_arena.allocator().print("a:{s}", .{try route.toUrl(session.page_arena)}) }; 362 return box; 363 } 364