OXYGENHUB
◆ wiki-says-so oxygenhub / crates / hub / src / web / inbox.rs
10.9 KiB · Rust · last changed Notifications by thread: participating versus watching, filters by kind and repository, read, unread and done per thread, and opening a row marks it read 1 month ago source blame history raw
18e381ea Andy McCall ↶ 1
//! The inbox, notification preferences and search (hub spec §10, §11).
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,
eb0c44a9 Andy McCall ↶ 18
    threads: Vec<ndb::Thread>,
18e381ea Andy McCall ↶ 19
    unread: i64,
20
    email_on: bool,
21
    notice: Option<String>,
eb0c44a9 Andy McCall ↶ 22
    q: InboxQuery,
23
    /// Kind families and repositories among the threads in the inbox, with counts.
24
    kinds: Vec<(String, usize)>,
25
    repos: Vec<(String, usize)>,
26
}
27
28
/// `?view=unread|done&reason=participating|watching&kind=issue&repo=owner/name`.
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
    /// This query with one key changed, as a URL (empty values drop out).
43
    pub fn with(&self, key: &str, value: &str) -> String {
44
        let mut q = self.clone();
45
        match key {
46
            "view" => q.view = value.into(),
47
            "reason" => q.reason = value.into(),
48
            "kind" => q.kind = value.into(),
49
            "repo" => q.repo = value.into(),
50
            _ => {}
51
        }
52
        let parts: Vec<String> = [("view", &q.view), ("reason", &q.reason), ("kind", &q.kind), ("repo", &q.repo)]
53
            .iter()
54
            .filter(|(_, v)| !v.is_empty())
55
            .map(|(k, v)| format!("{k}={}", super::settings::urlencode(v)))
56
            .collect();
57
        if parts.is_empty() {
58
            "/notifications".into()
59
        } else {
60
            format!("/notifications?{}", parts.join("&"))
61
        }
62
    }
63
64
    /// This query as it is, for forms that come back to it.
65
    pub fn here(&self) -> String {
66
        self.with("", "")
67
    }
18e381ea Andy McCall ↶ 68
}
69
eb0c44a9 Andy McCall ↶ 70
/// `issue.closed` → `issue`.
71
fn family(kind: &str) -> &str {
72
    kind.split('.').next().unwrap_or(kind)
73
}
74
75
async fn inbox_page(
76
    app: &Shared,
77
    shell: Shell,
78
    me: &accounts::Account,
79
    q: InboxQuery,
80
    notice: Option<String>,
81
) -> Result<Page<InboxPage>, Error> {
82
    let e = |e| Error::from_db(shell.clone(), e);
83
    let repo_id = match q.repo.split_once('/') {
84
        Some((o, n)) => db::repos::get(&app.db, o, n).await.map_err(e)?.map(|r| r.id).or(Some(-1)),
85
        None => None,
86
    };
87
    let filter = ndb::Filter {
88
        done: q.view == "done",
89
        unread_only: q.view == "unread",
90
        reason: (!q.reason.is_empty()).then(|| q.reason.clone()),
91
        kind: (!q.kind.is_empty()).then(|| q.kind.clone()),
92
        repo_id,
93
    };
94
    let threads = ndb::threads(&app.db, me.id, &filter, 200).await.map_err(e)?;
95
    // Facets over the whole inbox, so the choices do not vanish as you pick.
96
    let all = ndb::threads(&app.db, me.id, &ndb::Filter { done: filter.done, ..Default::default() }, 1000).await.map_err(e)?;
97
    let mut kinds: std::collections::BTreeMap<String, usize> = Default::default();
98
    let mut repos: std::collections::BTreeMap<String, usize> = Default::default();
99
    for t in &all {
100
        *kinds.entry(family(&t.kind).to_string()).or_default() += 1;
101
        if let Some(r) = &t.repo {
102
            *repos.entry(r.clone()).or_default() += 1;
103
        }
104
    }
105
    let unread = ndb::unread(&app.db, me.id).await.map_err(e)?;
18e381ea Andy McCall ↶ 106
    let fresh = accounts::account_by_id(&app.db, me.id).await.ok().flatten();
eb0c44a9 Andy McCall ↶ 107
    Ok(Page(InboxPage {
108
        shell,
109
        threads,
110
        unread,
111
        email_on: fresh.map(|a| a.email_notifications).unwrap_or(false),
112
        notice,
113
        q,
114
        kinds: kinds.into_iter().collect(),
115
        repos: repos.into_iter().collect(),
116
    }))
18e381ea Andy McCall ↶ 117
}
118
eb0c44a9 Andy McCall ↶ 119
pub async fn inbox(State(app): State<Shared>, viewer: Viewer, Query(q): Query<InboxQuery>) -> Result<Page<InboxPage>, Error> {
18e381ea Andy McCall ↶ 120
    let shell = Shell::new(&app, "/notifications", &viewer);
121
    let me = require(&viewer, &shell)?;
eb0c44a9 Andy McCall ↶ 122
    inbox_page(&app, shell, &me.account, q, None).await
18e381ea Andy McCall ↶ 123
}
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
pub async fn mark_read(State(app): State<Shared>, viewer: Viewer, Form(f): Form<ReadForm>) -> Result<Page<InboxPage>, Error> {
134
    let shell = Shell::new(&app, "/notifications", &viewer);
135
    let me = require(&viewer, &shell)?;
136
    csrf(&viewer, &f.csrf_token, &shell)?;
137
    ndb::mark_read(&app.db, me.account.id, f.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
eb0c44a9 Andy McCall ↶ 138
    inbox_page(&app, shell, &me.account, InboxQuery::default(), None).await
139
}
140
141
#[derive(Deserialize)]
142
pub struct ThreadForm {
143
    #[serde(rename = "_csrf")]
144
    csrf_token: String,
145
    thread: String,
146
    /// `read`, `unread`, `done` or `undone`.
147
    action: String,
148
    /// Where to go back to: the inbox with its filters.
149
    #[serde(default)]
150
    back: String,
18e381ea Andy McCall ↶ 151
}
152
eb0c44a9 Andy McCall ↶ 153
/// `POST /notifications/thread`.
154
pub async fn thread(State(app): State<Shared>, viewer: Viewer, Form(f): Form<ThreadForm>) -> Result<Response, Error> {
155
    let shell = Shell::new(&app, "/notifications", &viewer);
156
    let me = require(&viewer, &shell)?;
157
    csrf(&viewer, &f.csrf_token, &shell)?;
158
    let (read, done) = match f.action.as_str() {
159
        "read" => (Some(true), None),
160
        "unread" => (Some(false), None),
161
        "done" => (None, Some(true)),
162
        "undone" => (None, Some(false)),
163
        _ => return Err(Error::bad(shell, "unknown action")),
164
    };
165
    ndb::set_thread(&app.db, me.account.id, &f.thread, read, done).await.map_err(|e| Error::from_db(shell.clone(), e))?;
166
    let back = if f.back.starts_with("/notifications") { f.back } else { "/notifications".into() };
167
    Ok(Redirect::to(&back).into_response())
168
}
169
170
/// `GET /notifications/open/{id}`: mark the thread read and go to it.
171
pub async fn open(State(app): State<Shared>, viewer: Viewer, axum::extract::Path(id): axum::extract::Path<i64>) -> Result<Response, Error> {
172
    let shell = Shell::new(&app, "/notifications", &viewer);
173
    let me = require(&viewer, &shell)?;
174
    let n = ndb::get(&app.db, me.account.id, id)
175
        .await
176
        .map_err(|e| Error::from_db(shell.clone(), e))?
177
        .ok_or_else(|| Error::not_found(shell.clone()))?;
178
    ndb::set_thread(&app.db, me.account.id, &n.thread, Some(true), None).await.map_err(|e| Error::from_db(shell.clone(), e))?;
179
    // Only local pages: a notification's URL is a path on this hub.
180
    let to = if n.url.starts_with('/') && !n.url.starts_with("//") { n.url } else { "/notifications".into() };
181
    Ok(Redirect::to(&to).into_response())
182
}
183
18e381ea Andy McCall ↶ 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
pub async fn prefs(State(app): State<Shared>, viewer: Viewer, Form(f): Form<PrefsForm>) -> Result<Page<InboxPage>, Error> {
193
    let shell = Shell::new(&app, "/notifications", &viewer);
194
    let me = require(&viewer, &shell)?;
195
    csrf(&viewer, &f.csrf_token, &shell)?;
196
    let key = if me.account.unsubscribe_key.is_empty() { crate::auth::random_hex(16) } else { me.account.unsubscribe_key.clone() };
197
    sqlx::query("UPDATE accounts SET email_notifications = ?, unsubscribe_key = ? WHERE id = ?")
198
        .bind(f.email.is_some())
199
        .bind(&key)
200
        .bind(me.account.id)
201
        .execute(&app.db)
202
        .await
203
        .map_err(|e| Error::from_db(shell.clone(), e))?;
204
    inbox_page(
205
        &app,
206
        shell,
207
        &me.account,
eb0c44a9 Andy McCall ↶ 208
        InboxQuery::default(),
18e381ea Andy McCall ↶ 209
        Some(if f.email.is_some() { "Email notifications are on.".into() } else { "Email notifications are off.".into() }),
210
    )
211
    .await
212
}
213
214
#[derive(Deserialize)]
215
pub struct UnsubQuery {
216
    unsubscribe: Option<String>,
217
}
218
219
/// The one-click link in every email: no session needed, the key is the proof.
220
pub async fn unsubscribe(State(app): State<Shared>, Query(q): Query<UnsubQuery>) -> Response {
221
    if let Some(key) = q.unsubscribe.filter(|k| k.len() >= 16) {
222
        sqlx::query("UPDATE accounts SET email_notifications = 0 WHERE unsubscribe_key = ?").bind(key).execute(&app.db).await.ok();
223
        return (axum::http::StatusCode::OK, "Email notifications are off for that account.").into_response();
224
    }
225
    Redirect::to("/notifications").into_response()
226
}
227
228
// ------------------------------------------------------------------ search
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
pub async fn search(State(app): State<Shared>, viewer: Viewer, Query(q): Query<SearchQuery>) -> Result<Page<SearchPage>, Error> {
255
    let shell = Shell::new(&app, "/search", &viewer);
256
    let query = q.q.unwrap_or_default().trim().to_string();
257
    if query.is_empty() {
258
        return Ok(Page(SearchPage { shell, q: query, repos: vec![], accounts: vec![], hits: vec![] }));
259
    }
260
    let e = |e| Error::from_db(shell.clone(), e);
261
    // What the viewer may see.
262
    let all = db::repos::list(&app.db).await.map_err(e)?;
263
    let mut visible = Vec::new();
264
    for r in all {
265
        let role = crate::auth::roles::role_for(&app.db, viewer.account(), &r).await.map_err(e)?;
266
        if crate::auth::roles::allows(role, crate::auth::Access::Read) {
267
            visible.push(r);
268
        }
269
    }
270
    let lower = query.to_lowercase();
271
    let repos: Vec<db::repos::RepoRow> = visible
272
        .iter()
273
        .filter(|r| {
274
            r.name.to_lowercase().contains(&lower)
275
                || r.description.to_lowercase().contains(&lower)
276
                || r.owner.to_lowercase().contains(&lower)
277
        })
278
        .cloned()
279
        .collect();
280
    let accounts: Vec<String> = accounts::accounts(&app.db)
281
        .await
282
        .map_err(e)?
283
        .into_iter()
284
        .filter(|a| a.username.to_lowercase().contains(&lower) || a.display_name.to_lowercase().contains(&lower))
285
        .map(|a| a.username)
286
        .collect();
287
    let mut hits = Vec::new();
288
    for h in ndb::search(&app.db, &query, None, 100).await.map_err(e)? {
289
        let Some(r) = visible.iter().find(|r| r.id == h.repo_id) else { continue };
290
        let url = format!("/{}/{}/{}", r.slug(), if h.kind == "proposal" { "proposal" } else { "issue" }, h.number);
291
        hits.push(HitView {
292
            kind: h.kind,
293
            slug: r.slug(),
294
            number: h.number,
295
            title: h.title,
296
            snippet: crate::render::escape(&h.snippet).replace("&lt;mark&gt;", "<mark>").replace("&lt;/mark&gt;", "</mark>"),
297
            url,
298
        });
299
    }
300
    let _ = Visibility::Public;
301
    Ok(Page(SearchPage { shell, q: query, repos, accounts, hits }))
302
}