forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
20.4 KiB
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
6use std::collections::HashMap;
7
8use forge_core::discussions::{Discussion, DiscussionCategory, DiscussionComment};
9use forge_core::{ReactionMap, ReactionTarget};
10use topcoat::Result;
11use topcoat::context::{Cx, app_context};
12use topcoat::router::{page, path_param};
13use topcoat::view::{Unescaped, View, component, view};
14
15use crate::i18n::community::{self as g, K};
16use crate::i18n::{self, Lang};
17use crate::pages::discussions::{
18 discussion_by_number, discussions_href, ensure_enabled, is_author, may_converse,
19};
20use crate::pages::issue_meta::can_triage;
21use crate::pages::issue_parts::{ReactionBar, When, csrf_of, reaction_bar_for, reaction_row};
22use crate::pages::repo::repo_head;
23use crate::pages::repo_ctx::{RepoCtx, load_repo};
24use crate::pages::tree::link_context;
25use crate::render::markdown::{LinkContext, render_markdown};
26use crate::state::AppState;
27use crate::views::{avatar_url, display_name, now_unix, shell, user_href, user_link};
28
29path_param!(owner);
30path_param!(repo);
31path_param!(number);
32
33/// One comment, with every string the template shows already built.
34pub(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.
66pub(crate) struct Thread {
67 root: CommentView,
68 replies: Vec<CommentView>,
69}
70
71/// Everything the comment builder needs from the page, computed once.
72struct 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
90fn 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]
152async 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]
243async 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}")]
259async 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.
269async 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}