forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
12.1 KiB
1//! One review thread: a root comment and its replies, with the path and line, badges, an optional
2//! diff hunk, and the reply and resolve controls. The Files tab renders it under its diff line;
3//! the Conversation tab (`return_to = "conversation"`) renders it in the timeline. Resolved
4//! threads collapse into a `<details>`. Markup uses the `review-*` classes.
5
6use std::collections::HashMap;
7use std::sync::Arc;
8
9use forge_core::{ReviewComment, Thread};
10use topcoat::Result;
11use topcoat::view::{Unescaped, View, component, view};
12
13use crate::i18n::Lang;
14use crate::i18n::pulls_review::{self as g, K};
15use crate::pages::issue_parts::When;
16use crate::render::markdown::{LinkContext, render_markdown};
17use crate::views::{avatar_url, display_name, now_unix, user_href, user_link};
18
19/// What a thread needs from its page: where the review POSTs go, the CSRF token, who is viewing
20/// and what they may do. Built once per page and cloned into each thread.
21#[derive(Clone, Default)]
22pub struct ThreadCtx {
23 /// `/{owner}/{repo}/pulls/{n}`, the base of the review routes.
24 pub pr_href: String,
25 pub csrf: String,
26 /// The signed-in viewer's user id, if any. Decides which comments are theirs.
27 pub viewer_id: Option<String>,
28 /// Signed in on an unarchived repo: may reply, and may edit or delete their own comments.
29 pub can_comment: bool,
30 /// The PR author or a Write holder: may resolve threads.
31 pub can_resolve: bool,
32 /// Triage or better: may delete any comment.
33 pub can_moderate: bool,
34 /// Signed in, unarchived and the PR is open: may start a review.
35 pub can_review: bool,
36 /// Timezone for the relative-time titles.
37 pub tz: String,
38 /// Link context for the markdown bodies, as issue comments use.
39 pub link: Option<LinkContext>,
40 /// Earlier versions per review comment id. A comment in here with a count shows "edited".
41 pub edits: Arc<HashMap<String, u64>>,
42}
43
44/// One comment of a thread, flattened for the view.
45#[derive(Clone)]
46struct CommentRow {
47 author: String,
48 login: String,
49 avatar: String,
50 when: When,
51 html: String,
52 history_href: Option<String>,
53 edit_href: Option<String>,
54 delete_href: Option<String>,
55}
56
57/// The controls and content of a thread, shared by the open and the collapsed (resolved) forms.
58struct ThreadParts {
59 location: String,
60 outdated: bool,
61 pending: bool,
62 hunk: Option<String>,
63 comments: Vec<CommentRow>,
64 resolve_action: Option<String>,
65 resolve_value: String,
66 resolve_label: String,
67 reply_action: Option<String>,
68 csrf: String,
69 return_to: String,
70 lang: Lang,
71}
72
73fn comment_row(comment: ReviewComment, ctx: &ThreadCtx, now: i64, lang: Lang) -> CommentRow {
74 let mine = ctx.viewer_id.as_deref() == Some(comment.author_id.as_str());
75 let base = format!("{}/review_comments/{}", ctx.pr_href, comment.id);
76 let edited = ctx.edits.get(&comment.id).is_some_and(|count| *count > 0);
77 CommentRow {
78 avatar: avatar_url(&comment.author_name, None),
79 author: display_name(lang, &comment.author_name),
80 login: comment.author_name.clone(),
81 when: When::new(lang, &comment.created_at, &ctx.tz, now),
82 html: render_markdown(&comment.body, ctx.link.as_ref()),
83 history_href: edited
84 .then(|| format!("{}/history/review_comment/{}", ctx.pr_href, comment.id)),
85 edit_href: (mine && ctx.can_comment).then(|| format!("{base}/edit")),
86 delete_href: ((mine || ctx.can_moderate) && ctx.can_comment)
87 .then(|| format!("{base}/delete")),
88 }
89}
90
91/// One thread, in English. Pages that know the viewer's language use `review_thread_in`.
92/// `show_hunk` adds the stored diff hunk as a code block; `return_to` is `files` or
93/// `conversation` and tells the POST routes which tab to redirect to.
94#[component]
95pub async fn review_thread(
96 thread: Thread,
97 ctx: ThreadCtx,
98 show_hunk: bool,
99 return_to: &str,
100) -> Result<impl View> {
101 Ok(view! {
102 review_thread_in(lang: Lang::En, thread: thread, ctx: ctx, show_hunk: show_hunk,
103 return_to: return_to)
104 })
105}
106
107/// One thread with its strings in `lang`. See `review_thread` for the arguments.
108#[component]
109pub async fn review_thread_in(
110 lang: Lang,
111 thread: Thread,
112 ctx: ThreadCtx,
113 show_hunk: bool,
114 return_to: &str,
115) -> Result<impl View> {
116 let now = now_unix();
117 let return_to = return_to.to_owned();
118 let Thread { root, replies } = thread;
119 let anchor = format!("rc-{}", root.id);
120 let location = format!("{}:{}", root.path, root.line);
121 let hunk = (show_hunk && !root.diff_hunk.is_empty()).then(|| root.diff_hunk.clone());
122 let resolved = root.resolved;
123 let pending = root.pending;
124 let outdated = root.outdated;
125 let root_base = format!("{}/review_comments/{}", ctx.pr_href, root.id);
126 let resolve_action = (ctx.can_resolve && !pending).then(|| format!("{root_base}/resolve"));
127 let reply_action = (ctx.can_comment && !pending).then(|| format!("{root_base}/reply"));
128 let (resolve_value, resolve_label) = if resolved {
129 ("0", g::t(lang, K::Unresolve))
130 } else {
131 ("1", g::t(lang, K::Resolve))
132 };
133 let comments: Vec<CommentRow> = std::iter::once(root)
134 .chain(replies)
135 .map(|comment| comment_row(comment, &ctx, now, lang))
136 .collect();
137
138 let parts = ThreadParts {
139 location: location.clone(),
140 outdated,
141 pending,
142 hunk,
143 comments,
144 resolve_action,
145 resolve_value: resolve_value.to_owned(),
146 resolve_label: resolve_label.to_owned(),
147 reply_action,
148 csrf: ctx.csrf.clone(),
149 return_to,
150 lang,
151 };
152
153 // One `<details>` for every thread: open unless resolved, so a resolved thread starts
154 // collapsed and can still be expanded.
155 let summary = if resolved {
156 g::resolved_conversation(lang, &location)
157 } else {
158 location
159 };
160 Ok(view! {
161 <details class="review-thread" id=(anchor.as_str()) open=(!resolved)>
162 <summary class="review-thread-summary">(summary.as_str())</summary>
163 thread_parts(parts: parts)
164 </details>
165 })
166}
167
168#[component]
169async fn thread_parts(parts: ThreadParts) -> Result<impl View> {
170 // Split into three siblings so each component's view stays small enough to type-check.
171 let ThreadParts {
172 location,
173 outdated,
174 pending,
175 hunk,
176 comments,
177 resolve_action,
178 resolve_value,
179 resolve_label,
180 reply_action,
181 csrf,
182 return_to,
183 lang,
184 } = parts;
185 let footer = ThreadFooter {
186 resolve_action,
187 resolve_value,
188 resolve_label,
189 reply_action,
190 csrf: csrf.clone(),
191 return_to: return_to.clone(),
192 lang,
193 };
194 Ok(view! {
195 thread_top(location: location, outdated: outdated, pending: pending, hunk: hunk, lang: lang)
196 for comment in comments.into_iter() {
197 thread_comment(comment: comment, csrf: csrf.clone(), return_to: return_to.clone(),
198 lang: lang)
199 }
200 thread_footer(footer: footer)
201 })
202}
203
204/// Actions and the reply form at the foot of a thread.
205struct ThreadFooter {
206 resolve_action: Option<String>,
207 resolve_value: String,
208 resolve_label: String,
209 reply_action: Option<String>,
210 csrf: String,
211 return_to: String,
212 lang: Lang,
213}
214
215#[component]
216async fn thread_footer(footer: ThreadFooter) -> Result<impl View> {
217 let ThreadFooter {
218 resolve_action,
219 resolve_value,
220 resolve_label,
221 reply_action,
222 csrf,
223 return_to,
224 lang,
225 } = footer;
226 Ok(view! {
227 thread_actions(resolve_action: resolve_action, resolve_value: resolve_value,
228 resolve_label: resolve_label, csrf: csrf.clone(), return_to: return_to.clone())
229 if let Some(action) = reply_action {
230 reply_form(action: action, csrf: csrf, return_to: return_to, lang: lang)
231 }
232 })
233}
234
235/// The header (path, line, badges) and the optional diff hunk.
236#[component]
237async fn thread_top(
238 location: String,
239 outdated: bool,
240 pending: bool,
241 hunk: Option<String>,
242 lang: Lang,
243) -> Result<impl View> {
244 let outdated_text = g::t(lang, K::Outdated);
245 let pending_text = g::t(lang, K::Pending);
246 Ok(view! {
247 <header class="review-thread-head">
248 <code>(location.as_str())</code>
249 if outdated {
250 <span class="review-badge review-badge-outdated">(outdated_text)</span>
251 }
252 if pending {
253 <span class="review-badge review-badge-pending">(pending_text)</span>
254 }
255 </header>
256 if let Some(text) = hunk {
257 <pre class="review-hunk"><code>(text.as_str())</code></pre>
258 }
259 })
260}
261
262/// The resolve or unresolve button. Empty when the viewer may not resolve.
263#[component]
264async fn thread_actions(
265 resolve_action: Option<String>,
266 resolve_value: String,
267 resolve_label: String,
268 csrf: String,
269 return_to: String,
270) -> Result<impl View> {
271 Ok(view! {
272 <div class="review-thread-actions">
273 if let Some(action) = resolve_action {
274 <form method="post" action=(action.as_str()) class="review-inline">
275 <input type="hidden" name="csrf" value=(csrf.as_str())>
276 <input type="hidden" name="resolved" value=(resolve_value.as_str())>
277 <input type="hidden" name="return" value=(return_to.as_str())>
278 <button type="submit" class="link-button">(resolve_label.as_str())</button>
279 </form>
280 }
281 </div>
282 })
283}
284
285#[component]
286async fn reply_form(
287 action: String,
288 csrf: String,
289 return_to: String,
290 lang: Lang,
291) -> Result<impl View> {
292 let label = g::t(lang, K::ReplyLabel);
293 let button = g::t(lang, K::ReplyButton);
294 Ok(view! {
295 <form method="post" action=(action.as_str()) class="review-reply stack">
296 <input type="hidden" name="csrf" value=(csrf.as_str())>
297 <input type="hidden" name="return" value=(return_to.as_str())>
298 <label>
299 <span>(label)</span>
300 <textarea name="body" rows="3"></textarea>
301 </label>
302 <div class="issue-actions">
303 <button type="submit">(button)</button>
304 </div>
305 </form>
306 })
307}
308
309#[component]
310async fn thread_comment(
311 comment: CommentRow,
312 csrf: String,
313 return_to: String,
314 lang: Lang,
315) -> Result<impl View> {
316 let CommentRow {
317 author,
318 login,
319 avatar,
320 when,
321 html,
322 history_href,
323 edit_href,
324 delete_href,
325 } = comment;
326 let profile = user_href(&login);
327 let edited = g::t(lang, K::Edited);
328 let edit = g::t(lang, K::EditLink);
329 let delete = g::t(lang, K::DeleteButton);
330 Ok(view! {
331 <article class="review-comment">
332 <header class="review-comment-head">
333 <a href=(profile.as_str())><img class="avatar avatar-sm" src=(avatar.as_str()) alt="" width="24" height="24"></a>
334 <strong>user_link(shown: author.as_str(), login: login.as_str())</strong>
335 <time datetime=(when.iso.as_str()) title=(when.absolute.as_str())>(when.relative.as_str())</time>
336 if let Some(href) = history_href {
337 <a class="issue-edited" href=(href.as_str())>(edited)</a>
338 }
339 <span class="review-comment-actions">
340 if let Some(href) = edit_href {
341 <a href=(href.as_str())>(edit)</a>
342 }
343 if let Some(action) = delete_href {
344 <form method="post" action=(action.as_str()) class="review-inline">
345 <input type="hidden" name="csrf" value=(csrf.as_str())>
346 <input type="hidden" name="return" value=(return_to.as_str())>
347 <button type="submit" class="link-button">(delete)</button>
348 </form>
349 }
350 </span>
351 </header>
352 <div class="markdown-body review-comment-body">(Unescaped::new_unchecked(html))</div>
353 </article>
354 })
355}