forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
55.5 KiB
1//! Notifications. The inbox at `GET /notifications` (Unread by default, `?all=1` for everything,
2//! grouped by repo, 50 rows a page with a `?before=` cursor) and its routes: open a row, mark a
3//! row or a whole repo or inbox read, toggle the subscription on an issue or pull, and set the
4//! repo watch level. Rows for repos the viewer can no longer read are hidden and marked read.
5//! The sidebar box and the watch form are shared with the issue, pull and repo header views.
6
7use std::collections::HashMap;
8use std::time::Duration;
9
10use forge_core::authz::{Action, authorize};
11use forge_core::discussions::{DiscussionNotification, DiscussionReason};
12use forge_core::store::advisories::AdvisoryNotice;
13use forge_core::store::transfers::PendingTransfer;
14use forge_core::{Notification, Reason, Role, SubState, User, WatchLevel};
15use futures_util::{StreamExt, future, stream};
16use serde::Deserialize;
17use topcoat::Result;
18use topcoat::context::{Cx, app_context};
19use topcoat::router::content::Form;
20use topcoat::router::content::sse::{Event, KeepAlive, Sse};
21use topcoat::router::error::{SeeOther, bad_request, not_found, see_other};
22use topcoat::router::response::{IntoResponse, Response};
23use topcoat::router::{Body, HeaderValue, StatusCode, page, path_param, query_params, route};
24use topcoat::view::{View, component, view};
25
26use crate::auth::{current_user, percent_encode, require_csrf, require_user};
27use crate::i18n::misc::{self as g, K};
28use crate::i18n::{self, Key, Lang};
29use crate::notify_hub::publish_unread;
30use crate::pages::issue_parts::{parse_number, stamp_unix};
31use crate::pages::repo_ctx::{RepoCtx, load_repo};
32use crate::pages::repo_transfer::transfer_list_for;
33use crate::pages::star::return_path;
34use crate::state::AppState;
35use crate::views::{csrf_field, display_name, iso_time, now_unix, relative_time, repo_href, shell};
36
37/// Rows per inbox page.
38const PAGE_SIZE: u32 = 50;
39
40path_param!(owner);
41path_param!(repo);
42path_param!(number);
43path_param!(note);
44path_param!(discussion_id);
45path_param!(advisory_repo_id);
46
47#[query_params(error = bad_request)]
48struct InboxQuery {
49 all: Option<String>,
50 before: Option<String>,
51}
52
53#[query_params(error = bad_request)]
54struct RepoQuery {
55 repo: Option<String>,
56}
57
58#[derive(Deserialize)]
59struct CsrfForm {
60 #[serde(default)]
61 csrf: String,
62}
63
64#[derive(Deserialize)]
65struct SubscriptionForm {
66 #[serde(default)]
67 csrf: String,
68 #[serde(default)]
69 action: String,
70}
71
72#[derive(Deserialize)]
73struct WatchForm {
74 #[serde(default)]
75 csrf: String,
76 #[serde(default)]
77 level: String,
78}
79
80/// One inbox row, with its strings ready for the view.
81pub(crate) struct RowView {
82 item_class: &'static str,
83 icon_class: &'static str,
84 kind_label: &'static str,
85 open_href: String,
86 read_action: String,
87 title_line: String,
88 reason: &'static str,
89 activity: String,
90 iso: String,
91 absolute: String,
92 relative: String,
93 unread: bool,
94}
95
96/// The rows of one repo, in the order they first appear in the inbox.
97pub(crate) struct RepoGroup {
98 heading: String,
99 href: String,
100 mark_action: String,
101 rows: Vec<RowView>,
102}
103
104/// One inbox entry before it becomes a row: an issue or pull notification, a discussion one, or
105/// the security alerts of a repo.
106enum Entry {
107 Issue(Notification),
108 Discussion(DiscussionNotification),
109 Advisory(AdvisoryNotice),
110}
111
112impl Entry {
113 fn repo_id(&self) -> &str {
114 match self {
115 Entry::Issue(note) => &note.repo_id,
116 Entry::Discussion(note) => &note.repo_id,
117 Entry::Advisory(note) => &note.repo_id,
118 }
119 }
120
121 fn repo_owner(&self) -> &str {
122 match self {
123 Entry::Issue(note) => &note.repo_owner,
124 Entry::Discussion(note) => &note.repo_owner,
125 Entry::Advisory(note) => &note.repo_owner,
126 }
127 }
128
129 fn repo_name(&self) -> &str {
130 match self {
131 Entry::Issue(note) => &note.repo_name,
132 Entry::Discussion(note) => &note.repo_name,
133 Entry::Advisory(note) => &note.repo_name,
134 }
135 }
136
137 fn updated_at(&self) -> &str {
138 match self {
139 Entry::Issue(note) => &note.updated_at,
140 Entry::Discussion(note) => &note.updated_at,
141 Entry::Advisory(note) => &note.updated_at,
142 }
143 }
144}
145
146/// The row for an issue or pull inbox entry.
147fn issue_row(lang: Lang, note: &Notification, tz: &str, now: i64) -> RowView {
148 let (kind_label, icon_class) = match (note.is_pull, note.issue_state.as_str()) {
149 (false, "open") => (g::t(lang, K::KindOpenIssue), "notif-icon issue-open"),
150 (false, _) => (g::t(lang, K::KindClosedIssue), "notif-icon issue-closed"),
151 (true, "open") => (g::t(lang, K::KindOpenPull), "notif-icon pull-open"),
152 (true, _) => (g::t(lang, K::KindClosedPull), "notif-icon pull-closed"),
153 };
154 let actor = note
155 .last_actor_name
156 .as_deref()
157 .map(|name| display_name(lang, name));
158 let unix = stamp_unix(&note.updated_at);
159 RowView {
160 item_class: if note.unread {
161 "notif-row unread"
162 } else {
163 "notif-row"
164 },
165 icon_class,
166 kind_label,
167 open_href: format!("/notifications/{}/open", percent_encode(&note.id)),
168 read_action: format!("/notifications/{}/read", percent_encode(&note.id)),
169 title_line: format!(
170 "{}/{} #{} {}",
171 note.repo_owner, note.repo_name, note.number, note.title
172 ),
173 reason: reason_label(lang, note.reason),
174 activity: g::activity_line(lang, activity_verb(&note.last_activity), actor.as_deref()),
175 iso: iso_time(unix),
176 absolute: forge_core::tz::format_in_tz(unix, tz),
177 relative: relative_time(lang, unix, now),
178 unread: note.unread,
179 }
180}
181
182/// The row for a discussion inbox entry: a mention, or a reply to the viewer's discussion.
183fn discussion_row(lang: Lang, note: &DiscussionNotification, tz: &str, now: i64) -> RowView {
184 let (reason, activity) = match note.reason {
185 DiscussionReason::Mention => (g::t(lang, K::ReasonMentioned), K::VerbMentionedYou),
186 DiscussionReason::Reply => (g::t(lang, K::ReasonReplied), K::VerbDiscussionReply),
187 };
188 let actor = note
189 .last_actor_name
190 .as_deref()
191 .map(|name| display_name(lang, name));
192 let unix = stamp_unix(&note.updated_at);
193 RowView {
194 item_class: if note.unread {
195 "notif-row unread"
196 } else {
197 "notif-row"
198 },
199 icon_class: "notif-icon discussion",
200 kind_label: g::t(lang, K::KindDiscussion),
201 open_href: format!(
202 "/notifications/discussions/{}/open",
203 percent_encode(&note.discussion_id)
204 ),
205 read_action: format!(
206 "/notifications/discussions/{}/read",
207 percent_encode(&note.discussion_id)
208 ),
209 title_line: format!(
210 "{}/{} #{} {}",
211 note.repo_owner, note.repo_name, note.number, note.title
212 ),
213 reason,
214 activity: g::activity_line(lang, activity, actor.as_deref()),
215 iso: iso_time(unix),
216 absolute: forge_core::tz::format_in_tz(unix, tz),
217 relative: relative_time(lang, unix, now),
218 unread: note.unread,
219 }
220}
221
222/// The row for a repo's security alerts: how many alerts a scan opened since the viewer last read
223/// the row. It links to the repo's security page through the open route.
224fn advisory_row(lang: Lang, note: &AdvisoryNotice, tz: &str, now: i64) -> RowView {
225 let unix = stamp_unix(&note.updated_at);
226 let count = note.new_alerts;
227 RowView {
228 item_class: if note.unread {
229 "notif-row unread"
230 } else {
231 "notif-row"
232 },
233 icon_class: "notif-icon advisory",
234 kind_label: g::t(lang, K::KindSecurityAlerts),
235 open_href: format!(
236 "/notifications/advisories/{}/open",
237 percent_encode(&note.repo_id)
238 ),
239 read_action: format!(
240 "/notifications/advisories/{}/read",
241 percent_encode(&note.repo_id)
242 ),
243 title_line: format!("{}/{}", note.repo_owner, note.repo_name),
244 reason: g::t(lang, K::ReasonSecurity),
245 activity: g::new_alerts(lang, count),
246 iso: iso_time(unix),
247 absolute: forge_core::tz::format_in_tz(unix, tz),
248 relative: relative_time(lang, unix, now),
249 unread: note.unread,
250 }
251}
252
253#[page("/notifications")]
254async fn inbox(cx: &Cx) -> Result<impl View> {
255 let viewer = require_user(cx).await?;
256 let state = app_context::<AppState>(cx);
257 let query = query_params::<InboxQuery>(cx)?;
258 let all = query.all.as_deref() == Some("1");
259 let before = query.before.clone().filter(|s| !s.is_empty());
260 let user = &viewer.0;
261 let lang = i18n::lang(cx, Some(user));
262 let tz = user.timezone.clone();
263 let now = now_unix();
264
265 let rows = state
266 .store
267 .list_notifications(&user.id, !all, before.as_deref(), PAGE_SIZE)
268 .await?;
269 let discussion_rows = state
270 .store
271 .list_discussion_notifications(&user.id, !all, before.as_deref(), PAGE_SIZE)
272 .await?;
273 let advisory_rows = state
274 .store
275 .list_advisory_notifications(&user.id, !all, before.as_deref(), PAGE_SIZE)
276 .await?;
277 // Every list is newest first and holds at most one page, so the top page of the merge is
278 // complete. A `more` link is due when any list was full or the merge has rows past the page.
279 let lists_full = rows.len() == PAGE_SIZE as usize
280 || discussion_rows.len() == PAGE_SIZE as usize
281 || advisory_rows.len() == PAGE_SIZE as usize;
282 let mut merged: Vec<Entry> = rows
283 .into_iter()
284 .map(Entry::Issue)
285 .chain(discussion_rows.into_iter().map(Entry::Discussion))
286 .chain(advisory_rows.into_iter().map(Entry::Advisory))
287 .collect();
288 merged.sort_by(|a, b| b.updated_at().cmp(a.updated_at()));
289 let more_rows = lists_full || merged.len() > PAGE_SIZE as usize;
290 merged.truncate(PAGE_SIZE as usize);
291 let more = if more_rows {
292 merged.last().map(|last| {
293 let mut href = format!(
294 "/notifications?before={}",
295 percent_encode(last.updated_at())
296 );
297 if all {
298 href.push_str("&all=1");
299 }
300 href
301 })
302 } else {
303 None
304 };
305
306 let mut readable: HashMap<String, bool> = HashMap::new();
307 let mut hidden = Vec::new();
308 let mut groups: Vec<RepoGroup> = Vec::new();
309 let mut index: HashMap<String, usize> = HashMap::new();
310 for entry in merged {
311 let repo_id = entry.repo_id().to_owned();
312 let visible = match readable.get(&repo_id) {
313 Some(visible) => *visible,
314 None => {
315 let visible = can_read(state, user, &repo_id).await?;
316 readable.insert(repo_id.clone(), visible);
317 visible
318 }
319 };
320 // Security alerts are for the repo's managers only, the same rule as the security page.
321 let visible = visible
322 && (!matches!(entry, Entry::Advisory(_))
323 || can_manage_alerts(state, user, &repo_id).await?);
324 if !visible {
325 hidden.push(entry);
326 continue;
327 }
328 let slot = match index.get(&repo_id) {
329 Some(slot) => *slot,
330 None => {
331 let heading = format!("{}/{}", entry.repo_owner(), entry.repo_name());
332 groups.push(RepoGroup {
333 href: repo_href(entry.repo_owner(), entry.repo_name(), "", ""),
334 mark_action: format!(
335 "/notifications/mark_all_read?repo={}",
336 percent_encode(&repo_id)
337 ),
338 heading,
339 rows: Vec::new(),
340 });
341 index.insert(repo_id.clone(), groups.len() - 1);
342 groups.len() - 1
343 }
344 };
345 let row = match entry {
346 Entry::Issue(note) => issue_row(lang, &note, &tz, now),
347 Entry::Discussion(note) => discussion_row(lang, &note, &tz, now),
348 Entry::Advisory(note) => advisory_row(lang, &note, &tz, now),
349 };
350 groups[slot].rows.push(row);
351 }
352 // Rows for repos the viewer can no longer read are marked read, so they stop counting.
353 for entry in &hidden {
354 match entry {
355 Entry::Issue(note) => state.store.mark_read(&user.id, &note.id).await?,
356 Entry::Discussion(note) => {
357 state
358 .store
359 .mark_discussion_notification_read(&user.id, &note.discussion_id)
360 .await?;
361 }
362 Entry::Advisory(note) => {
363 state
364 .store
365 .mark_advisory_notification_read(&user.id, &note.repo_id)
366 .await?;
367 }
368 }
369 }
370
371 if !hidden.is_empty() {
372 publish_unread(state, &user.id).await;
373 }
374 let unread_count = state.store.unread_count(&user.id).await?;
375 let transfers = state.store.pending_transfers_for_user(&user.id).await?;
376 let csrf = viewer.1.csrf_token.clone();
377 Ok(view! {
378 shell(title: i18n::t(lang, Key::Notifications), viewer: Some(&viewer),
379 inbox_list(lang: lang, all: all, unread_count: unread_count, groups: groups, more: more, csrf: csrf, transfers: transfers)
380 )
381 })
382}
383
384/// Opens one inbox row: marks it read and sends the viewer to the issue or pull. Another user's
385/// row, or a repo the viewer can no longer read, is 404.
386#[route(GET "/notifications/{note}/open")]
387async fn open_notification(cx: &Cx) -> Result<SeeOther> {
388 let (user, _) = require_user(cx).await?;
389 let state = app_context::<AppState>(cx);
390 let id = path_param::<Note>(cx);
391 let Some(target) = state.store.notification_target(&user.id, id).await? else {
392 return Err(not_found().into());
393 };
394 state.store.mark_read(&user.id, id).await?;
395 publish_unread(state, &user.id).await;
396 if !can_read(state, &user, &target.repo_id).await? {
397 return Err(not_found().into());
398 }
399 let section = if target.is_pull { "pulls" } else { "issues" };
400 let href = repo_href(
401 &target.owner,
402 &target.name,
403 section,
404 &target.number.to_string(),
405 );
406 Ok(see_other(href))
407}
408
409/// Opens one discussion inbox row: marks it read and sends the viewer to the discussion. Another
410/// user's row, or a repo the viewer can no longer read, is 404.
411#[route(GET "/notifications/discussions/{discussion_id}/open")]
412async fn open_discussion_notification(cx: &Cx) -> Result<SeeOther> {
413 let (user, _) = require_user(cx).await?;
414 let state = app_context::<AppState>(cx);
415 let id = path_param::<DiscussionId>(cx);
416 let Some(note) = state.store.discussion_notification(&user.id, id).await? else {
417 return Err(not_found().into());
418 };
419 state
420 .store
421 .mark_discussion_notification_read(&user.id, id)
422 .await?;
423 publish_unread(state, &user.id).await;
424 if !can_read(state, &user, &note.repo_id).await? {
425 return Err(not_found().into());
426 }
427 let href = repo_href(
428 &note.repo_owner,
429 &note.repo_name,
430 "discussions",
431 &note.number.to_string(),
432 );
433 Ok(see_other(href))
434}
435
436/// Marks one discussion inbox row read, then returns to the page the viewer came from.
437#[route(POST "/notifications/discussions/{discussion_id}/read")]
438async fn read_discussion_notification(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> {
439 let (user, _) = require_csrf(cx, &form.csrf).await?;
440 let state = app_context::<AppState>(cx);
441 let id = path_param::<DiscussionId>(cx);
442 state
443 .store
444 .mark_discussion_notification_read(&user.id, id)
445 .await?;
446 publish_unread(state, &user.id).await;
447 Ok(see_other(back(cx)))
448}
449
450/// Opens a repo's security alerts row: marks it read and sends the viewer to the repo's security
451/// page. Another user's row, or a repo whose alerts the viewer may not manage, is 404.
452#[route(GET "/notifications/advisories/{advisory_repo_id}/open")]
453async fn open_advisory_notification(cx: &Cx) -> Result<SeeOther> {
454 let (user, _) = require_user(cx).await?;
455 let state = app_context::<AppState>(cx);
456 let repo_id = path_param::<AdvisoryRepoId>(cx);
457 let Some(notice) = state.store.advisory_notification(&user.id, repo_id).await? else {
458 return Err(not_found().into());
459 };
460 state
461 .store
462 .mark_advisory_notification_read(&user.id, repo_id)
463 .await?;
464 publish_unread(state, &user.id).await;
465 if !can_manage_alerts(state, &user, repo_id).await? {
466 return Err(not_found().into());
467 }
468 Ok(see_other(repo_href(
469 &notice.repo_owner,
470 &notice.repo_name,
471 "security",
472 "",
473 )))
474}
475
476/// Marks one repo's security alerts row read, then returns to the page the viewer came from.
477#[route(POST "/notifications/advisories/{advisory_repo_id}/read")]
478async fn read_advisory_notification(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> {
479 let (user, _) = require_csrf(cx, &form.csrf).await?;
480 let state = app_context::<AppState>(cx);
481 let repo_id = path_param::<AdvisoryRepoId>(cx);
482 state
483 .store
484 .mark_advisory_notification_read(&user.id, repo_id)
485 .await?;
486 publish_unread(state, &user.id).await;
487 Ok(see_other(back(cx)))
488}
489
490/// Marks one inbox row read, then returns to the page the viewer came from.
491#[route(POST "/notifications/{note}/read")]
492async fn read_notification(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> {
493 let (user, _) = require_csrf(cx, &form.csrf).await?;
494 let state = app_context::<AppState>(cx);
495 let id = path_param::<Note>(cx);
496 state.store.mark_read(&user.id, id).await?;
497 publish_unread(state, &user.id).await;
498 Ok(see_other(back(cx)))
499}
500
501/// Marks the viewer's unread rows read: in one repo with `?repo=<id>`, otherwise everywhere.
502#[route(POST "/notifications/mark_all_read")]
503async fn mark_all_read(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> {
504 let (user, _) = require_csrf(cx, &form.csrf).await?;
505 let state = app_context::<AppState>(cx);
506 let query = query_params::<RepoQuery>(cx)?;
507 let repo_id = query.repo.as_deref().filter(|s| !s.is_empty());
508 state.store.mark_all_read(&user.id, repo_id).await?;
509 state
510 .store
511 .mark_all_discussion_notifications_read(&user.id, repo_id)
512 .await?;
513 state
514 .store
515 .mark_all_advisory_notifications_read(&user.id, repo_id)
516 .await?;
517 publish_unread(state, &user.id).await;
518 Ok(see_other(back(cx)))
519}
520
521/// How often an idle stream sends a `: keepalive` comment, so a reverse proxy does not drop the
522/// connection. This is the only timer in the feature: counts are pushed as they change.
523const STREAM_KEEP_ALIVE: Duration = Duration::from_secs(30);
524
525/// One `unread` event carrying the count.
526fn unread_event(count: u64) -> topcoat::Result<Event> {
527 Ok(Event::new().event("unread").data(count.to_string()))
528}
529
530/// The bell's live count. Sends an `unread` event with the viewer's unread count now, then one
531/// after each change to the inbox, until the viewer disconnects or the server shuts down. An
532/// anonymous viewer gets a bare 401: an event stream has no login page to redirect to.
533#[route(GET "/notifications/stream")]
534async fn notification_stream(cx: &Cx) -> Result<Response> {
535 let Some((user, _)) = current_user(cx).await? else {
536 let mut res = Response::new(Body::empty());
537 *res.status_mut() = StatusCode::UNAUTHORIZED;
538 return Ok(res);
539 };
540 let state = app_context::<AppState>(cx);
541 let subscription = state.notify_hub.subscribe(&user.id);
542 // Read after subscribing: a change that lands in between is pushed again, never missed.
543 let count = state.store.unread_count(&user.id).await?;
544 let first = stream::once(future::ready(unread_event(count)));
545 let changes = stream::unfold(subscription, |mut sub| async move {
546 let count = sub.next_count().await?;
547 Some((unread_event(count), sub))
548 });
549 let events = Sse::new(first.chain(changes)).keep_alive(
550 KeepAlive::new()
551 .interval(STREAM_KEEP_ALIVE)
552 .text("keepalive"),
553 );
554 let mut res = events.into_response(cx)?;
555 // Nginx buffers a proxied response unless told not to, which would hold the events back.
556 res.headers_mut()
557 .insert("x-accel-buffering", HeaderValue::from_static("no"));
558 Ok(res)
559}
560
561/// Subscribes or unsubscribes the viewer from an issue.
562#[route(POST "/{owner}/{repo}/issues/{number}/subscription")]
563async fn issue_subscription(cx: &Cx, Form(form): Form<SubscriptionForm>) -> Result<SeeOther> {
564 set_subscription(cx, false, &form).await
565}
566
567/// Subscribes or unsubscribes the viewer from a pull request.
568#[route(POST "/{owner}/{repo}/pulls/{number}/subscription")]
569async fn pull_subscription(cx: &Cx, Form(form): Form<SubscriptionForm>) -> Result<SeeOther> {
570 set_subscription(cx, true, &form).await
571}
572
573async fn set_subscription(cx: &Cx, pull: bool, form: &SubscriptionForm) -> Result<SeeOther> {
574 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
575 let (user, _) = require_csrf(cx, &form.csrf).await?;
576 let state = app_context::<AppState>(cx);
577 let number = parse_number(path_param::<Number>(cx))?;
578 let issue = if pull {
579 state
580 .store
581 .pull_by_number(&ctx.repo.id, number)
582 .await?
583 .map(|pr| pr.issue)
584 } else {
585 state.store.issue_by_number(&ctx.repo.id, number).await?
586 };
587 let Some(issue) = issue else {
588 return Err(not_found().into());
589 };
590 // Keep the reason the thread already has; a new subscription is for an explicit choice.
591 let reason = state
592 .store
593 .subscription(&user.id, &issue.id)
594 .await?
595 .map_or(Reason::Subscribed, |(_, reason)| reason);
596 match form.action.as_str() {
597 "subscribe" => {
598 state
599 .store
600 .subscribe(&user.id, &issue.id, SubState::Subscribed, reason)
601 .await?
602 }
603 "unsubscribe" => {
604 state
605 .store
606 .subscribe(&user.id, &issue.id, SubState::Ignored, reason)
607 .await?
608 }
609 _ => return Err(bad_request("unknown subscription action").into()),
610 }
611 let section = if pull { "pulls" } else { "issues" };
612 let href = repo_href(
613 &ctx.repo.owner_name,
614 &ctx.repo.name,
615 section,
616 &number.to_string(),
617 );
618 Ok(see_other(href))
619}
620
621/// Sets the viewer's watch level on a repo. The viewer must be able to read the repo.
622#[route(POST "/{owner}/{repo}/watch")]
623async fn set_watch(cx: &Cx, Form(form): Form<WatchForm>) -> Result<SeeOther> {
624 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
625 let (user, _) = require_csrf(cx, &form.csrf).await?;
626 let level = match form.level.as_str() {
627 "participating" => WatchLevel::Participating,
628 "watching" => WatchLevel::Watching,
629 "ignoring" => WatchLevel::Ignoring,
630 _ => return Err(bad_request("unknown watch level").into()),
631 };
632 let state = app_context::<AppState>(cx);
633 state.store.set_watch(&user.id, &ctx.repo.id, level).await?;
634 Ok(see_other(repo_href(
635 &ctx.repo.owner_name,
636 &ctx.repo.name,
637 "",
638 "",
639 )))
640}
641
642/// Whether the viewer may manage the repo's security alerts: Write on the repo, the same rule as
643/// the security page. Rows for a repo the viewer may not manage are hidden from the inbox.
644async fn can_manage_alerts(state: &AppState, user: &User, repo_id: &str) -> Result<bool> {
645 let Some(repo) = state.store.repo_by_id(repo_id).await? else {
646 return Ok(false);
647 };
648 let role: Option<Role> = state.store.collaborator_role(&repo.id, &user.id).await?;
649 let org_role = state
650 .store
651 .org_repo_role(&repo.owner_id, &user.id, &repo.id)
652 .await?;
653 Ok(authorize(Some(user), &repo, role, org_role, Action::Write).is_ok())
654}
655
656/// Whether the viewer may read the repo right now, by their current collaborator role.
657pub(crate) async fn can_read(state: &AppState, user: &User, repo_id: &str) -> Result<bool> {
658 let Some(repo) = state.store.repo_by_id(repo_id).await? else {
659 return Ok(false);
660 };
661 let role: Option<Role> = state.store.collaborator_role(&repo.id, &user.id).await?;
662 let org_role = state
663 .store
664 .org_repo_role(&repo.owner_id, &user.id, &repo.id)
665 .await?;
666 Ok(authorize(Some(user), &repo, role, org_role, Action::Read).is_ok())
667}
668
669/// The sidebar box of an issue or pull for a signed-in viewer. Viewing the thread marks the
670/// viewer's inbox row for it read. `base` is the item's URL. `None` for anonymous viewers.
671pub(crate) async fn notify_box_for(
672 state: &AppState,
673 ctx: &RepoCtx,
674 lang: Lang,
675 issue_id: &str,
676 base: &str,
677) -> Result<Option<NotifyBox>> {
678 let Some((user, session)) = &ctx.viewer else {
679 return Ok(None);
680 };
681 state.store.mark_issue_read(&user.id, issue_id).await?;
682 publish_unread(state, &user.id).await;
683 let level = state.store.watch_level(&user.id, &ctx.repo.id).await?;
684 let subscription = state.store.subscription(&user.id, issue_id).await?;
685 let (subscribed, reason) = if level == WatchLevel::Ignoring {
686 (false, g::t(lang, K::NotReceivingRepo).to_owned())
687 } else {
688 match subscription {
689 Some((SubState::Ignored, _)) => (false, g::t(lang, K::NotReceivingThread).to_owned()),
690 Some((SubState::Subscribed, reason)) => (true, because(lang, reason)),
691 None if level == WatchLevel::Watching => {
692 (true, g::receiving_because(lang, K::WhyWatching))
693 }
694 None => (false, g::t(lang, K::NotReceivingThread).to_owned()),
695 }
696 };
697 Ok(Some(NotifyBox {
698 action: format!("{base}/subscription"),
699 csrf: session.csrf_token.clone(),
700 subscribed,
701 reason,
702 }))
703}
704
705/// The notification sidebar box data: where the form posts, the session CSRF token, whether the
706/// viewer is receiving notifications, and the sentence explaining why.
707pub(crate) struct NotifyBox {
708 pub(crate) action: String,
709 pub(crate) csrf: String,
710 pub(crate) subscribed: bool,
711 pub(crate) reason: String,
712}
713
714/// "You're receiving notifications because ..." for a stored subscription reason.
715fn because(lang: Lang, reason: Reason) -> String {
716 let why = match reason {
717 Reason::Mention => K::WhyMentioned,
718 Reason::Assign => K::WhyAssigned,
719 Reason::ReviewRequested => K::WhyReviewRequested,
720 Reason::Author => K::WhyAuthor,
721 Reason::Comment => K::WhyCommented,
722 Reason::StateChange | Reason::Subscribed => K::WhySubscribed,
723 Reason::Watching => K::WhyWatching,
724 };
725 g::receiving_because(lang, why)
726}
727
728/// The human label of a notification reason.
729fn reason_label(lang: Lang, reason: Reason) -> &'static str {
730 match reason {
731 Reason::Mention => g::t(lang, K::ReasonMentioned),
732 Reason::Assign => g::t(lang, K::ReasonAssigned),
733 Reason::ReviewRequested => g::t(lang, K::ReasonReviewRequested),
734 Reason::Author => g::t(lang, K::ReasonAuthor),
735 Reason::Comment => g::t(lang, K::ReasonCommented),
736 Reason::StateChange => g::t(lang, K::ReasonStateChange),
737 Reason::Subscribed => g::t(lang, K::ReasonSubscribed),
738 Reason::Watching => g::t(lang, K::ReasonWatching),
739 }
740}
741
742/// The verb shown for the last activity on a thread.
743fn activity_verb(activity: &str) -> K {
744 match activity {
745 "opened" => K::VerbOpened,
746 "comment" => K::VerbCommented,
747 "closed" => K::VerbClosed,
748 "reopened" => K::VerbReopened,
749 "merged" => K::VerbMerged,
750 "review" => K::VerbReviewed,
751 "assigned" => K::VerbAssigned,
752 "mentioned" => K::VerbMentionedYou,
753 "pushed" => K::VerbPushedTo,
754 _ => K::VerbUpdated,
755 }
756}
757
758/// Where the POST forms return: the Referer when it is a same-site path, else the inbox.
759fn back(cx: &Cx) -> String {
760 return_path(cx).unwrap_or_else(|| "/notifications".to_owned())
761}
762
763/// The inbox body: heading, mark-all button, tabs, the repo groups and the older-rows link.
764#[component]
765async fn inbox_list(
766 lang: Lang,
767 all: bool,
768 unread_count: u64,
769 groups: Vec<RepoGroup>,
770 more: Option<String>,
771 csrf: String,
772 transfers: Vec<PendingTransfer>,
773) -> Result<impl View> {
774 let empty = groups.is_empty();
775 let unread_class = if all { "" } else { "active" };
776 let all_class = if all { "active" } else { "" };
777 let unread_text = format!("{} ({unread_count})", g::t(lang, K::Unread));
778 Ok(view! {
779 <section class="page-head">
780 <h1>(i18n::t(lang, Key::Notifications))</h1>
781 if !empty {
782 <form method="post" action="/notifications/mark_all_read" class="notif-inline-form">
783 csrf_field(token: csrf.as_str())
784 <button type="submit" class="button">(g::t(lang, K::MarkAllAsRead))</button>
785 </form>
786 }
787 </section>
788 transfer_list_for(lang: lang, transfers: transfers, csrf: csrf.clone())
789 <nav class="notif-tabs" aria-label=(g::t(lang, K::NotificationFilter))>
790 <a href="/notifications" class=(unread_class)>(unread_text.as_str())</a>
791 <a href="/notifications?all=1" class=(all_class)>(g::t(lang, K::AllTab))</a>
792 </nav>
793 if empty {
794 <p class="muted">
795 if all {
796 (g::t(lang, K::NoNotificationsYet))
797 } else {
798 (g::t(lang, K::NoUnreadNotifications))
799 }
800 </p>
801 }
802 for group in groups.into_iter() {
803 <section class="card notif-group">
804 <header class="notif-group-head">
805 <a class="notif-repo" href=(group.href.as_str())>(group.heading.as_str())</a>
806 <form method="post" action=(group.mark_action.as_str()) class="notif-inline-form">
807 csrf_field(token: csrf.as_str())
808 <button type="submit" class="link-button">(g::t(lang, K::MarkAllRead))</button>
809 </form>
810 </header>
811 <ul class="notif-list">
812 for row in group.rows.into_iter() {
813 <li class=(row.item_class)>
814 <span class=(row.icon_class) aria-hidden="true"></span>
815 <span class="notif-sr">(row.kind_label)</span>
816 <a class="notif-title" href=(row.open_href.as_str())>(row.title_line.as_str())</a>
817 <span class="badge">(row.reason)</span>
818 <span class="muted small">(row.activity.as_str())</span>
819 <time class="muted small" datetime=(row.iso.as_str()) title=(row.absolute.as_str())>(row.relative.as_str())</time>
820 if row.unread {
821 <form method="post" action=(row.read_action.as_str()) class="notif-inline-form">
822 csrf_field(token: csrf.as_str())
823 <button type="submit" class="link-button">(g::t(lang, K::MarkRead))</button>
824 </form>
825 }
826 </li>
827 }
828 </ul>
829 </section>
830 }
831 if let Some(href) = more {
832 <p class="notif-more"><a class="button" href=(href.as_str())>(g::t(lang, K::Older))</a></p>
833 }
834 })
835}
836
837/// The Notifications box in the issue and pull sidebar: the reason and a Subscribe or
838/// Unsubscribe button.
839#[component]
840pub(crate) async fn notify_box(
841 lang: Lang,
842 action: String,
843 csrf: String,
844 subscribed: bool,
845 reason: String,
846) -> Result<impl View> {
847 let (label, value_text) = if subscribed {
848 (g::t(lang, K::Unsubscribe), "unsubscribe")
849 } else {
850 (g::t(lang, K::Subscribe), "subscribe")
851 };
852 Ok(view! {
853 <section class="card notif-box">
854 <h2>(i18n::t(lang, Key::Notifications))</h2>
855 <p class="muted small">(reason.as_str())</p>
856 <form method="post" action=(action.as_str()) class="notif-inline-form">
857 csrf_field(token: csrf.as_str())
858 <button type="submit" name="action" value=(value_text)>(label)</button>
859 </form>
860 </section>
861 })
862}
863
864/// The Watch control in the repo header: a dropdown whose summary shows the current level, one
865/// POST form per level inside it (the current one marked), and the watcher count. The forms post
866/// without JavaScript; nav.js submits them in place and closes the menu.
867#[component]
868pub(crate) async fn watch_form(
869 lang: Lang,
870 action: String,
871 csrf: String,
872 level: &'static str,
873 watchers: u64,
874) -> Result<impl View> {
875 let count = g::watchers_count(lang, watchers);
876 let current = match level {
877 "watching" => g::t(lang, K::OptWatching),
878 "ignoring" => g::t(lang, K::OptIgnoring),
879 _ => g::t(lang, K::OptParticipating),
880 };
881 let class = |value: &str| -> &'static str {
882 if level == value {
883 "watch-option is-current"
884 } else {
885 "watch-option"
886 }
887 };
888 let (cls_participating, cls_watching, cls_ignoring) =
889 (class("participating"), class("watching"), class("ignoring"));
890 Ok(view! {
891 <div class="watch-control">
892 <details class="menu watch-menu">
893 <summary>
894 <svg class="watch-eye" viewBox="0 0 24 24" aria-hidden="true"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>
895 <span>(current)</span>
896 </summary>
897 <ul class="menu-list watch-list">
898 <li>
899 <form method="post" action=(action.as_str()) class="watch-option-form">
900 csrf_field(token: csrf.as_str())
901 <input type="hidden" name="level" value="participating">
902 <button type="submit" class=(cls_participating)>
903 <span class="watch-option-title">(g::t(lang, K::OptParticipating))</span>
904 <span class="watch-option-desc">(g::t(lang, K::WatchDescParticipating))</span>
905 </button>
906 </form>
907 </li>
908 <li>
909 <form method="post" action=(action.as_str()) class="watch-option-form">
910 csrf_field(token: csrf.as_str())
911 <input type="hidden" name="level" value="watching">
912 <button type="submit" class=(cls_watching)>
913 <span class="watch-option-title">(g::t(lang, K::OptWatching))</span>
914 <span class="watch-option-desc">(g::t(lang, K::WatchDescWatching))</span>
915 </button>
916 </form>
917 </li>
918 <li>
919 <form method="post" action=(action.as_str()) class="watch-option-form">
920 csrf_field(token: csrf.as_str())
921 <input type="hidden" name="level" value="ignoring">
922 <button type="submit" class=(cls_ignoring)>
923 <span class="watch-option-title">(g::t(lang, K::OptIgnoring))</span>
924 <span class="watch-option-desc">(g::t(lang, K::WatchDescIgnoring))</span>
925 </button>
926 </form>
927 </li>
928 </ul>
929 </details>
930 <span class="muted small">(count.as_str())</span>
931 </div>
932 })
933}
934
935#[cfg(test)]
936mod tests {
937 use forge_core::{Issue, Reason, Role, Store, SubState, Visibility, WatchLevel};
938 use futures_util::StreamExt;
939 use topcoat::router::request::Request;
940 use topcoat::router::{Body, Router, to_bytes};
941
942 use crate::auth::SESSION_COOKIE;
943
944 const CSRF: &str = "notif-test-csrf";
945
946 struct Site {
947 router: Router,
948 store: Store,
949 _dir: tempfile::TempDir,
950 }
951
952 struct Reply {
953 status: u16,
954 location: Option<String>,
955 body: String,
956 }
957
958 async fn site() -> Site {
959 let dir = tempfile::tempdir().expect("tempdir");
960 let state = crate::test_support::test_state(dir.path()).await;
961 let store = state.store.clone();
962 let router = crate::test_support::app_router(state);
963 Site {
964 router,
965 store,
966 _dir: dir,
967 }
968 }
969
970 /// Signs `user_id` in with `token` and returns the cookie header value.
971 async fn sign_in(store: &Store, user_id: &str, token: &str) -> String {
972 crate::test_support::create_session(store, user_id, token, CSRF).await;
973 format!("__Host-{SESSION_COOKIE}={token}")
974 }
975
976 async fn call(
977 site: &Site,
978 method: &str,
979 uri: &str,
980 cookie: Option<&str>,
981 form: Option<String>,
982 ) -> Reply {
983 let mut builder = Request::builder().method(method).uri(uri);
984 if let Some(cookie) = cookie {
985 builder = builder.header("cookie", cookie);
986 }
987 let body = match form {
988 Some(form) => {
989 builder = builder.header("content-type", "application/x-www-form-urlencoded");
990 Body::from(form)
991 }
992 None => Body::empty(),
993 };
994 let res = site
995 .router
996 .handle(builder.body(body).expect("request"))
997 .await;
998 let status = res.status().as_u16();
999 let location = res
1000 .headers()
1001 .get("location")
1002 .and_then(|v| v.to_str().ok())
1003 .map(str::to_owned);
1004 let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body");
1005 Reply {
1006 status,
1007 location,
1008 body: String::from_utf8_lossy(&bytes).into_owned(),
1009 }
1010 }
1011
1012 async fn get(site: &Site, uri: &str, cookie: Option<&str>) -> Reply {
1013 call(site, "GET", uri, cookie, None).await
1014 }
1015
1016 async fn post(site: &Site, uri: &str, cookie: &str, extra: &str) -> Reply {
1017 call(
1018 site,
1019 "POST",
1020 uri,
1021 Some(cookie),
1022 Some(format!("csrf={CSRF}{extra}")),
1023 )
1024 .await
1025 }
1026
1027 /// Alice owns `demo` with one issue (#1), and Bob has a notification row for it.
1028 struct World {
1029 alice: forge_core::User,
1030 bob: forge_core::User,
1031 repo: forge_core::Repo,
1032 issue: Issue,
1033 }
1034
1035 async fn world(site: &Site, visibility: Visibility) -> World {
1036 let store = &site.store;
1037 let alice = store
1038 .create_user("alice", None, "x", false)
1039 .await
1040 .expect("alice");
1041 let bob = store
1042 .create_user("bob", None, "x", false)
1043 .await
1044 .expect("bob");
1045 let repo = store
1046 .create_repo(&alice.id, "demo", "", visibility, "main")
1047 .await
1048 .expect("repo");
1049 let issue = store
1050 .create_issue(&repo.id, &alice.id, "First", "body")
1051 .await
1052 .expect("issue");
1053 World {
1054 alice,
1055 bob,
1056 repo,
1057 issue,
1058 }
1059 }
1060
1061 async fn notify(site: &Site, w: &World, reason: Reason) {
1062 site.store
1063 .upsert_notification(
1064 &w.bob.id,
1065 &w.repo.id,
1066 &w.issue.id,
1067 reason,
1068 "comment",
1069 Some(&w.alice.id),
1070 )
1071 .await
1072 .expect("notification");
1073 }
1074
1075 async fn first_note_id(site: &Site, user_id: &str) -> String {
1076 site.store
1077 .list_notifications(user_id, false, None, 50)
1078 .await
1079 .expect("list")
1080 .first()
1081 .expect("one notification")
1082 .id
1083 .clone()
1084 }
1085
1086 #[tokio::test]
1087 async fn inbox_lists_unread_and_all_shows_read() {
1088 let site = site().await;
1089 let w = world(&site, Visibility::Public).await;
1090 let cookie = sign_in(&site.store, &w.bob.id, "bob-token").await;
1091 notify(&site, &w, Reason::Mention).await;
1092 let id = first_note_id(&site, &w.bob.id).await;
1093 site.store.mark_read(&w.bob.id, &id).await.expect("read");
1094
1095 let unread = get(&site, "/notifications", Some(&cookie)).await;
1096 assert_eq!(unread.status, 200);
1097 assert!(!unread.body.contains("alice/demo #1 First"));
1098 assert!(unread.body.contains("You have no unread notifications."));
1099
1100 let all = get(&site, "/notifications?all=1", Some(&cookie)).await;
1101 assert_eq!(all.status, 200);
1102 assert!(all.body.contains("alice/demo #1 First"));
1103 assert!(all.body.contains("mentioned"));
1104 }
1105
1106 #[tokio::test]
1107 async fn unread_inbox_lists_unread_rows_grouped_by_repo() {
1108 let site = site().await;
1109 let w = world(&site, Visibility::Public).await;
1110 let cookie = sign_in(&site.store, &w.bob.id, "bob-token").await;
1111 notify(&site, &w, Reason::Assign).await;
1112
1113 let page = get(&site, "/notifications", Some(&cookie)).await;
1114 assert_eq!(page.status, 200);
1115 assert!(page.body.contains("alice/demo #1 First"));
1116 assert!(page.body.contains("assigned"));
1117 assert!(page.body.contains("/notifications/mark_all_read?repo="));
1118 assert!(page.body.contains("Mark all as read"));
1119 }
1120
1121 #[tokio::test]
1122 async fn open_marks_read_and_redirects_to_the_issue() {
1123 let site = site().await;
1124 let w = world(&site, Visibility::Public).await;
1125 let cookie = sign_in(&site.store, &w.bob.id, "bob-token").await;
1126 notify(&site, &w, Reason::Comment).await;
1127 let id = first_note_id(&site, &w.bob.id).await;
1128
1129 let res = get(&site, &format!("/notifications/{id}/open"), Some(&cookie)).await;
1130 assert_eq!(res.status, 303);
1131 assert_eq!(res.location.as_deref(), Some("/alice/demo/issues/1"));
1132 assert_eq!(site.store.unread_count(&w.bob.id).await.expect("count"), 0);
1133 }
1134
1135 #[tokio::test]
1136 async fn another_users_notification_is_404() {
1137 let site = site().await;
1138 let w = world(&site, Visibility::Public).await;
1139 let alice_cookie = sign_in(&site.store, &w.alice.id, "alice-token").await;
1140 notify(&site, &w, Reason::Comment).await;
1141 let id = first_note_id(&site, &w.bob.id).await;
1142
1143 let res = get(
1144 &site,
1145 &format!("/notifications/{id}/open"),
1146 Some(&alice_cookie),
1147 )
1148 .await;
1149 assert_eq!(res.status, 404);
1150 assert_eq!(site.store.unread_count(&w.bob.id).await.expect("count"), 1);
1151
1152 let read = post(
1153 &site,
1154 &format!("/notifications/{id}/read"),
1155 &alice_cookie,
1156 "",
1157 )
1158 .await;
1159 assert_eq!(read.status, 303);
1160 assert_eq!(site.store.unread_count(&w.bob.id).await.expect("count"), 1);
1161 }
1162
1163 #[tokio::test]
1164 async fn mark_all_read_clears_the_inbox() {
1165 let site = site().await;
1166 let w = world(&site, Visibility::Public).await;
1167 let cookie = sign_in(&site.store, &w.bob.id, "bob-token").await;
1168 notify(&site, &w, Reason::Comment).await;
1169
1170 let res = post(&site, "/notifications/mark_all_read", &cookie, "").await;
1171 assert_eq!(res.status, 303);
1172 assert_eq!(site.store.unread_count(&w.bob.id).await.expect("count"), 0);
1173 }
1174
1175 #[tokio::test]
1176 async fn the_stream_needs_a_session() {
1177 let site = site().await;
1178 let res = get(&site, "/notifications/stream", None).await;
1179 assert_eq!(res.status, 401);
1180 }
1181
1182 #[tokio::test]
1183 async fn the_stream_sends_the_count_then_each_change() {
1184 let site = site().await;
1185 let w = world(&site, Visibility::Public).await;
1186 let cookie = sign_in(&site.store, &w.bob.id, "bob-token").await;
1187 notify(&site, &w, Reason::Mention).await;
1188
1189 let request = Request::builder()
1190 .method("GET")
1191 .uri("/notifications/stream")
1192 .header("cookie", cookie.as_str())
1193 .body(Body::empty())
1194 .expect("request");
1195 let res = site.router.handle(request).await;
1196 assert_eq!(res.status().as_u16(), 200);
1197 assert_eq!(
1198 res.headers().get("content-type").unwrap(),
1199 "text/event-stream"
1200 );
1201 assert_eq!(res.headers().get("x-accel-buffering").unwrap(), "no");
1202
1203 let mut frames = res.into_body().into_data_stream();
1204 let first = frames.next().await.expect("a frame").expect("data");
1205 assert_eq!(&first[..], b"event: unread\ndata: 1\n\n");
1206
1207 // Marking the row read pushes the new count to the open stream.
1208 let read = post(&site, "/notifications/mark_all_read", &cookie, "").await;
1209 assert_eq!(read.status, 303);
1210 let next = frames.next().await.expect("a frame").expect("data");
1211 assert_eq!(&next[..], b"event: unread\ndata: 0\n\n");
1212 }
1213
1214 #[tokio::test]
1215 async fn header_bell_shows_the_unread_count() {
1216 let site = site().await;
1217 let w = world(&site, Visibility::Public).await;
1218 let cookie = sign_in(&site.store, &w.bob.id, "bob-token").await;
1219
1220 let none = get(&site, "/", Some(&cookie)).await;
1221 assert!(none.body.contains("href=\"/notifications\""));
1222 assert!(!none.body.contains("bell-count"));
1223
1224 notify(&site, &w, Reason::Mention).await;
1225 let one = get(&site, "/", Some(&cookie)).await;
1226 assert!(one.body.contains("<span class=\"bell-count\">1</span>"));
1227 }
1228
1229 #[tokio::test]
1230 async fn subscription_toggle_on_an_issue() {
1231 let site = site().await;
1232 let w = world(&site, Visibility::Public).await;
1233 let cookie = sign_in(&site.store, &w.bob.id, "bob-token").await;
1234
1235 let res = post(
1236 &site,
1237 "/alice/demo/issues/1/subscription",
1238 &cookie,
1239 "&action=subscribe",
1240 )
1241 .await;
1242 assert_eq!(res.status, 303);
1243 assert_eq!(res.location.as_deref(), Some("/alice/demo/issues/1"));
1244 let sub = site
1245 .store
1246 .subscription(&w.bob.id, &w.issue.id)
1247 .await
1248 .expect("sub");
1249 assert_eq!(sub, Some((SubState::Subscribed, Reason::Subscribed)));
1250
1251 let page = get(&site, "/alice/demo/issues/1", Some(&cookie)).await;
1252 assert!(page.body.contains("because you subscribed to this."));
1253 assert!(page.body.contains("value=\"unsubscribe\""));
1254
1255 post(
1256 &site,
1257 "/alice/demo/issues/1/subscription",
1258 &cookie,
1259 "&action=unsubscribe",
1260 )
1261 .await;
1262 let sub = site
1263 .store
1264 .subscription(&w.bob.id, &w.issue.id)
1265 .await
1266 .expect("sub");
1267 assert_eq!(sub, Some((SubState::Ignored, Reason::Subscribed)));
1268 }
1269
1270 #[tokio::test]
1271 async fn pull_subscription_404_for_unknown_number() {
1272 let site = site().await;
1273 let w = world(&site, Visibility::Public).await;
1274 let cookie = sign_in(&site.store, &w.bob.id, "bob-token").await;
1275 let res = post(
1276 &site,
1277 "/alice/demo/pulls/1/subscription",
1278 &cookie,
1279 "&action=subscribe",
1280 )
1281 .await;
1282 assert_eq!(res.status, 404);
1283 }
1284
1285 #[tokio::test]
1286 async fn watch_form_sets_the_level() {
1287 let site = site().await;
1288 let w = world(&site, Visibility::Public).await;
1289 let cookie = sign_in(&site.store, &w.bob.id, "bob-token").await;
1290
1291 let res = post(&site, "/alice/demo/watch", &cookie, "&level=watching").await;
1292 assert_eq!(res.status, 303);
1293 assert_eq!(
1294 site.store
1295 .watch_level(&w.bob.id, &w.repo.id)
1296 .await
1297 .expect("level"),
1298 WatchLevel::Watching
1299 );
1300
1301 let header = get(&site, "/alice/demo/issues/1", Some(&cookie)).await;
1302 assert!(header.body.contains("action=\"/alice/demo/watch\""));
1303 assert!(header.body.contains("<details class=\"menu watch-menu\">"));
1304 assert!(!header.body.contains("<select"));
1305 // One form per level, and only the watched level is marked current.
1306 assert_eq!(header.body.matches("name=\"level\"").count(), 3);
1307 assert_eq!(
1308 header
1309 .body
1310 .matches("class=\"watch-option is-current\"")
1311 .count(),
1312 1
1313 );
1314 assert_eq!(header.body.matches("class=\"watch-option\"").count(), 2);
1315 assert!(header.body.contains("1 watching"));
1316
1317 let bad = post(&site, "/alice/demo/watch", &cookie, "&level=loud").await;
1318 assert_eq!(bad.status, 400);
1319 }
1320
1321 #[tokio::test]
1322 async fn anonymous_viewer_is_redirected_to_login() {
1323 let site = site().await;
1324 let _w = world(&site, Visibility::Public).await;
1325 let res = get(&site, "/notifications", None).await;
1326 assert_eq!(res.status, 303);
1327 assert!(res.location.unwrap_or_default().starts_with("/login"));
1328 }
1329
1330 #[tokio::test]
1331 async fn row_for_a_repo_the_viewer_lost_access_to_is_hidden() {
1332 let site = site().await;
1333 let w = world(&site, Visibility::Private).await;
1334 let cookie = sign_in(&site.store, &w.bob.id, "bob-token").await;
1335 site.store
1336 .set_collaborator(&w.repo.id, &w.bob.id, Role::Read)
1337 .await
1338 .expect("collaborator");
1339 notify(&site, &w, Reason::Mention).await;
1340 assert_eq!(site.store.unread_count(&w.bob.id).await.expect("count"), 1);
1341
1342 site.store
1343 .remove_collaborator(&w.repo.id, &w.bob.id)
1344 .await
1345 .expect("remove");
1346 let page = get(&site, "/notifications?all=1", Some(&cookie)).await;
1347 assert_eq!(page.status, 200);
1348 assert!(!page.body.contains("alice/demo #1 First"));
1349 assert_eq!(site.store.unread_count(&w.bob.id).await.expect("count"), 0);
1350 }
1351
1352 #[tokio::test]
1353 async fn discussion_reply_shows_in_inbox_and_open_marks_read() {
1354 let site = site().await;
1355 let w = world(&site, Visibility::Public).await;
1356 // The world's issue takes number 1, so the discussion is #2.
1357 let discussion = site
1358 .store
1359 .create_discussion(
1360 &w.repo.id,
1361 &w.alice.id,
1362 forge_core::discussions::DiscussionCategory::General,
1363 "Ideas",
1364 "",
1365 )
1366 .await
1367 .expect("discussion");
1368 site.store
1369 .add_discussion_comment(&discussion.id, &w.bob.id, None, "+1", false)
1370 .await
1371 .expect("reply");
1372 let alice = sign_in(&site.store, &w.alice.id, "alice-token").await;
1373 let bob = sign_in(&site.store, &w.bob.id, "bob-token").await;
1374 let open = format!("/notifications/discussions/{}/open", discussion.id);
1375
1376 let page = get(&site, "/notifications", Some(&alice)).await;
1377 assert_eq!(page.status, 200);
1378 assert!(page.body.contains("Ideas"));
1379 assert!(page.body.contains(&open));
1380 assert_eq!(
1381 site.store.unread_count(&w.alice.id).await.expect("count"),
1382 1
1383 );
1384
1385 // Bob has no row for the discussion, so he cannot open it.
1386 let other = get(&site, &open, Some(&bob)).await;
1387 assert_eq!(other.status, 404);
1388
1389 let res = get(&site, &open, Some(&alice)).await;
1390 assert_eq!(res.status, 303);
1391 assert_eq!(res.location.as_deref(), Some("/alice/demo/discussions/2"));
1392 assert_eq!(
1393 site.store.unread_count(&w.alice.id).await.expect("count"),
1394 0
1395 );
1396 }
1397
1398 #[tokio::test]
1399 async fn discussion_rows_are_marked_read_by_mark_all() {
1400 let site = site().await;
1401 let w = world(&site, Visibility::Public).await;
1402 let discussion = site
1403 .store
1404 .create_discussion(
1405 &w.repo.id,
1406 &w.alice.id,
1407 forge_core::discussions::DiscussionCategory::General,
1408 "Ideas",
1409 "",
1410 )
1411 .await
1412 .expect("discussion");
1413 site.store
1414 .add_discussion_comment(&discussion.id, &w.bob.id, None, "+1", false)
1415 .await
1416 .expect("reply");
1417 let alice = sign_in(&site.store, &w.alice.id, "alice-token").await;
1418 assert_eq!(
1419 site.store.unread_count(&w.alice.id).await.expect("count"),
1420 1
1421 );
1422
1423 let res = post(&site, "/notifications/mark_all_read", &alice, "").await;
1424 assert_eq!(res.status, 303);
1425 assert_eq!(
1426 site.store.unread_count(&w.alice.id).await.expect("count"),
1427 0
1428 );
1429 }
1430
1431 #[tokio::test]
1432 async fn advisory_alerts_show_for_managers_and_open_marks_read() {
1433 let site = site().await;
1434 let w = world(&site, Visibility::Public).await;
1435 // Alice owns the repo, so she manages its alerts. Bob is a reader with a stale row.
1436 site.store
1437 .notify_new_alerts(&w.alice.id, &w.repo.id, 2)
1438 .await
1439 .expect("alerts");
1440 site.store
1441 .notify_new_alerts(&w.bob.id, &w.repo.id, 1)
1442 .await
1443 .expect("alerts");
1444 let alice = sign_in(&site.store, &w.alice.id, "alice-token").await;
1445 let bob = sign_in(&site.store, &w.bob.id, "bob-token").await;
1446 let open = format!("/notifications/advisories/{}/open", w.repo.id);
1447
1448 let page = get(&site, "/notifications", Some(&alice)).await;
1449 assert_eq!(page.status, 200);
1450 assert!(page.body.contains("Security alerts"));
1451 assert!(page.body.contains("2 new alerts"));
1452 assert!(page.body.contains(&open));
1453 assert_eq!(
1454 site.store.unread_count(&w.alice.id).await.expect("count"),
1455 1
1456 );
1457
1458 // Bob cannot manage the alerts: his row is hidden and marked read, as lost-access rows are.
1459 let bob_page = get(&site, "/notifications", Some(&bob)).await;
1460 assert_eq!(bob_page.status, 200);
1461 assert!(!bob_page.body.contains(&open));
1462 assert_eq!(site.store.unread_count(&w.bob.id).await.expect("count"), 0);
1463 let bob_open = get(&site, &open, Some(&bob)).await;
1464 assert_eq!(bob_open.status, 404);
1465
1466 let res = get(&site, &open, Some(&alice)).await;
1467 assert_eq!(res.status, 303);
1468 assert_eq!(res.location.as_deref(), Some("/alice/demo/security"));
1469 assert_eq!(
1470 site.store.unread_count(&w.alice.id).await.expect("count"),
1471 0
1472 );
1473 }
1474
1475 #[tokio::test]
1476 async fn advisory_rows_are_marked_read_by_mark_read_and_mark_all() {
1477 let site = site().await;
1478 let w = world(&site, Visibility::Public).await;
1479 let alice = sign_in(&site.store, &w.alice.id, "alice-token").await;
1480 let read = format!("/notifications/advisories/{}/read", w.repo.id);
1481
1482 site.store
1483 .notify_new_alerts(&w.alice.id, &w.repo.id, 1)
1484 .await
1485 .expect("alerts");
1486 assert_eq!(
1487 site.store.unread_count(&w.alice.id).await.expect("count"),
1488 1
1489 );
1490 let res = post(&site, &read, &alice, "").await;
1491 assert_eq!(res.status, 303);
1492 assert_eq!(
1493 site.store.unread_count(&w.alice.id).await.expect("count"),
1494 0
1495 );
1496
1497 site.store
1498 .notify_new_alerts(&w.alice.id, &w.repo.id, 3)
1499 .await
1500 .expect("alerts");
1501 assert_eq!(
1502 site.store.unread_count(&w.alice.id).await.expect("count"),
1503 1
1504 );
1505 let res = post(&site, "/notifications/mark_all_read", &alice, "").await;
1506 assert_eq!(res.status, 303);
1507 assert_eq!(
1508 site.store.unread_count(&w.alice.id).await.expect("count"),
1509 0
1510 );
1511 }
1512}