//! Page: one discussion at `/{owner}/{repo}/discussions/{n}`. The body comes first, then the //! comments, each top-level comment with its replies under it. Q&A discussions show the accepted //! answer and let the author or a Triage holder mark one. Edit and delete forms are `
` //! blocks, so no script is needed. The POST routes are in `discussion_actions`. use std::collections::HashMap; use forge_core::discussions::{Discussion, DiscussionCategory, DiscussionComment}; use forge_core::{ReactionMap, ReactionTarget}; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::{page, path_param}; use topcoat::view::{Unescaped, View, component, view}; use crate::i18n::community::{self as g, K}; use crate::i18n::{self, Lang}; use crate::pages::discussions::{ discussion_by_number, discussions_href, ensure_enabled, is_author, may_converse, }; use crate::pages::issue_meta::can_triage; use crate::pages::issue_parts::{ReactionBar, When, csrf_of, reaction_bar_for, reaction_row}; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::{RepoCtx, load_repo}; use crate::pages::tree::link_context; use crate::render::markdown::{LinkContext, render_markdown}; use crate::state::AppState; use crate::views::{avatar_url, display_name, now_unix, shell, user_href, user_link}; path_param!(owner); path_param!(repo); path_param!(number); /// One comment, with every string the template shows already built. pub(crate) struct CommentView { lang: Lang, anchor: String, author: String, /// The author's username, the link target of the name and avatar. login: String, avatar: String, when: When, /// Rendered Markdown. Empty when the comment is deleted. html: String, /// The raw text, for the edit form. raw: String, deleted: bool, /// The accepted answer of a Q&A discussion. accepted: bool, /// The comment's id, for the reply and answer forms. id: String, /// Edit and delete forms: set when the viewer wrote the comment. edit_action: Option, delete_action: Option, /// Reply form: set on top-level comments while the viewer may comment. reply_action: Option, /// Answer toggle: set on Q&A comments the viewer may mark. answer_action: Option, /// `1` marks the comment, `0` clears the mark. answer_value: &'static str, answer_label: &'static str, csrf: String, reactions: Option, } /// A top-level comment and its replies. pub(crate) struct Thread { root: CommentView, replies: Vec, } /// Everything the comment builder needs from the page, computed once. struct Frame { lang: Lang, base: String, csrf: String, tz: String, now: i64, names: HashMap)>, reactions: ReactionMap, reaction_action: String, can_react: bool, can_comment: bool, /// Whether the viewer may mark answers: the discussion's author or Triage, on a writable repo. can_mark: bool, qa: bool, answer_id: Option, viewer_id: Option, } fn comment_view( frame: &Frame, link: &LinkContext, c: &DiscussionComment, top_level: bool, ) -> CommentView { let (username, hash) = frame .names .get(&c.author_id) .cloned() .unwrap_or_else(|| ("ghost".to_owned(), None)); let mine = frame.viewer_id.as_deref() == Some(c.author_id.as_str()); let live = !c.deleted; let accepted = frame.answer_id.as_deref() == Some(c.id.as_str()); let reactions = if live { reaction_bar_for( frame.lang, frame.reactions.get(&c.id), frame.can_react, &frame.reaction_action, &frame.csrf, ReactionTarget::DiscussionComment, &c.id, ) } else { None }; CommentView { lang: frame.lang, anchor: format!("comment-{}", c.id), author: display_name(frame.lang, &username), login: username.clone(), avatar: avatar_url(&username, hash.as_deref()), when: When::new(frame.lang, &c.created_at, &frame.tz, frame.now), html: if live { render_markdown(&c.body, Some(link)) } else { String::new() }, raw: c.body.clone(), deleted: c.deleted, accepted, id: c.id.clone(), edit_action: (mine && live).then(|| format!("{}/comments/{}/edit", frame.base, c.id)), delete_action: (mine && live).then(|| format!("{}/comments/{}/delete", frame.base, c.id)), reply_action: (top_level && live && frame.can_comment) .then(|| format!("{}/comments", frame.base)), answer_action: (frame.qa && live && frame.can_mark) .then(|| format!("{}/answer", frame.base)), answer_value: if accepted { "0" } else { "1" }, answer_label: if accepted { g::t(frame.lang, K::UnmarkAnswer) } else { g::t(frame.lang, K::MarkAsAnswer) }, csrf: frame.csrf.clone(), reactions, } } /// One comment, with its reactions and the forms the viewer may use. #[component] async fn comment_article(item: CommentView) -> Result { let CommentView { lang, anchor, author, login, avatar, when, html, raw, deleted, accepted, id, edit_action, delete_action, reply_action, answer_action, answer_value, answer_label, csrf, reactions, } = item; let profile = user_href(&login); let class = if accepted { "issue-item discussion-accepted" } else { "issue-item" }; Ok(view! {
if deleted { (g::t(lang, K::CommentDeleted)) } else { user_link(shown: author.as_str(), login: login.as_str()) } if accepted { (g::t(lang, K::Answer)) }
if !deleted {
(Unescaped::new_unchecked(html))
if let Some(bar) = reactions { reaction_row(lang: lang, bar: bar) }
if let Some(action) = answer_action {
} if let Some(action) = reply_action {
(g::t(lang, K::Reply))
} if let Some(action) = edit_action {
(g::t(lang, K::Edit))
} if let Some(action) = delete_action {
}
}
}) } /// A top-level comment with its replies indented under it. #[component] async fn comment_thread(root: CommentView, replies: Vec) -> Result { Ok(view! {
comment_article(item: root) if !replies.is_empty() {
for reply in replies.into_iter() { comment_article(item: reply) }
}
}) } #[page("/{owner}/{repo}/discussions/{number}")] async fn discussion_show(cx: &Cx) -> Result { let number = path_param::(cx); let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; let state = app_context::(cx); ensure_enabled(state, &ctx).await?; let discussion = discussion_by_number(state, &ctx, number).await?; discussion_page(cx, state, ctx, discussion).await } /// The discussion page: header, body, comments, the comment form and the sidebar. async fn discussion_page( cx: &Cx, state: &AppState, ctx: RepoCtx, discussion: Discussion, ) -> Result { let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user)); let owner = ctx.repo.owner_name.clone(); let name = ctx.repo.name.clone(); let number = discussion.number; let base = discussions_href(&owner, &name, &number.to_string()); let viewer_id = ctx.viewer.as_ref().map(|(user, _)| user.id.clone()); let tz = ctx.viewer_tz().to_owned(); let now = now_unix(); let csrf = csrf_of(&ctx); let triage = can_triage(&ctx); let archived = ctx.repo.archived; let signed_in = ctx.viewer.is_some(); let can_converse = may_converse(&ctx, discussion.locked); let qa = discussion.category == DiscussionCategory::Qa; let comments = state.store.list_discussion_comments(&discussion.id).await?; let mut author_ids: Vec = comments.iter().map(|c| c.author_id.clone()).collect(); author_ids.push(discussion.author_id.clone()); let names = state.store.usernames_by_ids(&author_ids).await?; let comment_ids: Vec = comments.iter().map(|c| c.id.clone()).collect(); let reactions = state .store .reactions_for( ReactionTarget::DiscussionComment, &comment_ids, viewer_id.as_deref(), ) .await?; let body_reactions = state .store .reactions_for( ReactionTarget::Discussion, std::slice::from_ref(&discussion.id), viewer_id.as_deref(), ) .await?; let reaction_action = discussions_href(&owner, &name, &format!("{number}/reactions")); let link = link_context(state, &ctx.repo, &ctx.repo.default_branch, ""); let frame = Frame { lang, base: base.clone(), csrf: csrf.clone(), tz: tz.clone(), now, names: names.clone(), reactions, reaction_action: reaction_action.clone(), can_react: can_converse, can_comment: can_converse, can_mark: !archived && (triage || is_author(&ctx, &discussion.author_id)), qa, answer_id: discussion.answer_comment_id.clone(), viewer_id: viewer_id.clone(), }; // Top-level comments in order, each with its replies in order. let mut replies: HashMap> = HashMap::new(); for c in comments.iter().filter(|c| c.parent_id.is_some()) { if let Some(parent) = &c.parent_id { replies.entry(parent.clone()).or_default().push(c); } } let threads: Vec = comments .iter() .filter(|c| c.parent_id.is_none()) .map(|c| Thread { root: comment_view(&frame, &link, c, true), replies: replies .get(&c.id) .map(|list| { list.iter() .map(|r| comment_view(&frame, &link, r, false)) .collect() }) .unwrap_or_default(), }) .collect(); // The discussion body, with the viewer's controls. let (author_name, author_hash) = names .get(&discussion.author_id) .cloned() .unwrap_or_else(|| ("ghost".to_owned(), None)); let author = display_name(lang, &author_name); let author_avatar = avatar_url(&author_name, author_hash.as_deref()); let author_profile = user_href(&author_name); let author_login = author_name.clone(); let opened = When::new(lang, &discussion.created_at, &tz, now); let body_source = if discussion.body.trim().is_empty() { g::t(lang, K::NoDescription) } else { discussion.body.as_str() }; let body_html = render_markdown(body_source, Some(&link)); let mine = is_author(&ctx, &discussion.author_id); let edit_action = format!("{base}/edit"); let delete_action = format!("{base}/delete"); let lock_action = format!("{base}/lock"); let comment_action = format!("{base}/comments"); let body_bar = reaction_bar_for( lang, body_reactions.get(&discussion.id), can_converse, &reaction_action, &csrf, ReactionTarget::Discussion, &discussion.id, ); let body_mine_actions = !archived && mine; let locked = discussion.locked; let lock_value = if locked { "0" } else { "1" }; let lock_label = if locked { g::t(lang, K::UnlockConversation) } else { g::t(lang, K::LockConversation) }; let category = g::category_label(lang, discussion.category); let answered = qa && discussion.answer_comment_id.is_some(); let number_label = format!("#{number}"); let page_title = format!("{} {number_label} \u{b7} {owner}/{name}", discussion.title); let login_href = "/login".to_owned(); let lock_notice = g::t(lang, K::LockedNotice); let header = ctx.head(state, "discussions").await?; let comments_label = g::comments_total(lang, discussion.comment_count); Ok(view! { cx => shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(), repo_head(head: header)

(discussion.title.as_str()) " " (number_label.as_str())

user_link(shown: author.as_str(), login: author_login.as_str())
(Unescaped::new_unchecked(body_html))
if let Some(bar) = body_bar { reaction_row(lang: lang, bar: bar) } if body_mine_actions {
(g::t(lang, K::Edit))
}
for thread in threads.into_iter() { comment_thread(root: thread.root, replies: thread.replies) } if can_converse {
} else if locked && signed_in && !archived {

(lock_notice)

} else if !signed_in {

(g::comment_login_lead(lang))(g::t(lang, K::LogInLink))(g::t(lang, K::ToCommentSuffix))

} if archived {

(g::t(lang, K::ArchivedDiscussions))

}
) }) }