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