Tahinli/forge
public0 watching
★ 0
| 1 | //! Page: one discussion at `/{owner}/{repo}/discussions/{n}`. The body comes first, then the |
| 2 | //! comments, each top-level comment with its replies under it. Q&A discussions show the accepted |
| 3 | //! answer and let the author or a Triage holder mark one. Edit and delete forms are `<details>` |
| 4 | //! blocks, so no script is needed. The POST routes are in `discussion_actions`. |
| 5 | |
| 6 | use std::collections::HashMap; |
| 7 | |
| 8 | use forge_core::discussions::{Discussion, DiscussionCategory, DiscussionComment}; |
| 9 | use forge_core::{ReactionMap, ReactionTarget}; |
| 10 | use topcoat::Result; |
| 11 | use topcoat::context::{Cx, app_context}; |
| 12 | use topcoat::router::{page, path_param}; |
| 13 | use topcoat::view::{Unescaped, View, component, view}; |
| 14 | |
| 15 | use crate::i18n::community::{self as g, K}; |
| 16 | use crate::i18n::{self, Lang}; |
| 17 | use crate::pages::discussions::{ |
| 18 | discussion_by_number, discussions_href, ensure_enabled, is_author, may_converse, |
| 19 | }; |
| 20 | use crate::pages::issue_meta::can_triage; |
| 21 | use crate::pages::issue_parts::{ReactionBar, When, csrf_of, reaction_bar_for, reaction_row}; |
| 22 | use crate::pages::repo::repo_head; |
| 23 | use crate::pages::repo_ctx::{RepoCtx, load_repo}; |
| 24 | use crate::pages::tree::link_context; |
| 25 | use crate::render::markdown::{LinkContext, render_markdown}; |
| 26 | use crate::state::AppState; |
| 27 | use crate::views::{avatar_url, display_name, now_unix, shell, user_href, user_link}; |
| 28 | |
| 29 | path_param!(owner); |
| 30 | path_param!(repo); |
| 31 | path_param!(number); |
| 32 | |
| 33 | /// One comment, with every string the template shows already built. |
| 34 | pub(crate) struct CommentView { |
| 35 | lang: Lang, |
| 36 | anchor: String, |
| 37 | author: String, |
| 38 | /// The author's username, the link target of the name and avatar. |
| 39 | login: String, |
| 40 | avatar: String, |
| 41 | when: When, |
| 42 | /// Rendered Markdown. Empty when the comment is deleted. |
| 43 | html: String, |
| 44 | /// The raw text, for the edit form. |
| 45 | raw: String, |
| 46 | deleted: bool, |
| 47 | /// The accepted answer of a Q&A discussion. |
| 48 | accepted: bool, |
| 49 | /// The comment's id, for the reply and answer forms. |
| 50 | id: String, |
| 51 | /// Edit and delete forms: set when the viewer wrote the comment. |
| 52 | edit_action: Option<String>, |
| 53 | delete_action: Option<String>, |
| 54 | /// Reply form: set on top-level comments while the viewer may comment. |
| 55 | reply_action: Option<String>, |
| 56 | /// Answer toggle: set on Q&A comments the viewer may mark. |
| 57 | answer_action: Option<String>, |
| 58 | /// `1` marks the comment, `0` clears the mark. |
| 59 | answer_value: &'static str, |
| 60 | answer_label: &'static str, |
| 61 | csrf: String, |
| 62 | reactions: Option<ReactionBar>, |
| 63 | } |
| 64 | |
| 65 | /// A top-level comment and its replies. |
| 66 | pub(crate) struct Thread { |
| 67 | root: CommentView, |
| 68 | replies: Vec<CommentView>, |
| 69 | } |
| 70 | |
| 71 | /// Everything the comment builder needs from the page, computed once. |
| 72 | struct Frame { |
| 73 | lang: Lang, |
| 74 | base: String, |
| 75 | csrf: String, |
| 76 | tz: String, |
| 77 | now: i64, |
| 78 | names: HashMap<String, (String, Option<String>)>, |
| 79 | reactions: ReactionMap, |
| 80 | reaction_action: String, |
| 81 | can_react: bool, |
| 82 | can_comment: bool, |
| 83 | /// Whether the viewer may mark answers: the discussion's author or Triage, on a writable repo. |
| 84 | can_mark: bool, |
| 85 | qa: bool, |
| 86 | answer_id: Option<String>, |
| 87 | viewer_id: Option<String>, |
| 88 | } |
| 89 | |
| 90 | fn comment_view( |
| 91 | frame: &Frame, |
| 92 | link: &LinkContext, |
| 93 | c: &DiscussionComment, |
| 94 | top_level: bool, |
| 95 | ) -> CommentView { |
| 96 | let (username, hash) = frame |
| 97 | .names |
| 98 | .get(&c.author_id) |
| 99 | .cloned() |
| 100 | .unwrap_or_else(|| ("ghost".to_owned(), None)); |
| 101 | let mine = frame.viewer_id.as_deref() == Some(c.author_id.as_str()); |
| 102 | let live = !c.deleted; |
| 103 | let accepted = frame.answer_id.as_deref() == Some(c.id.as_str()); |
| 104 | let reactions = if live { |
| 105 | reaction_bar_for( |
| 106 | frame.lang, |
| 107 | frame.reactions.get(&c.id), |
| 108 | frame.can_react, |
| 109 | &frame.reaction_action, |
| 110 | &frame.csrf, |
| 111 | ReactionTarget::DiscussionComment, |
| 112 | &c.id, |
| 113 | ) |
| 114 | } else { |
| 115 | None |
| 116 | }; |
| 117 | CommentView { |
| 118 | lang: frame.lang, |
| 119 | anchor: format!("comment-{}", c.id), |
| 120 | author: display_name(frame.lang, &username), |
| 121 | login: username.clone(), |
| 122 | avatar: avatar_url(&username, hash.as_deref()), |
| 123 | when: When::new(frame.lang, &c.created_at, &frame.tz, frame.now), |
| 124 | html: if live { |
| 125 | render_markdown(&c.body, Some(link)) |
| 126 | } else { |
| 127 | String::new() |
| 128 | }, |
| 129 | raw: c.body.clone(), |
| 130 | deleted: c.deleted, |
| 131 | accepted, |
| 132 | id: c.id.clone(), |
| 133 | edit_action: (mine && live).then(|| format!("{}/comments/{}/edit", frame.base, c.id)), |
| 134 | delete_action: (mine && live).then(|| format!("{}/comments/{}/delete", frame.base, c.id)), |
| 135 | reply_action: (top_level && live && frame.can_comment) |
| 136 | .then(|| format!("{}/comments", frame.base)), |
| 137 | answer_action: (frame.qa && live && frame.can_mark) |
| 138 | .then(|| format!("{}/answer", frame.base)), |
| 139 | answer_value: if accepted { "0" } else { "1" }, |
| 140 | answer_label: if accepted { |
| 141 | g::t(frame.lang, K::UnmarkAnswer) |
| 142 | } else { |
| 143 | g::t(frame.lang, K::MarkAsAnswer) |
| 144 | }, |
| 145 | csrf: frame.csrf.clone(), |
| 146 | reactions, |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | /// One comment, with its reactions and the forms the viewer may use. |
| 151 | #[component] |
| 152 | async fn comment_article(item: CommentView) -> Result<impl View> { |
| 153 | let CommentView { |
| 154 | lang, |
| 155 | anchor, |
| 156 | author, |
| 157 | login, |
| 158 | avatar, |
| 159 | when, |
| 160 | html, |
| 161 | raw, |
| 162 | deleted, |
| 163 | accepted, |
| 164 | id, |
| 165 | edit_action, |
| 166 | delete_action, |
| 167 | reply_action, |
| 168 | answer_action, |
| 169 | answer_value, |
| 170 | answer_label, |
| 171 | csrf, |
| 172 | reactions, |
| 173 | } = item; |
| 174 | let profile = user_href(&login); |
| 175 | let class = if accepted { |
| 176 | "issue-item discussion-accepted" |
| 177 | } else { |
| 178 | "issue-item" |
| 179 | }; |
| 180 | Ok(view! { |
| 181 | <article class=(class) id=(anchor.as_str())> |
| 182 | <header class="issue-item-head"> |
| 183 | if deleted { |
| 184 | <span class="muted">(g::t(lang, K::CommentDeleted))</span> |
| 185 | } else { |
| 186 | <a href=(profile.as_str())><img class="avatar avatar-sm" src=(avatar.as_str()) alt="" width="24" height="24"></a> |
| 187 | <strong>user_link(shown: author.as_str(), login: login.as_str())</strong> |
| 188 | <time datetime=(when.iso.as_str()) title=(when.absolute.as_str())>(when.relative.as_str())</time> |
| 189 | } |
| 190 | if accepted { |
| 191 | <span class="issue-badge issue-badge-completed">(g::t(lang, K::Answer))</span> |
| 192 | } |
| 193 | </header> |
| 194 | if !deleted { |
| 195 | <div class="issue-item-body markdown-body">(Unescaped::new_unchecked(html))</div> |
| 196 | if let Some(bar) = reactions { |
| 197 | reaction_row(lang: lang, bar: bar) |
| 198 | } |
| 199 | <div class="issue-item-actions"> |
| 200 | if let Some(action) = answer_action { |
| 201 | <form method="post" action=(action.as_str())> |
| 202 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 203 | <input type="hidden" name="comment_id" value=(id.as_str())> |
| 204 | <input type="hidden" name="answer" value=(answer_value)> |
| 205 | <button type="submit" class="link-button">(answer_label)</button> |
| 206 | </form> |
| 207 | } |
| 208 | if let Some(action) = reply_action { |
| 209 | <details class="discussion-reply"> |
| 210 | <summary>(g::t(lang, K::Reply))</summary> |
| 211 | <form method="post" action=(action.as_str()) class="stack"> |
| 212 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 213 | <input type="hidden" name="parent" value=(id.as_str())> |
| 214 | <textarea name="body" rows="4" aria-label=(g::t(lang, K::Reply))></textarea> |
| 215 | <div><button type="submit">(g::t(lang, K::Reply))</button></div> |
| 216 | </form> |
| 217 | </details> |
| 218 | } |
| 219 | if let Some(action) = edit_action { |
| 220 | <details class="discussion-reply"> |
| 221 | <summary>(g::t(lang, K::Edit))</summary> |
| 222 | <form method="post" action=(action.as_str()) class="stack"> |
| 223 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 224 | <textarea name="body" rows="6" aria-label=(g::t(lang, K::EditComment))>(raw.as_str())</textarea> |
| 225 | <div><button type="submit">(g::t(lang, K::Save))</button></div> |
| 226 | </form> |
| 227 | </details> |
| 228 | } |
| 229 | if let Some(action) = delete_action { |
| 230 | <form method="post" action=(action.as_str())> |
| 231 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 232 | <button type="submit" class="link-button">(g::t(lang, K::Delete))</button> |
| 233 | </form> |
| 234 | } |
| 235 | </div> |
| 236 | } |
| 237 | </article> |
| 238 | }) |
| 239 | } |
| 240 | |
| 241 | /// A top-level comment with its replies indented under it. |
| 242 | #[component] |
| 243 | async fn comment_thread(root: CommentView, replies: Vec<CommentView>) -> Result<impl View> { |
| 244 | Ok(view! { |
| 245 | <div class="discussion-thread"> |
| 246 | comment_article(item: root) |
| 247 | if !replies.is_empty() { |
| 248 | <div class="discussion-replies"> |
| 249 | for reply in replies.into_iter() { |
| 250 | comment_article(item: reply) |
| 251 | } |
| 252 | </div> |
| 253 | } |
| 254 | </div> |
| 255 | }) |
| 256 | } |
| 257 | |
| 258 | #[page("/{owner}/{repo}/discussions/{number}")] |
| 259 | async fn discussion_show(cx: &Cx) -> Result<impl View> { |
| 260 | let number = path_param::<Number>(cx); |
| 261 | let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?; |
| 262 | let state = app_context::<AppState>(cx); |
| 263 | ensure_enabled(state, &ctx).await?; |
| 264 | let discussion = discussion_by_number(state, &ctx, number).await?; |
| 265 | discussion_page(cx, state, ctx, discussion).await |
| 266 | } |
| 267 | |
| 268 | /// The discussion page: header, body, comments, the comment form and the sidebar. |
| 269 | async fn discussion_page( |
| 270 | cx: &Cx, |
| 271 | state: &AppState, |
| 272 | ctx: RepoCtx, |
| 273 | discussion: Discussion, |
| 274 | ) -> Result<impl View> { |
| 275 | let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user)); |
| 276 | let owner = ctx.repo.owner_name.clone(); |
| 277 | let name = ctx.repo.name.clone(); |
| 278 | let number = discussion.number; |
| 279 | let base = discussions_href(&owner, &name, &number.to_string()); |
| 280 | let viewer_id = ctx.viewer.as_ref().map(|(user, _)| user.id.clone()); |
| 281 | let tz = ctx.viewer_tz().to_owned(); |
| 282 | let now = now_unix(); |
| 283 | let csrf = csrf_of(&ctx); |
| 284 | let triage = can_triage(&ctx); |
| 285 | let archived = ctx.repo.archived; |
| 286 | let signed_in = ctx.viewer.is_some(); |
| 287 | let can_converse = may_converse(&ctx, discussion.locked); |
| 288 | let qa = discussion.category == DiscussionCategory::Qa; |
| 289 | |
| 290 | let comments = state.store.list_discussion_comments(&discussion.id).await?; |
| 291 | let mut author_ids: Vec<String> = comments.iter().map(|c| c.author_id.clone()).collect(); |
| 292 | author_ids.push(discussion.author_id.clone()); |
| 293 | let names = state.store.usernames_by_ids(&author_ids).await?; |
| 294 | let comment_ids: Vec<String> = comments.iter().map(|c| c.id.clone()).collect(); |
| 295 | let reactions = state |
| 296 | .store |
| 297 | .reactions_for( |
| 298 | ReactionTarget::DiscussionComment, |
| 299 | &comment_ids, |
| 300 | viewer_id.as_deref(), |
| 301 | ) |
| 302 | .await?; |
| 303 | let body_reactions = state |
| 304 | .store |
| 305 | .reactions_for( |
| 306 | ReactionTarget::Discussion, |
| 307 | std::slice::from_ref(&discussion.id), |
| 308 | viewer_id.as_deref(), |
| 309 | ) |
| 310 | .await?; |
| 311 | |
| 312 | let reaction_action = discussions_href(&owner, &name, &format!("{number}/reactions")); |
| 313 | let link = link_context(state, &ctx.repo, &ctx.repo.default_branch, ""); |
| 314 | let frame = Frame { |
| 315 | lang, |
| 316 | base: base.clone(), |
| 317 | csrf: csrf.clone(), |
| 318 | tz: tz.clone(), |
| 319 | now, |
| 320 | names: names.clone(), |
| 321 | reactions, |
| 322 | reaction_action: reaction_action.clone(), |
| 323 | can_react: can_converse, |
| 324 | can_comment: can_converse, |
| 325 | can_mark: !archived && (triage || is_author(&ctx, &discussion.author_id)), |
| 326 | qa, |
| 327 | answer_id: discussion.answer_comment_id.clone(), |
| 328 | viewer_id: viewer_id.clone(), |
| 329 | }; |
| 330 | |
| 331 | // Top-level comments in order, each with its replies in order. |
| 332 | let mut replies: HashMap<String, Vec<&DiscussionComment>> = HashMap::new(); |
| 333 | for c in comments.iter().filter(|c| c.parent_id.is_some()) { |
| 334 | if let Some(parent) = &c.parent_id { |
| 335 | replies.entry(parent.clone()).or_default().push(c); |
| 336 | } |
| 337 | } |
| 338 | let threads: Vec<Thread> = comments |
| 339 | .iter() |
| 340 | .filter(|c| c.parent_id.is_none()) |
| 341 | .map(|c| Thread { |
| 342 | root: comment_view(&frame, &link, c, true), |
| 343 | replies: replies |
| 344 | .get(&c.id) |
| 345 | .map(|list| { |
| 346 | list.iter() |
| 347 | .map(|r| comment_view(&frame, &link, r, false)) |
| 348 | .collect() |
| 349 | }) |
| 350 | .unwrap_or_default(), |
| 351 | }) |
| 352 | .collect(); |
| 353 | |
| 354 | // The discussion body, with the viewer's controls. |
| 355 | let (author_name, author_hash) = names |
| 356 | .get(&discussion.author_id) |
| 357 | .cloned() |
| 358 | .unwrap_or_else(|| ("ghost".to_owned(), None)); |
| 359 | let author = display_name(lang, &author_name); |
| 360 | let author_avatar = avatar_url(&author_name, author_hash.as_deref()); |
| 361 | let author_profile = user_href(&author_name); |
| 362 | let author_login = author_name.clone(); |
| 363 | let opened = When::new(lang, &discussion.created_at, &tz, now); |
| 364 | let body_source = if discussion.body.trim().is_empty() { |
| 365 | g::t(lang, K::NoDescription) |
| 366 | } else { |
| 367 | discussion.body.as_str() |
| 368 | }; |
| 369 | let body_html = render_markdown(body_source, Some(&link)); |
| 370 | let mine = is_author(&ctx, &discussion.author_id); |
| 371 | let edit_action = format!("{base}/edit"); |
| 372 | let delete_action = format!("{base}/delete"); |
| 373 | let lock_action = format!("{base}/lock"); |
| 374 | let comment_action = format!("{base}/comments"); |
| 375 | let body_bar = reaction_bar_for( |
| 376 | lang, |
| 377 | body_reactions.get(&discussion.id), |
| 378 | can_converse, |
| 379 | &reaction_action, |
| 380 | &csrf, |
| 381 | ReactionTarget::Discussion, |
| 382 | &discussion.id, |
| 383 | ); |
| 384 | let body_mine_actions = !archived && mine; |
| 385 | let locked = discussion.locked; |
| 386 | let lock_value = if locked { "0" } else { "1" }; |
| 387 | let lock_label = if locked { |
| 388 | g::t(lang, K::UnlockConversation) |
| 389 | } else { |
| 390 | g::t(lang, K::LockConversation) |
| 391 | }; |
| 392 | let category = g::category_label(lang, discussion.category); |
| 393 | let answered = qa && discussion.answer_comment_id.is_some(); |
| 394 | let number_label = format!("#{number}"); |
| 395 | let page_title = format!("{} {number_label} \u{b7} {owner}/{name}", discussion.title); |
| 396 | let login_href = "/login".to_owned(); |
| 397 | let lock_notice = g::t(lang, K::LockedNotice); |
| 398 | let header = ctx.head(state, "discussions").await?; |
| 399 | |
| 400 | let comments_label = g::comments_total(lang, discussion.comment_count); |
| 401 | Ok(view! { cx => |
| 402 | shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(), |
| 403 | repo_head(head: header) |
| 404 | <section class="issue-head"> |
| 405 | <h1 class="issue-heading">(discussion.title.as_str()) " " <span class="issue-number">(number_label.as_str())</span></h1> |
| 406 | <p class="issue-byline"> |
| 407 | <span class="label-chip">(category)</span> |
| 408 | if answered { |
| 409 | " " <span class="issue-badge issue-badge-completed">(g::t(lang, K::Answered))</span> |
| 410 | } |
| 411 | if locked { |
| 412 | " " <span class="issue-badge">(g::t(lang, K::Locked))</span> |
| 413 | } |
| 414 | " " <strong>user_link(shown: author.as_str(), login: author_login.as_str())</strong> |
| 415 | (g::t(lang, K::OpenedThis)) |
| 416 | <time datetime=(opened.iso.as_str()) title=(opened.absolute.as_str())>(opened.relative.as_str())</time> |
| 417 | (g::t(lang, K::OpenedSeparator)) |
| 418 | (comments_label.as_str()) |
| 419 | </p> |
| 420 | </section> |
| 421 | <div class="issue-layout"> |
| 422 | <div class="issue-column"> |
| 423 | <article class="issue-item" id="discussion-body"> |
| 424 | <header class="issue-item-head"> |
| 425 | <a href=(author_profile.as_str())><img class="avatar avatar-sm" src=(author_avatar.as_str()) alt="" width="24" height="24"></a> |
| 426 | <strong>user_link(shown: author.as_str(), login: author_login.as_str())</strong> |
| 427 | </header> |
| 428 | <div class="issue-item-body markdown-body">(Unescaped::new_unchecked(body_html))</div> |
| 429 | if let Some(bar) = body_bar { |
| 430 | reaction_row(lang: lang, bar: bar) |
| 431 | } |
| 432 | if body_mine_actions { |
| 433 | <details class="discussion-reply"> |
| 434 | <summary>(g::t(lang, K::Edit))</summary> |
| 435 | <form method="post" action=(edit_action.as_str()) class="stack"> |
| 436 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 437 | <label><span>(g::t(lang, K::Title))</span> |
| 438 | <input type="text" name="title" value=(discussion.title.as_str()) required=(true) autocomplete="off"> |
| 439 | </label> |
| 440 | <label><span>(g::t(lang, K::Description))</span> |
| 441 | <textarea name="body" rows="8">(discussion.body.as_str())</textarea> |
| 442 | </label> |
| 443 | <div><button type="submit">(g::t(lang, K::Save))</button></div> |
| 444 | </form> |
| 445 | </details> |
| 446 | <form method="post" action=(delete_action.as_str()) class="inline-form"> |
| 447 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 448 | <button type="submit" class="link-button">(g::t(lang, K::DeleteDiscussion))</button> |
| 449 | </form> |
| 450 | } |
| 451 | </article> |
| 452 | for thread in threads.into_iter() { |
| 453 | comment_thread(root: thread.root, replies: thread.replies) |
| 454 | } |
| 455 | if can_converse { |
| 456 | <section class="card"> |
| 457 | <form method="post" action=(comment_action.as_str()) class="stack"> |
| 458 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 459 | <label> |
| 460 | <span>(g::t(lang, K::AddComment))</span> |
| 461 | <textarea name="body" rows="6"></textarea> |
| 462 | <span class="muted small">(g::t(lang, K::MarkdownSupported))</span> |
| 463 | </label> |
| 464 | <div><button type="submit">(g::t(lang, K::CommentButton))</button></div> |
| 465 | </form> |
| 466 | </section> |
| 467 | } else if locked && signed_in && !archived { |
| 468 | <p class="muted">(lock_notice)</p> |
| 469 | } else if !signed_in { |
| 470 | <p class="muted">(g::comment_login_lead(lang))<a href=(login_href.as_str())>(g::t(lang, K::LogInLink))</a>(g::t(lang, K::ToCommentSuffix))</p> |
| 471 | } |
| 472 | if archived { |
| 473 | <p class="muted">(g::t(lang, K::ArchivedDiscussions))</p> |
| 474 | } |
| 475 | </div> |
| 476 | <aside class="issue-sidebar"> |
| 477 | if triage && !archived { |
| 478 | <section class="card"> |
| 479 | <h2>(g::t(lang, K::Conversation))</h2> |
| 480 | <form method="post" action=(lock_action.as_str()) class="stack"> |
| 481 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 482 | <input type="hidden" name="locked" value=(lock_value)> |
| 483 | <button type="submit">(lock_label)</button> |
| 484 | </form> |
| 485 | </section> |
| 486 | } |
| 487 | </aside> |
| 488 | </div> |
| 489 | ) |
| 490 | }) |
| 491 | } |