forge
ExploreLog in

Tahinli/forge

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