//! One review thread: a root comment and its replies, with the path and line, badges, an optional //! diff hunk, and the reply and resolve controls. The Files tab renders it under its diff line; //! the Conversation tab (`return_to = "conversation"`) renders it in the timeline. Resolved //! threads collapse into a `
`. Markup uses the `review-*` classes. use std::collections::HashMap; use std::sync::Arc; use forge_core::{ReviewComment, Thread}; use topcoat::Result; use topcoat::view::{Unescaped, View, component, view}; use crate::i18n::Lang; use crate::i18n::pulls_review::{self as g, K}; use crate::pages::issue_parts::When; use crate::render::markdown::{LinkContext, render_markdown}; use crate::views::{avatar_url, display_name, now_unix}; /// What a thread needs from its page: where the review POSTs go, the CSRF token, who is viewing /// and what they may do. Built once per page and cloned into each thread. #[derive(Clone, Default)] pub struct ThreadCtx { /// `/{owner}/{repo}/pulls/{n}`, the base of the review routes. pub pr_href: String, pub csrf: String, /// The signed-in viewer's user id, if any. Decides which comments are theirs. pub viewer_id: Option, /// Signed in on an unarchived repo: may reply, and may edit or delete their own comments. pub can_comment: bool, /// The PR author or a Write holder: may resolve threads. pub can_resolve: bool, /// Triage or better: may delete any comment. pub can_moderate: bool, /// Signed in, unarchived and the PR is open: may start a review. pub can_review: bool, /// Timezone for the relative-time titles. pub tz: String, /// Link context for the markdown bodies, as issue comments use. pub link: Option, /// Earlier versions per review comment id. A comment in here with a count shows "edited". pub edits: Arc>, } /// One comment of a thread, flattened for the view. #[derive(Clone)] struct CommentRow { author: String, avatar: String, when: When, html: String, history_href: Option, edit_href: Option, delete_href: Option, } /// The controls and content of a thread, shared by the open and the collapsed (resolved) forms. struct ThreadParts { location: String, outdated: bool, pending: bool, hunk: Option, comments: Vec, resolve_action: Option, resolve_value: String, resolve_label: String, reply_action: Option, csrf: String, return_to: String, lang: Lang, } fn comment_row(comment: ReviewComment, ctx: &ThreadCtx, now: i64, lang: Lang) -> CommentRow { let mine = ctx.viewer_id.as_deref() == Some(comment.author_id.as_str()); let base = format!("{}/review_comments/{}", ctx.pr_href, comment.id); let edited = ctx.edits.get(&comment.id).is_some_and(|count| *count > 0); CommentRow { avatar: avatar_url(&comment.author_name, None), author: display_name(lang, &comment.author_name), when: When::new(lang, &comment.created_at, &ctx.tz, now), html: render_markdown(&comment.body, ctx.link.as_ref()), history_href: edited .then(|| format!("{}/history/review_comment/{}", ctx.pr_href, comment.id)), edit_href: (mine && ctx.can_comment).then(|| format!("{base}/edit")), delete_href: ((mine || ctx.can_moderate) && ctx.can_comment) .then(|| format!("{base}/delete")), } } /// One thread, in English. Pages that know the viewer's language use `review_thread_in`. /// `show_hunk` adds the stored diff hunk as a code block; `return_to` is `files` or /// `conversation` and tells the POST routes which tab to redirect to. #[component] pub async fn review_thread( thread: Thread, ctx: ThreadCtx, show_hunk: bool, return_to: &str, ) -> Result { Ok(view! { review_thread_in(lang: Lang::En, thread: thread, ctx: ctx, show_hunk: show_hunk, return_to: return_to) }) } /// One thread with its strings in `lang`. See `review_thread` for the arguments. #[component] pub async fn review_thread_in( lang: Lang, thread: Thread, ctx: ThreadCtx, show_hunk: bool, return_to: &str, ) -> Result { let now = now_unix(); let return_to = return_to.to_owned(); let Thread { root, replies } = thread; let anchor = format!("rc-{}", root.id); let location = format!("{}:{}", root.path, root.line); let hunk = (show_hunk && !root.diff_hunk.is_empty()).then(|| root.diff_hunk.clone()); let resolved = root.resolved; let pending = root.pending; let outdated = root.outdated; let root_base = format!("{}/review_comments/{}", ctx.pr_href, root.id); let resolve_action = (ctx.can_resolve && !pending).then(|| format!("{root_base}/resolve")); let reply_action = (ctx.can_comment && !pending).then(|| format!("{root_base}/reply")); let (resolve_value, resolve_label) = if resolved { ("0", g::t(lang, K::Unresolve)) } else { ("1", g::t(lang, K::Resolve)) }; let comments: Vec = std::iter::once(root) .chain(replies) .map(|comment| comment_row(comment, &ctx, now, lang)) .collect(); let parts = ThreadParts { location: location.clone(), outdated, pending, hunk, comments, resolve_action, resolve_value: resolve_value.to_owned(), resolve_label: resolve_label.to_owned(), reply_action, csrf: ctx.csrf.clone(), return_to, lang, }; // One `
` for every thread: open unless resolved, so a resolved thread starts // collapsed and can still be expanded. let summary = if resolved { g::resolved_conversation(lang, &location) } else { location }; Ok(view! {
(summary.as_str()) thread_parts(parts: parts)
}) } #[component] async fn thread_parts(parts: ThreadParts) -> Result { // Split into three siblings so each component's view stays small enough to type-check. let ThreadParts { location, outdated, pending, hunk, comments, resolve_action, resolve_value, resolve_label, reply_action, csrf, return_to, lang, } = parts; let footer = ThreadFooter { resolve_action, resolve_value, resolve_label, reply_action, csrf: csrf.clone(), return_to: return_to.clone(), lang, }; Ok(view! { thread_top(location: location, outdated: outdated, pending: pending, hunk: hunk, lang: lang) for comment in comments.into_iter() { thread_comment(comment: comment, csrf: csrf.clone(), return_to: return_to.clone(), lang: lang) } thread_footer(footer: footer) }) } /// Actions and the reply form at the foot of a thread. struct ThreadFooter { resolve_action: Option, resolve_value: String, resolve_label: String, reply_action: Option, csrf: String, return_to: String, lang: Lang, } #[component] async fn thread_footer(footer: ThreadFooter) -> Result { let ThreadFooter { resolve_action, resolve_value, resolve_label, reply_action, csrf, return_to, lang, } = footer; Ok(view! { thread_actions(resolve_action: resolve_action, resolve_value: resolve_value, resolve_label: resolve_label, csrf: csrf.clone(), return_to: return_to.clone()) if let Some(action) = reply_action { reply_form(action: action, csrf: csrf, return_to: return_to, lang: lang) } }) } /// The header (path, line, badges) and the optional diff hunk. #[component] async fn thread_top( location: String, outdated: bool, pending: bool, hunk: Option, lang: Lang, ) -> Result { let outdated_text = g::t(lang, K::Outdated); let pending_text = g::t(lang, K::Pending); Ok(view! {
(location.as_str()) if outdated { (outdated_text) } if pending { (pending_text) }
if let Some(text) = hunk {
(text.as_str())
} }) } /// The resolve or unresolve button. Empty when the viewer may not resolve. #[component] async fn thread_actions( resolve_action: Option, resolve_value: String, resolve_label: String, csrf: String, return_to: String, ) -> Result { Ok(view! {
if let Some(action) = resolve_action {
}
}) } #[component] async fn reply_form( action: String, csrf: String, return_to: String, lang: Lang, ) -> Result { let label = g::t(lang, K::ReplyLabel); let button = g::t(lang, K::ReplyButton); Ok(view! {
}) } #[component] async fn thread_comment( comment: CommentRow, csrf: String, return_to: String, lang: Lang, ) -> Result { let CommentRow { author, avatar, when, html, history_href, edit_href, delete_href, } = comment; let edited = g::t(lang, K::Edited); let edit = g::t(lang, K::EditLink); let delete = g::t(lang, K::DeleteButton); Ok(view! {
(author.as_str()) if let Some(href) = history_href { (edited) } if let Some(href) = edit_href { (edit) } if let Some(action) = delete_href {
}
(Unescaped::new_unchecked(html))
}) }