╭ Discussions.zig ──────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const builtin = @import("builtin"); 3 const evt = @import("../../event.zig"); 4 const ui = @import("../../ui.zig"); 5 const xit = @import("xit"); 6 const rp = xit.repo; 7 const hash = xit.hash; 8 const Comment = @import("Comment.zig"); 9 const Attachment = @import("Attachment.zig"); 10 const Threads = @import("Threads.zig"); 11 12 const wasm = builtin.target.cpu.arch == .wasm32; 13 14 // how much of a description the detail pane shows before linking to the 15 // /description page, the same limit commit messages use. 16 pub const max_description_size = 2 * 1024; 17 18 // how many discussions one window shows before a "next" link appears. 19 pub const page_size = 20; 20 21 // how many tags the tags view shows at most. 22 pub const max_tags = 1000; 23 24 // one discussion from the repo's consumed event database, with its hex event id 25 // (the id lives in the event envelope, not the payload). 26 pub const DiscussionWithId = struct { 27 id: []const u8, 28 record: evt.Discussion.Record, 29 author: ui.Author = .unknown, 30 comments: Comment.Window = .empty, 31 attachments: []const Attachment.WithId = &.{}, 32 }; 33 34 pub const Entry = DiscussionWithId; 35 36 pub const Window = struct { 37 items: []const DiscussionWithId, 38 prev_id: ?[]const u8, 39 next_id: ?[]const u8, 40 count: usize, 41 42 pub const empty: Window = .{ .items = &.{}, .prev_id = null, .next_id = null, .count = 0 }; 43 }; 44 45 identity: []const u8, 46 tag: []const u8, 47 selected_id: []const u8, 48 comment_id: []const u8, 49 comments_start: usize, 50 comment_page: ?Comment.Permalink = null, 51 recent: Window, 52 view: ui.RoutablePage.DiscussionsView, 53 description_page: bool = false, 54 tags: []const []const u8, 55 repo_source: ?ui.RepoSource = null, 56 57 const Self = @This(); 58 59 pub const Event = evt.Discussion; 60 pub const Status = enum { recent }; 61 pub const ViewKind = ui.RoutablePage.DiscussionsView; 62 63 pub fn selectedThread(self: *const Self) ?*const DiscussionWithId { 64 if (self.selected_id.len == 0) return null; 65 for (self.recent.items) |*entry| { 66 if (std.mem.eql(u8, entry.id, self.selected_id)) return entry; 67 } 68 return null; 69 } 70 71 pub fn window(self: *const Self, _: Status) *const Window { 72 return &self.recent; 73 } 74 75 pub fn emptyResult( 76 aa: std.mem.Allocator, 77 identity: []const u8, 78 tag: []const u8, 79 selected_id: []const u8, 80 comment_id: []const u8, 81 comments_start: usize, 82 view: ui.RoutablePage.DiscussionsView, 83 ) !Self { 84 return .{ 85 .identity = try aa.dupe(u8, identity), 86 .tag = try aa.dupe(u8, tag), 87 .selected_id = try aa.dupe(u8, selected_id), 88 .comment_id = try aa.dupe(u8, comment_id), 89 .comments_start = comments_start, 90 .recent = .empty, 91 .view = if (view == .description) .recent else view, 92 .description_page = view == .description, 93 .tags = &.{}, 94 }; 95 } 96 97 pub fn init( 98 comptime repo_kind: rp.RepoKind, 99 comptime repo_opts: rp.RepoOpts(repo_kind), 100 arena: *std.heap.ArenaAllocator, 101 repo: *rp.Repo(repo_kind, repo_opts), 102 io: std.Io, 103 admin_moment: ?evt.AdminDB.HashMap(.read_only), 104 identity: []const u8, 105 tag: []const u8, 106 selected_id: []const u8, 107 comment_id: []const u8, 108 comments_start: usize, 109 view: ui.RoutablePage.DiscussionsView, 110 ) !Self { 111 const empty = try emptyResult(arena.allocator(), identity, tag, selected_id, comment_id, comments_start, view); 112 const aa = arena.allocator(); 113 const DB = evt.EventDB(repo_opts.hash); 114 const rooted = empty.selected_id.len != 0; 115 const tagged = empty.tag.len != 0; 116 const strict = rooted or tagged; 117 118 const gpa = arena.child_allocator; 119 var event_db_maybe: ?evt.LocalEventDB(repo_opts.hash) = if (repo_kind == .git) try evt.LocalEventDB(repo_opts.hash).openReadOnly(io, gpa, repo.core.repo_dir) else null; 120 defer if (event_db_maybe) |*event_db| event_db.deinit(io, gpa); 121 const haxy_moment = (if (event_db_maybe) |*event_db| 122 evt.currentMomentFromDb(repo_opts.hash, event_db.db) 123 else if (repo_kind == .git) 124 return empty 125 else 126 evt.currentMoment(repo_opts, repo)) catch { 127 if (strict) return error.NotFound; 128 return empty; 129 }; 130 131 const set_maybe: ?DB.SortedSet(.read_only) = if (tagged) blk: { 132 const tags_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Discussion.tag_to_id_set_key)) orelse return error.NotFound; 133 const tag_sets = try DB.SortedMap(.read_only).init(tags_cursor); 134 const decoded = std.Uri.percentDecodeInPlace(try aa.dupe(u8, empty.tag)); 135 const cursor = try tag_sets.getCursor(decoded) orelse return error.NotFound; 136 break :blk try DB.SortedSet(.read_only).init(cursor); 137 } else if (try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Discussion.active_id_set_key))) |cursor| 138 try DB.SortedSet(.read_only).init(cursor) 139 else 140 null; 141 142 const records_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Discussion.record_map_key)) orelse { 143 if (strict) return error.NotFound; 144 return empty; 145 }; 146 const records = try DB.HashMap(.read_only).init(records_cursor); 147 148 var root_key: ?[]const u8 = null; 149 if (rooted) { 150 const id = evt.parseEventId(empty.selected_id) catch return error.NotFound; 151 const record_cursor = try records.getCursor(hash.hashInt(repo_opts.hash, &id)) orelse return error.NotFound; 152 const record = try evt.read(evt.Discussion.Record, DB, repo_opts.hash, arena, try DB.HashMap(.read_only).init(record_cursor)); 153 if (record.removed) return error.NotFound; 154 const activity_order = try evt.Discussion.activityOrder(DB, repo_opts.hash, haxy_moment, &id); 155 const order_key = try aa.dupe(u8, &evt.orderKeyDesc(activity_order, &id)); 156 const set = set_maybe orelse return error.NotFound; 157 if (!try set.contains(order_key)) return error.NotFound; 158 root_key = order_key; 159 } 160 161 const discussion_comments_start = if (empty.comment_id.len == 0) comments_start else 0; 162 const loaded_window = try loadWindow(repo_opts.hash, arena, admin_moment, haxy_moment, records, set_maybe, root_key, empty.selected_id, discussion_comments_start); 163 const comment_page = if (empty.comment_id.len == 0) 164 null 165 else 166 try Comment.init(repo_opts.hash, arena, admin_moment, haxy_moment, empty.selected_id, empty.comment_id, comments_start); 167 168 var tag_names: std.ArrayList([]const u8) = .empty; 169 if (try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Discussion.tag_to_id_set_key))) |tags_cursor| { 170 const tag_sets = try DB.SortedMap(.read_only).init(tags_cursor); 171 var tag_iter = try tag_sets.iterator(); 172 while (try tag_iter.next()) |entry_cursor| { 173 if (tag_names.items.len == max_tags) break; 174 var entry = entry_cursor; 175 const pair = try entry.readKeyValuePair(); 176 try tag_names.append(aa, try pair.key_cursor.readBytesAlloc(aa, null)); 177 } 178 } 179 180 return .{ 181 .identity = empty.identity, 182 .tag = empty.tag, 183 .selected_id = empty.selected_id, 184 .comment_id = empty.comment_id, 185 .comments_start = comments_start, 186 .comment_page = comment_page, 187 .recent = loaded_window, 188 .view = empty.view, 189 .description_page = empty.description_page, 190 .tags = tag_names.items, 191 }; 192 } 193 194 fn loadWindow( 195 comptime hash_kind: hash.HashKind, 196 arena: *std.heap.ArenaAllocator, 197 admin_moment: ?evt.AdminDB.HashMap(.read_only), 198 haxy_moment: evt.EventDB(hash_kind).HashMap(.read_only), 199 records: evt.EventDB(hash_kind).HashMap(.read_only), 200 set_maybe: ?evt.EventDB(hash_kind).SortedSet(.read_only), 201 root_key: ?[]const u8, 202 selected_id: []const u8, 203 comments_start: usize, 204 ) !Window { 205 const set = set_maybe orelse return .empty; 206 const DB = evt.EventDB(hash_kind); 207 const aa = arena.allocator(); 208 209 var prev_id: ?[]const u8 = null; 210 var iter = if (root_key) |key| blk: { 211 const rank = try set.rank(key); 212 if (rank > 0 and rank <= page_size) { 213 prev_id = ""; 214 } else if (rank > page_size) { 215 const pair = try set.getIndexKeyValuePair(@intCast(rank - page_size)) orelse return error.NotFound; 216 var order_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; 217 _ = try pair.key_cursor.readBytes(&order_key); 218 const hex = std.fmt.bytesToHex(order_key[@sizeOf(u64)..].*, .lower); 219 prev_id = try aa.dupe(u8, &hex); 220 } 221 break :blk try set.iteratorFrom(key); 222 } else try set.iteratorFromIndex(0); 223 224 var discussions: std.ArrayList(DiscussionWithId) = .empty; 225 var next_id: ?[]const u8 = null; 226 while (try iter.next()) |cursor| { 227 const id = try evt.readOrderKeyId(DB, cursor); 228 const id_hex = std.fmt.bytesToHex(id, .lower); 229 if (discussions.items.len == page_size) { 230 next_id = try aa.dupe(u8, &id_hex); 231 break; 232 } 233 const record_cursor = try records.getCursor(hash.hashInt(hash_kind, &id)) orelse continue; 234 const record = try evt.read(evt.Discussion.Record, DB, hash_kind, arena, try DB.HashMap(.read_only).init(record_cursor)); 235 try discussions.append(aa, .{ 236 .id = try aa.dupe(u8, &id_hex), 237 .record = record, 238 .author = try ui.Author.initFromEmail(admin_moment, arena, record.author_email), 239 .comments = try Comment.loadWindow( 240 hash_kind, 241 arena, 242 admin_moment, 243 haxy_moment, 244 evt.Comment.thread_id_to_comment_id_set_key, 245 &id_hex, 246 if (std.mem.eql(u8, &id_hex, selected_id)) comments_start else 0, 247 ), 248 .attachments = try Attachment.load(hash_kind, arena, haxy_moment, &id_hex), 249 }); 250 } 251 252 return .{ 253 .items = discussions.items, 254 .prev_id = prev_id, 255 .next_id = next_id, 256 .count = @intCast(try set.count()), 257 }; 258 } 259 pub const View = Threads.View(.discuss, Self); 260 261 pub const Header = Threads.Header; 262 263 // tabs switching between the discussions page's views 264 pub fn initHeader(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self) !Header { 265 var header = try Header.init(allocator); 266 errdefer header.deinit(allocator); 267 const aa = session.page_arena.allocator(); 268 269 // recent discussions 270 { 271 const route = ui.RoutablePage.repoDiscussionsRoute(data.identity, data.tag, "") orelse return error.RouteTooLong; 272 const link = try std.fmt.allocPrint(aa, "ai:{s}", .{try route.toUrl(session.page_arena)}); 273 var label_buf: [64]u8 = undefined; 274 const label = try std.fmt.bufPrint(&label_buf, "recent ({d})", .{data.recent.count}); 275 try header.addTab(allocator, label, link, 0); 276 } 277 278 // tags tab, labeled with the active tag filter 279 { 280 const tags_route = ui.RoutablePage.repoThreadTagsRoute(.discuss, data.identity, data.tag) orelse return error.RouteTooLong; 281 const tags_link = try std.fmt.allocPrint(aa, "ai:{s}", .{try tags_route.toUrl(session.page_arena)}); 282 const label = if (data.tag.len == 0) "tags" else blk: { 283 const decoded = std.Uri.percentDecodeInPlace(try aa.dupe(u8, data.tag)); 284 break :blk try std.fmt.allocPrint(aa, "tags ({s})", .{decoded}); 285 }; 286 try header.addTab(allocator, label, tags_link, View.viewIndex(.tags)); 287 } 288 289 // new-discussion tab; an edit or comment url shows its tab in this place 290 { 291 const route = switch (data.view) { 292 .edit => ui.RoutablePage.repoThreadEditRoute(.discuss, data.identity, data.selected_id) orelse return error.RouteTooLong, 293 .new_comment => ui.RoutablePage.repoThreadCommentNewRoute(.discuss, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong, 294 .edit_comment => ui.RoutablePage.repoThreadCommentEditRoute(.discuss, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong, 295 .remove => ui.RoutablePage.repoThreadRemoveRoute(.discuss, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong, 296 else => ui.RoutablePage.repoThreadNewRoute(.discuss, data.identity) orelse return error.RouteTooLong, 297 }; 298 const link = try std.fmt.allocPrint(aa, "ai:{s}", .{try route.toUrl(session.page_arena)}); 299 const label: []const u8 = switch (data.view) { 300 .edit => "edit", 301 .new_comment => "reply", 302 .edit_comment => "edit", 303 .remove => "remove", 304 else => "new", 305 }; 306 try header.addTab(allocator, label, link, View.viewIndex(.new)); 307 } 308 309 header.select(View.viewIndex(data.view)); 310 return header; 311 } 312