diff --git a/src/mastodon_api/statuses/views.rs b/src/mastodon_api/statuses/views.rs index a0e4be0..0036b84 100644 --- a/src/mastodon_api/statuses/views.rs +++ b/src/mastodon_api/statuses/views.rs @@ -19,6 +19,7 @@ use crate::ipfs::store as ipfs_store; use crate::ipfs::utils::{IPFS_LOGO, get_ipfs_url}; use crate::mastodon_api::oauth::auth::get_current_user; use crate::models::attachments::queries::set_attachment_ipfs_cid; +use crate::models::posts::mentions::{find_mentioned_profiles, replace_mentions}; use crate::models::profiles::queries::get_followers; use crate::models::posts::helpers::{ get_actions_for_post, @@ -46,8 +47,21 @@ async fn create_status( ) -> Result { let db_client = &mut **get_database_client(&db_pool).await?; let current_user = get_current_user(db_client, auth.token()).await?; + let instance = config.instance(); let mut post_data = PostCreateData::from(data.into_inner()); post_data.validate()?; + // Mentions + let mention_map = find_mentioned_profiles( + db_client, + &instance.host(), + &post_data.content, + ).await?; + post_data.content = replace_mentions( + &mention_map, + &instance.host(), + &instance.url(), + &post_data.content, + ); let post = create_post(db_client, ¤t_user.id, post_data).await?; // Federate let maybe_in_reply_to = match post.in_reply_to_id { @@ -58,7 +72,7 @@ async fn create_status( None => None, }; let activity = create_activity_note( - &config.instance_url(), + &instance.url(), &post, maybe_in_reply_to.as_ref(), ); @@ -79,7 +93,7 @@ async fn create_status( } } deliver_activity(&config, ¤t_user, activity, recipients); - let status = Status::from_post(post, &config.instance_url()); + let status = Status::from_post(post, &instance.url()); Ok(HttpResponse::Created().json(status)) } diff --git a/src/models/posts/mentions.rs b/src/models/posts/mentions.rs new file mode 100644 index 0000000..6fa86ed --- /dev/null +++ b/src/models/posts/mentions.rs @@ -0,0 +1,129 @@ +use std::collections::HashMap; + +use regex::{Captures, Regex}; +use tokio_postgres::GenericClient; + +use crate::errors::DatabaseError; +use crate::models::profiles::queries::get_profiles_by_accts; +use crate::models::profiles::types::DbActorProfile; + +const MENTION_RE: &str = r"(?m)(?P^|\s)@(?P\w+)@(?P\S+)"; + +fn pattern_to_acct(caps: &Captures, instance_host: &str) -> String { + if &caps["instance"] == instance_host { + caps["user"].to_string() + } else { + format!("{}@{}", &caps["user"], &caps["instance"]) + } +} + +/// Finds everything that looks like a mention +fn find_mentions( + instance_host: &str, + text: &str, +) -> Vec { + let mention_re = Regex::new(MENTION_RE).unwrap(); + let mut mentions = vec![]; + for caps in mention_re.captures_iter(text) { + let acct = pattern_to_acct(&caps, instance_host); + mentions.push(acct); + }; + mentions +} + +pub async fn find_mentioned_profiles( + db_client: &impl GenericClient, + instance_host: &str, + text: &str, +) -> Result, DatabaseError> { + let mentions = find_mentions(instance_host, text); + let profiles = get_profiles_by_accts(db_client, mentions).await?; + let mut mention_map: HashMap = HashMap::new(); + for profile in profiles { + mention_map.insert(profile.acct.clone(), profile); + }; + Ok(mention_map) +} + +pub fn replace_mentions( + mention_map: &HashMap, + instance_host: &str, + instance_url: &str, + text: &str, +) -> String { + let mention_re = Regex::new(MENTION_RE).unwrap(); + let result = mention_re.replace_all(text, |caps: &Captures| { + let acct = pattern_to_acct(&caps, instance_host); + match mention_map.get(&acct) { + Some(profile) => { + // Replace with a link + let url = profile.actor_id(instance_url).unwrap(); + format!( + r#"{}@{}"#, + caps["space"].to_string(), + url, + profile.username, + ) + }, + None => caps[0].to_string(), // leave unchanged if actor is not known + } + }); + result.to_string() +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use super::*; + + const INSTANCE_HOST: &str = "server1.com"; + const INSTANCE_URL: &str = "https://server1.com"; + + #[test] + fn test_find_mentions() { + let text = concat!( + "@user1@server1.com ", + "@user2@server2.com ", + "@@invalid@server2.com ", + "@test@server3.com@nospace@server4.com ", + "@notmention ", + "some text", + ); + let results = find_mentions(INSTANCE_HOST, text); + assert_eq!(results, vec![ + "user1", + "user2@server2.com", + "test@server3.com@nospace@server4.com", + ]); + } + + #[test] + fn test_replace_mentions() { + // Local actor + let profile_1 = DbActorProfile { + username: "user1".to_string(), + ..Default::default() + }; + // Remote actor + let profile_2 = DbActorProfile { + username: "user2".to_string(), + actor_json: Some(json!({ + "id": "https://server2.com/actors/user2", + })), + ..Default::default() + }; + let text = "@user1@server1.com @user2@server2.com sometext @notmention @test@unknown.org"; + let mention_map = HashMap::from([ + ("user1".to_string(), profile_1), + ("user2@server2.com".to_string(), profile_2), + ]); + let result = replace_mentions(&mention_map, INSTANCE_HOST, INSTANCE_URL, text); + + let expected_result = concat!( + r#"@user1 "#, + r#"@user2 "#, + r#"sometext @notmention @test@unknown.org"#, + ); + assert_eq!(result, expected_result); + } +} diff --git a/src/models/posts/mod.rs b/src/models/posts/mod.rs index 391d9b9..eb3be89 100644 --- a/src/models/posts/mod.rs +++ b/src/models/posts/mod.rs @@ -1,3 +1,4 @@ pub mod helpers; +pub mod mentions; pub mod queries; pub mod types; diff --git a/src/models/profiles/queries.rs b/src/models/profiles/queries.rs index 43fa570..f4d147e 100644 --- a/src/models/profiles/queries.rs +++ b/src/models/profiles/queries.rs @@ -164,6 +164,24 @@ pub async fn get_profiles( Ok(profiles) } +pub async fn get_profiles_by_accts( + db_client: &impl GenericClient, + accts: Vec, +) -> Result, DatabaseError> { + let rows = db_client.query( + " + SELECT actor_profile + FROM actor_profile + WHERE acct = ANY($1) + ", + &[&accts], + ).await?; + let profiles = rows.iter() + .map(|row| row.try_get("actor_profile")) + .collect::>()?; + Ok(profiles) +} + pub async fn get_followers( db_client: &impl GenericClient, profile_id: &Uuid,