Skip to content

Commit f8a6aa0

Browse files
authored
feat: Push notifications for all message types (#44)
1 parent cf3611b commit f8a6aa0

8 files changed

Lines changed: 586 additions & 4 deletions

File tree

src/lib/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ pub mod klipy;
2828
pub mod list_atproto_records;
2929
pub mod map_tap_event;
3030
pub mod moderation;
31+
pub mod notification_preferences;
3132
pub mod notifications;
3233
pub mod owner_role_heal;
3334
pub mod pds_client;
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
//! Per-user, on-protocol notification level.
2+
//!
3+
//! Stored as a `social.colibri.actor.notificationPreference` record on the
4+
//! user's own repo (deterministic rkey, like `social.colibri.actor.mute`),
5+
//! and read back through the generic `record_data` firehose mirror — same
6+
//! shape as `list_mutes_handler::fetch_mutes`. Absence of a record means the
7+
//! default level, `"all"`.
8+
9+
use std::collections::{HashMap, HashSet};
10+
11+
use sea_orm::{ColumnTrait, DatabaseConnection, DbErr, EntityTrait, QueryFilter};
12+
use serde::Deserialize;
13+
14+
use crate::models::record_data;
15+
16+
pub const NOTIFICATION_PREFERENCE_NSID: &str = "social.colibri.actor.notificationPreference";
17+
pub const NOTIFICATION_PREFERENCE_RKEY: &str = "self";
18+
19+
pub const LEVEL_ALL: &str = "all";
20+
pub const LEVEL_MENTIONS_AND_REPLIES: &str = "mentionsAndReplies";
21+
22+
#[derive(Deserialize)]
23+
struct StoredPreference {
24+
level: String,
25+
}
26+
27+
fn level_from_record(record: &record_data::Model) -> Option<String> {
28+
let stored: StoredPreference = serde_json::from_value(record.data.clone()).ok()?;
29+
if stored.level == LEVEL_MENTIONS_AND_REPLIES {
30+
Some(stored.level)
31+
} else {
32+
// Any unrecognized/legacy value falls back to the default rather than
33+
// silently narrowing what a client that predates a future level would see.
34+
None
35+
}
36+
}
37+
38+
/// The recipient's notification level, defaulting to [`LEVEL_ALL`] when no
39+
/// record exists or it doesn't parse.
40+
pub async fn level_for(db: &DatabaseConnection, did: &str) -> Result<String, DbErr> {
41+
let record = record_data::Entity::find()
42+
.filter(record_data::Column::Did.eq(did))
43+
.filter(record_data::Column::Nsid.eq(NOTIFICATION_PREFERENCE_NSID))
44+
.filter(record_data::Column::Rkey.eq(NOTIFICATION_PREFERENCE_RKEY))
45+
.one(db)
46+
.await?;
47+
48+
Ok(record
49+
.as_ref()
50+
.and_then(level_from_record)
51+
.unwrap_or_else(|| LEVEL_ALL.to_string()))
52+
}
53+
54+
/// Given a set of candidate DIDs, returns the subset that has explicitly
55+
/// opted down to mentions/replies-only. Everyone else defaults to `"all"`.
56+
/// Batched into a single `IN` query so expanding "notify every member" for a
57+
/// message doesn't cost one round-trip per member.
58+
pub async fn mentions_and_replies_only_dids(
59+
db: &DatabaseConnection,
60+
dids: &[String],
61+
) -> Result<HashSet<String>, DbErr> {
62+
if dids.is_empty() {
63+
return Ok(HashSet::new());
64+
}
65+
66+
let records = record_data::Entity::find()
67+
.filter(record_data::Column::Did.is_in(dids.to_vec()))
68+
.filter(record_data::Column::Nsid.eq(NOTIFICATION_PREFERENCE_NSID))
69+
.filter(record_data::Column::Rkey.eq(NOTIFICATION_PREFERENCE_RKEY))
70+
.all(db)
71+
.await?;
72+
73+
let mut by_did: HashMap<String, String> = HashMap::new();
74+
for record in &records {
75+
if let Some(level) = level_from_record(record) {
76+
by_did.insert(record.did.clone(), level);
77+
}
78+
}
79+
80+
Ok(by_did
81+
.into_iter()
82+
.filter(|(_, level)| level == LEVEL_MENTIONS_AND_REPLIES)
83+
.map(|(did, _)| did)
84+
.collect())
85+
}
86+
87+
#[cfg(test)]
88+
mod tests {
89+
use super::*;
90+
use rocket::tokio;
91+
use sea_orm::{DatabaseBackend, MockDatabase};
92+
use serde_json::json;
93+
94+
fn pref_record(did: &str, level: &str) -> record_data::Model {
95+
record_data::Model {
96+
id: 1,
97+
did: did.to_string(),
98+
nsid: NOTIFICATION_PREFERENCE_NSID.to_string(),
99+
rkey: NOTIFICATION_PREFERENCE_RKEY.to_string(),
100+
data: json!({ "level": level }),
101+
indexed_at: String::new(),
102+
}
103+
}
104+
105+
#[tokio::test]
106+
async fn level_for_defaults_to_all_when_no_record() {
107+
let db = MockDatabase::new(DatabaseBackend::Postgres)
108+
.append_query_results([Vec::<record_data::Model>::new()])
109+
.into_connection();
110+
assert_eq!(level_for(&db, "did:plc:me").await.unwrap(), LEVEL_ALL);
111+
}
112+
113+
#[tokio::test]
114+
async fn level_for_reads_explicit_mentions_and_replies() {
115+
let db = MockDatabase::new(DatabaseBackend::Postgres)
116+
.append_query_results([vec![pref_record("did:plc:me", LEVEL_MENTIONS_AND_REPLIES)]])
117+
.into_connection();
118+
assert_eq!(
119+
level_for(&db, "did:plc:me").await.unwrap(),
120+
LEVEL_MENTIONS_AND_REPLIES
121+
);
122+
}
123+
124+
#[tokio::test]
125+
async fn level_for_falls_back_to_all_on_unrecognized_value() {
126+
let db = MockDatabase::new(DatabaseBackend::Postgres)
127+
.append_query_results([vec![pref_record("did:plc:me", "somethingElse")]])
128+
.into_connection();
129+
assert_eq!(level_for(&db, "did:plc:me").await.unwrap(), LEVEL_ALL);
130+
}
131+
132+
#[tokio::test]
133+
async fn mentions_and_replies_only_dids_returns_empty_for_empty_input() {
134+
let db = crate::lib::test_fixtures::mock_db();
135+
let out = mentions_and_replies_only_dids(&db, &[]).await.unwrap();
136+
assert!(out.is_empty());
137+
}
138+
139+
#[tokio::test]
140+
async fn mentions_and_replies_only_dids_filters_to_opted_down_members() {
141+
let db = MockDatabase::new(DatabaseBackend::Postgres)
142+
.append_query_results([vec![
143+
pref_record("did:plc:alice", LEVEL_MENTIONS_AND_REPLIES),
144+
pref_record("did:plc:bob", LEVEL_ALL),
145+
]])
146+
.into_connection();
147+
let out = mentions_and_replies_only_dids(
148+
&db,
149+
&[
150+
String::from("did:plc:alice"),
151+
String::from("did:plc:bob"),
152+
String::from("did:plc:carol"),
153+
],
154+
)
155+
.await
156+
.unwrap();
157+
assert_eq!(out.len(), 1);
158+
assert!(out.contains("did:plc:alice"));
159+
}
160+
}

0 commit comments

Comments
 (0)