| 1 | |
|
| 2 | |
|
| 3 | | use super::settings::{csrf, require}; |
| 4 | | use super::{filters, Error, Page, Shell}; |
| 5 | | use crate::auth::Viewer; |
| 6 | | use crate::db::{self, accounts, notify as ndb}; |
| 7 | | use crate::Shared; |
| 8 | | use askama::Template; |
| 9 | | use axum::extract::{Form, Query, State}; |
| 10 | | use axum::response::{IntoResponse, Redirect, Response}; |
| 11 | | use hub_api::Visibility; |
| 12 | | use serde::Deserialize; |
| 13 | |
|
| 14 | | #[derive(Template)] |
| 15 | | #[template(path = "notifications.html")] |
| 16 | | pub struct InboxPage { |
| 17 | | shell: Shell, |
| 18 | | threads: Vec<ndb::Thread>, |
| 19 | | unread: i64, |
| 20 | | email_on: bool, |
| 21 | | notice: Option<String>, |
| 22 | | q: InboxQuery, |
| 23 | | |
| 24 | | kinds: Vec<(String, usize)>, |
| 25 | | repos: Vec<(String, usize)>, |
| 26 | | } |
| 27 | |
|
| 28 | |
|
| 29 | | #[derive(Clone, Debug, Default, Deserialize)] |
| 30 | | pub struct InboxQuery { |
| 31 | | #[serde(default)] |
| 32 | | pub view: String, |
| 33 | | #[serde(default)] |
| 34 | | pub reason: String, |
| 35 | | #[serde(default)] |
| 36 | | pub kind: String, |
| 37 | | #[serde(default)] |
| 38 | | pub repo: String, |
| 39 | | } |
| 40 | |
|
| 41 | | impl InboxQuery { |
| 42 | | |
| 43 | 214× | pub fn with(&self, key: &str, value: &str) -> String { |
| 44 | 214× | let mut q = self.clone(); |
| 45 | 214× | match key { |
| 46 | 214× | "view" => q.view = value.into(), |
| 47 | 163× | "reason" => q.reason = value.into(), |
| 48 | 112× | "kind" => q.kind = value.into(), |
| 49 | 66× | "repo" => q.repo = value.into(), |
| 50 | 34× | _ => {} |
| 51 | | } |
| 52 | 214× | let parts: Vec<String> = [("view", &q.view), ("reason", &q.reason), ("kind", &q.kind), ("repo", &q.repo)] |
| 53 | 214× | .iter() |
| 54 | 856× | .filter(|(_, v)| !v.is_empty()) |
| 55 | 214× | .map(|(k, v)| format!("{k}={}", super::settings::urlencode(v))) |
| 56 | 214× | .collect(); |
| 57 | 214× | if parts.is_empty() { |
| 58 | 74× | "/notifications".into() |
| 59 | | } else { |
| 60 | 140× | format!("/notifications?{}", parts.join("&")) |
| 61 | | } |
| 62 | 214× | } |
| 63 | |
|
| 64 | | |
| 65 | 34× | pub fn here(&self) -> String { |
| 66 | 34× | self.with("", "") |
| 67 | 34× | } |
| 68 | | } |
| 69 | |
|
| 70 | |
|
| 71 | 42× | fn family(kind: &str) -> &str { |
| 72 | 42× | kind.split('.').next().unwrap_or(kind) |
| 73 | 42× | } |
| 74 | |
|
| 75 | 17× | async fn inbox_page( |
| 76 | 17× | app: &Shared, |
| 77 | 17× | shell: Shell, |
| 78 | 17× | me: &accounts::Account, |
| 79 | 17× | q: InboxQuery, |
| 80 | 17× | notice: Option<String>, |
| 81 | 17× | ) -> Result<Page<InboxPage>, Error> { |
| 82 | 17× | let e = |e| Error::from_db(shell.clone(), e); |
| 83 | 17× | let repo_id = match q.repo.split_once('/') { |
| 84 | 2× | Some((o, n)) => db::repos::get(&app.db, o, n).await.map_err(e)?.map(|r| r.id).or(Some(-1)), |
| 85 | 15× | None => None, |
| 86 | | }; |
| 87 | 17× | let filter = ndb::Filter { |
| 88 | 17× | done: q.view == "done", |
| 89 | 17× | unread_only: q.view == "unread", |
| 90 | 17× | reason: (!q.reason.is_empty()).then(|| q.reason.clone()), |
| 91 | 17× | kind: (!q.kind.is_empty()).then(|| q.kind.clone()), |
| 92 | 17× | repo_id, |
| 93 | | }; |
| 94 | 17× | let threads = ndb::threads(&app.db, me.id, &filter, 200).await.map_err(e)?; |
| 95 | | |
| 96 | 17× | let all = ndb::threads(&app.db, me.id, &ndb::Filter { done: filter.done, ..Default::default() }, 1000).await.map_err(e)?; |
| 97 | 17× | let mut kinds: std::collections::BTreeMap<String, usize> = Default::default(); |
| 98 | 17× | let mut repos: std::collections::BTreeMap<String, usize> = Default::default(); |
| 99 | 42× | for t in &all { |
| 100 | 42× | *kinds.entry(family(&t.kind).to_string()).or_default() += 1; |
| 101 | 42× | if let Some(r) = &t.repo { |
| 102 | 41× | *repos.entry(r.clone()).or_default() += 1; |
| 103 | 41× | } |
| 104 | | } |
| 105 | 17× | let unread = ndb::unread(&app.db, me.id).await.map_err(e)?; |
| 106 | 17× | let fresh = accounts::account_by_id(&app.db, me.id).await.ok().flatten(); |
| 107 | | Ok(Page(InboxPage { |
| 108 | 17× | shell, |
| 109 | 17× | threads, |
| 110 | 17× | unread, |
| 111 | 17× | email_on: fresh.map(|a| a.email_notifications).unwrap_or(false), |
| 112 | 17× | notice, |
| 113 | 17× | q, |
| 114 | 17× | kinds: kinds.into_iter().collect(), |
| 115 | 17× | repos: repos.into_iter().collect(), |
| 116 | | })) |
| 117 | 17× | } |
| 118 | |
|
| 119 | 15× | pub async fn inbox(State(app): State<Shared>, viewer: Viewer, Query(q): Query<InboxQuery>) -> Result<Page<InboxPage>, Error> { |
| 120 | 15× | let shell = Shell::new(&app, "/notifications", &viewer); |
| 121 | 15× | let me = require(&viewer, &shell)?; |
| 122 | 15× | inbox_page(&app, shell, &me.account, q, None).await |
| 123 | 15× | } |
| 124 | |
|
| 125 | | #[derive(Deserialize)] |
| 126 | | pub struct ReadForm { |
| 127 | | #[serde(rename = "_csrf")] |
| 128 | | csrf_token: String, |
| 129 | | #[serde(default)] |
| 130 | | id: Option<i64>, |
| 131 | | } |
| 132 | |
|
| 133 | 1× | pub async fn mark_read(State(app): State<Shared>, viewer: Viewer, Form(f): Form<ReadForm>) -> Result<Page<InboxPage>, Error> { |
| 134 | 1× | let shell = Shell::new(&app, "/notifications", &viewer); |
| 135 | 1× | let me = require(&viewer, &shell)?; |
| 136 | 1× | csrf(&viewer, &f.csrf_token, &shell)?; |
| 137 | 1× | ndb::mark_read(&app.db, me.account.id, f.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
| 138 | 1× | inbox_page(&app, shell, &me.account, InboxQuery::default(), None).await |
| 139 | 1× | } |
| 140 | |
|
| 141 | | #[derive(Deserialize)] |
| 142 | | pub struct ThreadForm { |
| 143 | | #[serde(rename = "_csrf")] |
| 144 | | csrf_token: String, |
| 145 | | thread: String, |
| 146 | | |
| 147 | | action: String, |
| 148 | | |
| 149 | | #[serde(default)] |
| 150 | | back: String, |
| 151 | | } |
| 152 | |
|
| 153 | |
|
| 154 | 2× | pub async fn thread(State(app): State<Shared>, viewer: Viewer, Form(f): Form<ThreadForm>) -> Result<Response, Error> { |
| 155 | 2× | let shell = Shell::new(&app, "/notifications", &viewer); |
| 156 | 2× | let me = require(&viewer, &shell)?; |
| 157 | 2× | csrf(&viewer, &f.csrf_token, &shell)?; |
| 158 | 2× | let (read, done) = match f.action.as_str() { |
| 159 | 2× | "read" => (Some(true), None), |
| 160 | 1× | "unread" => (Some(false), None), |
| 161 | 1× | "done" => (None, Some(true)), |
| 162 | 0× | "undone" => (None, Some(false)), |
| 163 | 0× | _ => return Err(Error::bad(shell, "unknown action")), |
| 164 | | }; |
| 165 | 2× | ndb::set_thread(&app.db, me.account.id, &f.thread, read, done).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
| 166 | 2× | let back = if f.back.starts_with("/notifications") { f.back } else { "/notifications".into() }; |
| 167 | 2× | Ok(Redirect::to(&back).into_response()) |
| 168 | 2× | } |
| 169 | |
|
| 170 | |
|
| 171 | 2× | pub async fn open(State(app): State<Shared>, viewer: Viewer, axum::extract::Path(id): axum::extract::Path<i64>) -> Result<Response, Error> { |
| 172 | 2× | let shell = Shell::new(&app, "/notifications", &viewer); |
| 173 | 2× | let me = require(&viewer, &shell)?; |
| 174 | 2× | let n = ndb::get(&app.db, me.account.id, id) |
| 175 | 2× | .await |
| 176 | 2× | .map_err(|e| Error::from_db(shell.clone(), e))? |
| 177 | 2× | .ok_or_else(|| Error::not_found(shell.clone()))?; |
| 178 | 1× | ndb::set_thread(&app.db, me.account.id, &n.thread, Some(true), None).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
| 179 | | |
| 180 | 1× | let to = if n.url.starts_with('/') && !n.url.starts_with("//") { n.url } else { "/notifications".into() }; |
| 181 | 1× | Ok(Redirect::to(&to).into_response()) |
| 182 | 2× | } |
| 183 | |
|
| 184 | | #[derive(Deserialize)] |
| 185 | | pub struct PrefsForm { |
| 186 | | #[serde(rename = "_csrf")] |
| 187 | | csrf_token: String, |
| 188 | | #[serde(default)] |
| 189 | | email: Option<String>, |
| 190 | | } |
| 191 | |
|
| 192 | 1× | pub async fn prefs(State(app): State<Shared>, viewer: Viewer, Form(f): Form<PrefsForm>) -> Result<Page<InboxPage>, Error> { |
| 193 | 1× | let shell = Shell::new(&app, "/notifications", &viewer); |
| 194 | 1× | let me = require(&viewer, &shell)?; |
| 195 | 1× | csrf(&viewer, &f.csrf_token, &shell)?; |
| 196 | 1× | let key = if me.account.unsubscribe_key.is_empty() { crate::auth::random_hex(16) } else { me.account.unsubscribe_key.clone() }; |
| 197 | 1× | sqlx::query("UPDATE accounts SET email_notifications = $1, unsubscribe_key = $2 WHERE id = $3") |
| 198 | 1× | .bind(f.email.is_some()) |
| 199 | 1× | .bind(&key) |
| 200 | 1× | .bind(me.account.id) |
| 201 | 1× | .execute(&app.db) |
| 202 | 1× | .await |
| 203 | 1× | .map_err(|e| Error::from_db(shell.clone(), e))?; |
| 204 | 1× | inbox_page( |
| 205 | 1× | &app, |
| 206 | 1× | shell, |
| 207 | 1× | &me.account, |
| 208 | 1× | InboxQuery::default(), |
| 209 | 1× | Some(if f.email.is_some() { "Email notifications are on.".into() } else { "Email notifications are off.".into() }), |
| 210 | | ) |
| 211 | 1× | .await |
| 212 | 1× | } |
| 213 | |
|
| 214 | | #[derive(Deserialize)] |
| 215 | | pub struct UnsubQuery { |
| 216 | | unsubscribe: Option<String>, |
| 217 | | } |
| 218 | |
|
| 219 | |
|
| 220 | 1× | pub async fn unsubscribe(State(app): State<Shared>, Query(q): Query<UnsubQuery>) -> Response { |
| 221 | 1× | if let Some(key) = q.unsubscribe.filter(|k| k.len() >= 16) { |
| 222 | 1× | sqlx::query("UPDATE accounts SET email_notifications = FALSE WHERE unsubscribe_key = $1").bind(key).execute(&app.db).await.ok(); |
| 223 | 1× | return (axum::http::StatusCode::OK, "Email notifications are off for that account.").into_response(); |
| 224 | 0× | } |
| 225 | 0× | Redirect::to("/notifications").into_response() |
| 226 | 1× | } |
| 227 | |
|
| 228 | |
|
| 229 | |
|
| 230 | | pub struct HitView { |
| 231 | | pub kind: String, |
| 232 | | pub slug: String, |
| 233 | | pub number: i64, |
| 234 | | pub title: String, |
| 235 | | pub snippet: String, |
| 236 | | pub url: String, |
| 237 | | } |
| 238 | |
|
| 239 | | #[derive(Template)] |
| 240 | | #[template(path = "search.html")] |
| 241 | | pub struct SearchPage { |
| 242 | | shell: Shell, |
| 243 | | q: String, |
| 244 | | repos: Vec<db::repos::RepoRow>, |
| 245 | | accounts: Vec<String>, |
| 246 | | hits: Vec<HitView>, |
| 247 | | } |
| 248 | |
|
| 249 | | #[derive(Deserialize)] |
| 250 | | pub struct SearchQuery { |
| 251 | | q: Option<String>, |
| 252 | | } |
| 253 | |
|
| 254 | 6× | pub async fn search(State(app): State<Shared>, viewer: Viewer, Query(q): Query<SearchQuery>) -> Result<Page<SearchPage>, Error> { |
| 255 | 6× | let shell = Shell::new(&app, "/search", &viewer); |
| 256 | 6× | let query = q.q.unwrap_or_default().trim().to_string(); |
| 257 | 6× | if query.is_empty() { |
| 258 | 0× | return Ok(Page(SearchPage { shell, q: query, repos: vec![], accounts: vec![], hits: vec![] })); |
| 259 | 6× | } |
| 260 | 6× | let e = |e| Error::from_db(shell.clone(), e); |
| 261 | | |
| 262 | 6× | let all = db::repos::list(&app.db).await.map_err(e)?; |
| 263 | 6× | let mut visible = Vec::new(); |
| 264 | 6× | for r in all { |
| 265 | 2× | let role = crate::auth::roles::role_for(&app.db, viewer.account(), &r).await.map_err(e)?; |
| 266 | 2× | if crate::auth::roles::allows(role, crate::auth::Access::Read) { |
| 267 | 2× | visible.push(r); |
| 268 | 2× | } |
| 269 | | } |
| 270 | 6× | let lower = query.to_lowercase(); |
| 271 | 6× | let repos: Vec<db::repos::RepoRow> = visible |
| 272 | 6× | .iter() |
| 273 | 6× | .filter(|r| { |
| 274 | 2× | r.name.to_lowercase().contains(&lower) |
| 275 | 1× | || r.description.to_lowercase().contains(&lower) |
| 276 | 1× | || r.owner.to_lowercase().contains(&lower) |
| 277 | 2× | }) |
| 278 | 6× | .cloned() |
| 279 | 6× | .collect(); |
| 280 | 6× | let accounts: Vec<String> = accounts::accounts(&app.db) |
| 281 | 6× | .await |
| 282 | 6× | .map_err(e)? |
| 283 | 6× | .into_iter() |
| 284 | 12× | .filter(|a| a.username.to_lowercase().contains(&lower) || a.display_name.to_lowercase().contains(&lower)) |
| 285 | 6× | .map(|a| a.username) |
| 286 | 6× | .collect(); |
| 287 | 6× | let mut hits = Vec::new(); |
| 288 | 6× | for h in ndb::search(&app.db, &query, None, 100).await.map_err(e)? { |
| 289 | 1× | let Some(r) = visible.iter().find(|r| r.id == h.repo_id) else { continue }; |
| 290 | 1× | let url = format!("/{}/{}/{}", r.slug(), if h.kind == "proposal" { "proposal" } else { "issue" }, h.number); |
| 291 | 1× | hits.push(HitView { |
| 292 | 1× | kind: h.kind, |
| 293 | 1× | slug: r.slug(), |
| 294 | 1× | number: h.number, |
| 295 | 1× | title: h.title, |
| 296 | 1× | snippet: crate::render::escape(&h.snippet).replace("<mark>", "<mark>").replace("</mark>", "</mark>"), |
| 297 | 1× | url, |
| 298 | 1× | }); |
| 299 | | } |
| 300 | 6× | let _ = Visibility::Public; |
| 301 | 6× | Ok(Page(SearchPage { shell, q: query, repos, accounts, hits })) |
| 302 | 6× | } |