| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | use std::collections::HashMap; |
| 7 | use std::sync::Arc; |
| 8 | |
| 9 | use forge_core::{ReviewComment, Thread}; |
| 10 | use topcoat::Result; |
| 11 | use topcoat::view::{Unescaped, View, component, view}; |
| 12 | |
| 13 | use crate::i18n::Lang; |
| 14 | use crate::i18n::pulls_review::{self as g, K}; |
| 15 | use crate::pages::issue_parts::When; |
| 16 | use crate::render::markdown::{LinkContext, render_markdown}; |
| 17 | use crate::views::{avatar_url, display_name, now_unix}; |
| 18 | |
| 19 | |
| 20 | |
| 21 | #[derive(Clone, Default)] |
| 22 | pub struct ThreadCtx { |
| 23 | |
| 24 | pub pr_href: String, |
| 25 | pub csrf: String, |
| 26 | |
| 27 | pub viewer_id: Option<String>, |
| 28 | |
| 29 | pub can_comment: bool, |
| 30 | |
| 31 | pub can_resolve: bool, |
| 32 | |
| 33 | pub can_moderate: bool, |
| 34 | |
| 35 | pub can_review: bool, |
| 36 | |
| 37 | pub tz: String, |
| 38 | |
| 39 | pub link: Option<LinkContext>, |
| 40 | |
| 41 | pub edits: Arc<HashMap<String, u64>>, |
| 42 | } |
| 43 | |
| 44 | |
| 45 | #[derive(Clone)] |
| 46 | struct CommentRow { |
| 47 | author: String, |
| 48 | avatar: String, |
| 49 | when: When, |
| 50 | html: String, |
| 51 | history_href: Option<String>, |
| 52 | edit_href: Option<String>, |
| 53 | delete_href: Option<String>, |
| 54 | } |
| 55 | |
| 56 | |
| 57 | struct ThreadParts { |
| 58 | location: String, |
| 59 | outdated: bool, |
| 60 | pending: bool, |
| 61 | hunk: Option<String>, |
| 62 | comments: Vec<CommentRow>, |
| 63 | resolve_action: Option<String>, |
| 64 | resolve_value: String, |
| 65 | resolve_label: String, |
| 66 | reply_action: Option<String>, |
| 67 | csrf: String, |
| 68 | return_to: String, |
| 69 | lang: Lang, |
| 70 | } |
| 71 | |
| 72 | fn comment_row(comment: ReviewComment, ctx: &ThreadCtx, now: i64, lang: Lang) -> CommentRow { |
| 73 | let mine = ctx.viewer_id.as_deref() == Some(comment.author_id.as_str()); |
| 74 | let base = format!("{}/review_comments/{}", ctx.pr_href, comment.id); |
| 75 | let edited = ctx.edits.get(&comment.id).is_some_and(|count| *count > 0); |
| 76 | CommentRow { |
| 77 | avatar: avatar_url(&comment.author_name, None), |
| 78 | author: display_name(lang, &comment.author_name), |
| 79 | when: When::new(lang, &comment.created_at, &ctx.tz, now), |
| 80 | html: render_markdown(&comment.body, ctx.link.as_ref()), |
| 81 | history_href: edited |
| 82 | .then(|| format!("{}/history/review_comment/{}", ctx.pr_href, comment.id)), |
| 83 | edit_href: (mine && ctx.can_comment).then(|| format!("{base}/edit")), |
| 84 | delete_href: ((mine || ctx.can_moderate) && ctx.can_comment) |
| 85 | .then(|| format!("{base}/delete")), |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | |
| 90 | |
| 91 | |
| 92 | #[component] |
| 93 | pub async fn review_thread( |
| 94 | thread: Thread, |
| 95 | ctx: ThreadCtx, |
| 96 | show_hunk: bool, |
| 97 | return_to: &str, |
| 98 | ) -> Result<impl View> { |
| 99 | Ok(view! { |
| 100 | review_thread_in(lang: Lang::En, thread: thread, ctx: ctx, show_hunk: show_hunk, |
| 101 | return_to: return_to) |
| 102 | }) |
| 103 | } |
| 104 | |
| 105 | |
| 106 | #[component] |
| 107 | pub async fn review_thread_in( |
| 108 | lang: Lang, |
| 109 | thread: Thread, |
| 110 | ctx: ThreadCtx, |
| 111 | show_hunk: bool, |
| 112 | return_to: &str, |
| 113 | ) -> Result<impl View> { |
| 114 | let now = now_unix(); |
| 115 | let return_to = return_to.to_owned(); |
| 116 | let Thread { root, replies } = thread; |
| 117 | let anchor = format!("rc-{}", root.id); |
| 118 | let location = format!("{}:{}", root.path, root.line); |
| 119 | let hunk = (show_hunk && !root.diff_hunk.is_empty()).then(|| root.diff_hunk.clone()); |
| 120 | let resolved = root.resolved; |
| 121 | let pending = root.pending; |
| 122 | let outdated = root.outdated; |
| 123 | let root_base = format!("{}/review_comments/{}", ctx.pr_href, root.id); |
| 124 | let resolve_action = (ctx.can_resolve && !pending).then(|| format!("{root_base}/resolve")); |
| 125 | let reply_action = (ctx.can_comment && !pending).then(|| format!("{root_base}/reply")); |
| 126 | let (resolve_value, resolve_label) = if resolved { |
| 127 | ("0", g::t(lang, K::Unresolve)) |
| 128 | } else { |
| 129 | ("1", g::t(lang, K::Resolve)) |
| 130 | }; |
| 131 | let comments: Vec<CommentRow> = std::iter::once(root) |
| 132 | .chain(replies) |
| 133 | .map(|comment| comment_row(comment, &ctx, now, lang)) |
| 134 | .collect(); |
| 135 | |
| 136 | let parts = ThreadParts { |
| 137 | location: location.clone(), |
| 138 | outdated, |
| 139 | pending, |
| 140 | hunk, |
| 141 | comments, |
| 142 | resolve_action, |
| 143 | resolve_value: resolve_value.to_owned(), |
| 144 | resolve_label: resolve_label.to_owned(), |
| 145 | reply_action, |
| 146 | csrf: ctx.csrf.clone(), |
| 147 | return_to, |
| 148 | lang, |
| 149 | }; |
| 150 | |
| 151 | |
| 152 | |
| 153 | let summary = if resolved { |
| 154 | g::resolved_conversation(lang, &location) |
| 155 | } else { |
| 156 | location |
| 157 | }; |
| 158 | Ok(view! { |
| 159 | <details class="review-thread" id=(anchor.as_str()) open=(!resolved)> |
| 160 | <summary class="review-thread-summary">(summary.as_str())</summary> |
| 161 | thread_parts(parts: parts) |
| 162 | </details> |
| 163 | }) |
| 164 | } |
| 165 | |
| 166 | #[component] |
| 167 | async fn thread_parts(parts: ThreadParts) -> Result<impl View> { |
| 168 | |
| 169 | let ThreadParts { |
| 170 | location, |
| 171 | outdated, |
| 172 | pending, |
| 173 | hunk, |
| 174 | comments, |
| 175 | resolve_action, |
| 176 | resolve_value, |
| 177 | resolve_label, |
| 178 | reply_action, |
| 179 | csrf, |
| 180 | return_to, |
| 181 | lang, |
| 182 | } = parts; |
| 183 | let footer = ThreadFooter { |
| 184 | resolve_action, |
| 185 | resolve_value, |
| 186 | resolve_label, |
| 187 | reply_action, |
| 188 | csrf: csrf.clone(), |
| 189 | return_to: return_to.clone(), |
| 190 | lang, |
| 191 | }; |
| 192 | Ok(view! { |
| 193 | thread_top(location: location, outdated: outdated, pending: pending, hunk: hunk, lang: lang) |
| 194 | for comment in comments.into_iter() { |
| 195 | thread_comment(comment: comment, csrf: csrf.clone(), return_to: return_to.clone(), |
| 196 | lang: lang) |
| 197 | } |
| 198 | thread_footer(footer: footer) |
| 199 | }) |
| 200 | } |
| 201 | |
| 202 | |
| 203 | struct ThreadFooter { |
| 204 | resolve_action: Option<String>, |
| 205 | resolve_value: String, |
| 206 | resolve_label: String, |
| 207 | reply_action: Option<String>, |
| 208 | csrf: String, |
| 209 | return_to: String, |
| 210 | lang: Lang, |
| 211 | } |
| 212 | |
| 213 | #[component] |
| 214 | async fn thread_footer(footer: ThreadFooter) -> Result<impl View> { |
| 215 | let ThreadFooter { |
| 216 | resolve_action, |
| 217 | resolve_value, |
| 218 | resolve_label, |
| 219 | reply_action, |
| 220 | csrf, |
| 221 | return_to, |
| 222 | lang, |
| 223 | } = footer; |
| 224 | Ok(view! { |
| 225 | thread_actions(resolve_action: resolve_action, resolve_value: resolve_value, |
| 226 | resolve_label: resolve_label, csrf: csrf.clone(), return_to: return_to.clone()) |
| 227 | if let Some(action) = reply_action { |
| 228 | reply_form(action: action, csrf: csrf, return_to: return_to, lang: lang) |
| 229 | } |
| 230 | }) |
| 231 | } |
| 232 | |
| 233 | |
| 234 | #[component] |
| 235 | async fn thread_top( |
| 236 | location: String, |
| 237 | outdated: bool, |
| 238 | pending: bool, |
| 239 | hunk: Option<String>, |
| 240 | lang: Lang, |
| 241 | ) -> Result<impl View> { |
| 242 | let outdated_text = g::t(lang, K::Outdated); |
| 243 | let pending_text = g::t(lang, K::Pending); |
| 244 | Ok(view! { |
| 245 | <header class="review-thread-head"> |
| 246 | <code>(location.as_str())</code> |
| 247 | if outdated { |
| 248 | <span class="review-badge review-badge-outdated">(outdated_text)</span> |
| 249 | } |
| 250 | if pending { |
| 251 | <span class="review-badge review-badge-pending">(pending_text)</span> |
| 252 | } |
| 253 | </header> |
| 254 | if let Some(text) = hunk { |
| 255 | <pre class="review-hunk"><code>(text.as_str())</code></pre> |
| 256 | } |
| 257 | }) |
| 258 | } |
| 259 | |
| 260 | |
| 261 | #[component] |
| 262 | async fn thread_actions( |
| 263 | resolve_action: Option<String>, |
| 264 | resolve_value: String, |
| 265 | resolve_label: String, |
| 266 | csrf: String, |
| 267 | return_to: String, |
| 268 | ) -> Result<impl View> { |
| 269 | Ok(view! { |
| 270 | <div class="review-thread-actions"> |
| 271 | if let Some(action) = resolve_action { |
| 272 | <form method="post" action=(action.as_str()) class="review-inline"> |
| 273 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 274 | <input type="hidden" name="resolved" value=(resolve_value.as_str())> |
| 275 | <input type="hidden" name="return" value=(return_to.as_str())> |
| 276 | <button type="submit" class="link-button">(resolve_label.as_str())</button> |
| 277 | </form> |
| 278 | } |
| 279 | </div> |
| 280 | }) |
| 281 | } |
| 282 | |
| 283 | #[component] |
| 284 | async fn reply_form( |
| 285 | action: String, |
| 286 | csrf: String, |
| 287 | return_to: String, |
| 288 | lang: Lang, |
| 289 | ) -> Result<impl View> { |
| 290 | let label = g::t(lang, K::ReplyLabel); |
| 291 | let button = g::t(lang, K::ReplyButton); |
| 292 | Ok(view! { |
| 293 | <form method="post" action=(action.as_str()) class="review-reply stack"> |
| 294 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 295 | <input type="hidden" name="return" value=(return_to.as_str())> |
| 296 | <label> |
| 297 | <span>(label)</span> |
| 298 | <textarea name="body" rows="3"></textarea> |
| 299 | </label> |
| 300 | <div class="issue-actions"> |
| 301 | <button type="submit">(button)</button> |
| 302 | </div> |
| 303 | </form> |
| 304 | }) |
| 305 | } |
| 306 | |
| 307 | #[component] |
| 308 | async fn thread_comment( |
| 309 | comment: CommentRow, |
| 310 | csrf: String, |
| 311 | return_to: String, |
| 312 | lang: Lang, |
| 313 | ) -> Result<impl View> { |
| 314 | let CommentRow { |
| 315 | author, |
| 316 | avatar, |
| 317 | when, |
| 318 | html, |
| 319 | history_href, |
| 320 | edit_href, |
| 321 | delete_href, |
| 322 | } = comment; |
| 323 | let edited = g::t(lang, K::Edited); |
| 324 | let edit = g::t(lang, K::EditLink); |
| 325 | let delete = g::t(lang, K::DeleteButton); |
| 326 | Ok(view! { |
| 327 | <article class="review-comment"> |
| 328 | <header class="review-comment-head"> |
| 329 | <img class="avatar avatar-sm" src=(avatar.as_str()) alt="" width="24" height="24"> |
| 330 | <strong>(author.as_str())</strong> |
| 331 | <time datetime=(when.iso.as_str()) title=(when.absolute.as_str())>(when.relative.as_str())</time> |
| 332 | if let Some(href) = history_href { |
| 333 | <a class="issue-edited" href=(href.as_str())>(edited)</a> |
| 334 | } |
| 335 | <span class="review-comment-actions"> |
| 336 | if let Some(href) = edit_href { |
| 337 | <a href=(href.as_str())>(edit)</a> |
| 338 | } |
| 339 | if let Some(action) = delete_href { |
| 340 | <form method="post" action=(action.as_str()) class="review-inline"> |
| 341 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 342 | <input type="hidden" name="return" value=(return_to.as_str())> |
| 343 | <button type="submit" class="link-button">(delete)</button> |
| 344 | </form> |
| 345 | } |
| 346 | </span> |
| 347 | </header> |
| 348 | <div class="markdown-body review-comment-body">(Unescaped::new_unchecked(html))</div> |
| 349 | </article> |
| 350 | }) |
| 351 | } |