From 6c83c193edbadfe0764d3bb6796d3c02ff9d1e2f Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 15 May 2026 05:44:10 +0200 Subject: [PATCH] feat(ap): @mention notification from inbound remote Notes --- .../adapters/activitypub-base/src/content.rs | 8 ++ crates/adapters/activitypub/src/handler.rs | 79 ++++++++++++++++++- crates/adapters/activitypub/src/note.rs | 3 + crates/adapters/event-payload/src/lib.rs | 27 +++++++ .../src/services/notification_event.rs | 17 ++++ crates/domain/src/events.rs | 5 ++ 6 files changed, 138 insertions(+), 1 deletion(-) diff --git a/crates/adapters/activitypub-base/src/content.rs b/crates/adapters/activitypub-base/src/content.rs index 9d2bde7..e6156d9 100644 --- a/crates/adapters/activitypub-base/src/content.rs +++ b/crates/adapters/activitypub-base/src/content.rs @@ -55,6 +55,14 @@ pub trait ApObjectHandler: Send + Sync { /// Called when a remote actor removes a Like from a local thought. async fn on_unlike(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>; + /// Called when an inbound Note tags a local user with a Mention. + async fn on_mention( + &self, + thought_ap_id: &Url, + mentioned_user_uuid: uuid::Uuid, + actor_url: &Url, + ) -> anyhow::Result<()>; + /// Total number of locally-authored posts across all users. async fn count_local_posts(&self) -> anyhow::Result; } diff --git a/crates/adapters/activitypub/src/handler.rs b/crates/adapters/activitypub/src/handler.rs index 1f93248..e751e27 100644 --- a/crates/adapters/activitypub/src/handler.rs +++ b/crates/adapters/activitypub/src/handler.rs @@ -147,7 +147,44 @@ impl ApObjectHandler for ThoughtsObjectHandler { note.in_reply_to.as_ref(), ) .await - .map_err(|e| anyhow!("{e}")) + .map_err(|e| anyhow!("{e}"))?; + + // Fire mention notifications for local @mentions in the note's tag array. + let base_url = url::Url::parse(&self.urls.base_url) + .ok() + .and_then(|u| u.host_str().map(|h| h.to_string())) + .unwrap_or_default(); + + for tag in ¬e.tag { + if tag.get("type").and_then(|t| t.as_str()) != Some("Mention") { + continue; + } + let href = match tag.get("href").and_then(|h| h.as_str()) { + Some(h) => h, + None => continue, + }; + let href_url = match url::Url::parse(href) { + Ok(u) => u, + Err(_) => continue, + }; + if href_url.host_str().unwrap_or("") != base_url { + continue; + } + let user_uuid = href_url + .path() + .strip_prefix("/users/") + .and_then(|s| s.split('/').next()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + if let Some(uuid) = user_uuid { + self.on_mention(ap_id, uuid, actor_url) + .await + .unwrap_or_else(|e| { + tracing::warn!(error = %e, "failed to process mention notification"); + }); + } + } + + Ok(()) } async fn on_update( @@ -225,6 +262,46 @@ impl ApObjectHandler for ThoughtsObjectHandler { Ok(()) } + async fn on_mention( + &self, + thought_ap_id: &url::Url, + mentioned_user_uuid: uuid::Uuid, + actor_url: &url::Url, + ) -> anyhow::Result<()> { + let author_user_id = match self + .repo + .find_remote_actor_id(actor_url) + .await + .map_err(|e| anyhow!("{e}"))? + { + Some(id) => id, + None => return Ok(()), + }; + + let thought_uuid = thought_ap_id + .path() + .strip_prefix("/thoughts/") + .and_then(|s| s.split('/').next()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + + let thought_uuid = match thought_uuid { + Some(u) => u, + None => return Ok(()), + }; + + if let Some(ep) = &self.event_publisher { + ep.publish(&domain::events::DomainEvent::MentionReceived { + thought_id: domain::value_objects::ThoughtId::from_uuid(thought_uuid), + mentioned_user_id: domain::value_objects::UserId::from_uuid(mentioned_user_uuid), + author_user_id, + }) + .await + .map_err(|e| anyhow!("{e}"))?; + } + + Ok(()) + } + async fn on_announce_received(&self, object_url: &Url, actor_url: &Url) -> Result<()> { let thought_uuid = object_url .path() diff --git a/crates/adapters/activitypub/src/note.rs b/crates/adapters/activitypub/src/note.rs index 28f1465..9d2941f 100644 --- a/crates/adapters/activitypub/src/note.rs +++ b/crates/adapters/activitypub/src/note.rs @@ -24,6 +24,8 @@ pub struct ThoughtNote { pub sensitive: bool, #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub tag: Vec, } impl ThoughtNote { @@ -50,6 +52,7 @@ impl ThoughtNote { in_reply_to, sensitive, summary, + tag: Vec::new(), } } } diff --git a/crates/adapters/event-payload/src/lib.rs b/crates/adapters/event-payload/src/lib.rs index 17cb713..7c90a3d 100644 --- a/crates/adapters/event-payload/src/lib.rs +++ b/crates/adapters/event-payload/src/lib.rs @@ -81,6 +81,11 @@ pub enum EventPayload { connection_type: String, page: u32, }, + MentionReceived { + thought_id: String, + mentioned_user_id: String, + author_user_id: String, + }, } impl EventPayload { @@ -104,6 +109,7 @@ impl EventPayload { Self::ProfileUpdated { .. } => "users.profile_updated", Self::FetchRemoteActorPosts { .. } => "federation.fetch_outbox", Self::FetchActorConnections { .. } => "federation.fetch_connections", + Self::MentionReceived { .. } => "mentions.received", } } } @@ -234,6 +240,15 @@ impl From<&DomainEvent> for EventPayload { connection_type: connection_type.clone(), page: *page, }, + DomainEvent::MentionReceived { + thought_id, + mentioned_user_id, + author_user_id, + } => Self::MentionReceived { + thought_id: thought_id.to_string(), + mentioned_user_id: mentioned_user_id.to_string(), + author_user_id: author_user_id.to_string(), + }, } } } @@ -373,6 +388,18 @@ impl TryFrom for DomainEvent { connection_type, page, }, + EventPayload::MentionReceived { + thought_id, + mentioned_user_id, + author_user_id, + } => DomainEvent::MentionReceived { + thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?), + mentioned_user_id: UserId::from_uuid(parse_uuid( + &mentioned_user_id, + "mentioned_user_id", + )?), + author_user_id: UserId::from_uuid(parse_uuid(&author_user_id, "author_user_id")?), + }, }) } } diff --git a/crates/application/src/services/notification_event.rs b/crates/application/src/services/notification_event.rs index 2538e01..978cd6a 100644 --- a/crates/application/src/services/notification_event.rs +++ b/crates/application/src/services/notification_event.rs @@ -112,6 +112,23 @@ impl NotificationEventService { }) .await } + DomainEvent::MentionReceived { + thought_id, + mentioned_user_id, + author_user_id, + } => { + self.notifications + .save(&Notification { + id: NotificationId::new(), + user_id: mentioned_user_id.clone(), + notification_type: NotificationType::Mention, + from_user_id: Some(author_user_id.clone()), + thought_id: Some(thought_id.clone()), + read: false, + created_at: Utc::now(), + }) + .await + } _ => Ok(()), } } diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index a91d429..46acd9d 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -73,6 +73,11 @@ pub enum DomainEvent { connection_type: String, page: u32, }, + MentionReceived { + thought_id: ThoughtId, + mentioned_user_id: UserId, + author_user_id: UserId, + }, } pub struct EventEnvelope {