forge
ExploreLog in

Tahinli/forge

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