Repository: importantimport/hatsu (language: rust)

Issue:
Receive `Like` and `Announce`
https://docs.joinmastodon.org/spec/activitypub/#supported-activities-for-statuses

Since they have no content, can refer to the data format of the `received_follow` table.

API:

- `/api/v1/statuses/{id}/favourited_by` (Like)
- `/api/v1/statuses/{id}/reblogged_by` (Announce)


Discussion / hints:

Fix patch:
diff --git a/FEDERATION.md b/FEDERATION.md
index b34ee929..418f6dfd 100644
--- a/FEDERATION.md
+++ b/FEDERATION.md
@@ -32,9 +32,9 @@ The following activities and object types are supported:
 - `Follow(Actor)`, `Undo(Follow)`
 - `Create(Note)`
 - `Like(Note)`, `Undo(Like)`
+- `Announce(Note)`, `Undo(Announce)`
 
 <!-- - `Create(Note)`, `Update(Note)`, `Delete(Note)` -->
-<!-- - `Announce(Note)`, `Undo(Announce)` -->
 
 Activities are implemented in way that is compatible with Mastodon and other
 popular ActivityPub servers.
diff --git a/crates/api_apub/src/users/user_inbox.rs b/crates/api_apub/src/users/user_inbox.rs
index da9a196b..7d77f581 100644
--- a/crates/api_apub/src/users/user_inbox.rs
+++ b/crates/api_apub/src/users/user_inbox.rs
@@ -4,11 +4,11 @@ use activitypub_federation::{
     protocol::context::WithContext,
 };
 use axum::{debug_handler, response::IntoResponse};
-use hatsu_apub::{activities::ServiceInboxActivities, actors::ApubUser};
+use hatsu_apub::{activities::UserInboxActivities, actors::ApubUser};
 use hatsu_utils::AppData;
 
 #[debug_handler]
 pub async fn handler(data: Data<AppData>, activity_data: ActivityData) -> impl IntoResponse {
-    receive_activity::<WithContext<ServiceInboxActivities>, ApubUser, AppData>(activity_data, &data)
+    receive_activity::<WithContext<UserInboxActivities>, ApubUser, AppData>(activity_data, &data)
         .await
 }
diff --git a/crates/api_mastodon/src/routes/statuses/status_favourited_by.rs b/crates/api_mastodon/src/routes/statuses/status_favourited_by.rs
index 7c7e0ae6..b1115be6 100644
--- a/crates/api_mastodon/src/routes/statuses/status_favourited_by.rs
+++ b/crates/api_mastodon/src/routes/statuses/status_favourited_by.rs
@@ -6,9 +6,6 @@ use sea_orm::{EntityTrait, ModelTrait};
 
 use crate::entities::Account;
 
-// (status = NOT_FOUND, description = "Status does not exist or is private", body = AppError)
-// { "error": "Record not found" }
-
 /// See who favourited a status
 ///
 /// <https://docs.joinmastodon.org/methods/statuses/#favourited_by>
@@ -18,6 +15,7 @@ use crate::entities::Account;
     path = "/api/v1/statuses/{id}/favourited_by",
     responses(
         (status = OK, description = "A list of accounts who favourited the status", body = Vec<Account>),
+        (status = NOT_FOUND, description = "Status does not exist or is private", body = AppError),
     ),
     params(
         ("id" = String, Path, description = "The ID of the Status in the database.")
diff --git a/crates/api_mastodon/src/routes/statuses/status_reblogged_by.rs b/crates/api_mastodon/src/routes/statuses/status_reblogged_by.rs
index 45c23a71..1682455d 100644
--- a/crates/api_mastodon/src/routes/statuses/status_reblogged_by.rs
+++ b/crates/api_mastodon/src/routes/statuses/status_reblogged_by.rs
@@ -1,12 +1,11 @@
 use activitypub_federation::config::Data;
-use axum::{debug_handler, Json};
+use axum::{debug_handler, extract::Path, Json};
+use hatsu_db_schema::prelude::{Post, ReceivedAnnounce};
 use hatsu_utils::{AppData, AppError};
+use sea_orm::{EntityTrait, ModelTrait};
 
 use crate::entities::Account;
 
-// (status = NOT_FOUND, description = "Status does not exist or is private", body = AppError)
-// { "error": "Record not found" }
-
 /// See who boosted a status
 ///
 /// <https://docs.joinmastodon.org/methods/statuses/#reblogged_by>
@@ -16,12 +15,47 @@ use crate::entities::Account;
     path = "/api/v1/statuses/{id}/reblogged_by",
     responses(
         (status = OK, description = "A list of accounts that boosted the status", body = Vec<Account>),
+        (status = NOT_FOUND, description = "Status does not exist or is private", body = AppError),
     ),
     params(
         ("id" = String, Path, description = "The ID of the Status in the database.")
     )
 )]
 #[debug_handler]
-pub async fn status_reblogged_by(data: Data<AppData>) -> Result<Json<Vec<Account>>, AppError> {
-    Ok(Json(vec![Account::primary_account(&data).await?]))
+pub async fn status_reblogged_by(
+    Path(base64_url): Path<String>,
+    data: Data<AppData>,
+) -> Result<Json<Vec<Account>>, AppError> {
+    let base64 = base64_simd::URL_SAFE;
+
+    match base64.decode_to_vec(&base64_url) {
+        Ok(utf8_url) => match String::from_utf8(utf8_url) {
+            Ok(url) if url.starts_with("https://") => {
+                let post_url = hatsu_utils::url::generate_post_url(data.domain(), url)?;
+
+                match Post::find_by_id(&post_url.to_string())
+                    .one(&data.conn)
+                    .await?
+                {
+                    Some(post) => {
+                        let handles = post
+                            .find_related(ReceivedAnnounce)
+                            .all(&data.conn)
+                            .await
+                            .unwrap()
+                            .into_iter()
+                            .map(|received_like| async {
+                                Account::from_id(received_like.actor, &data).await.unwrap()
+                            })
+                            .collect::<Vec<_>>();
+
+                        Ok(Json(futures::future::join_all(handles).await))
+                    },
+                    _ => Err(AppError::not_found("Record", &base64_url)),
+                }
+            },
+            _ => Err(AppError::not_found("Record", &base64_url)),
+        },
+        _ => Err(AppError::not_found("Record", &base64_url)),
+    }
 }
diff --git a/crates/apub/assets/mastodon/activities/create_note.json b/crates/apub/assets/mastodon/activities/create_note.json
new file mode 100644
index 00000000..67aea397
--- /dev/null
+++ b/crates/apub/assets/mastodon/activities/create_note.json
@@ -0,0 +1,62 @@
+{
+    "@context": [
+        "https://www.w3.org/ns/activitystreams",
+        {
+            "ostatus": "http://ostatus.org#",
+            "atomUri": "ostatus:atomUri"
+        }
+    ],
+    "id": "https://mastodon.madrid/users/felix/statuses/107224289116410645/activity",
+    "type": "Create",
+    "actor": "https://mastodon.madrid/users/felix",
+    "published": "2021-11-05T11:46:50Z",
+    "to": [
+        "https://mastodon.madrid/users/felix/followers"
+    ],
+    "cc": [
+        "https://www.w3.org/ns/activitystreams#Public",
+        "https://mamot.fr/users/retiolus"
+    ],
+    "object": {
+        "id": "https://mastodon.madrid/users/felix/statuses/107224289116410645",
+        "type": "Note",
+        "summary": null,
+        "inReplyTo": "https://mamot.fr/users/retiolus/statuses/107224244380204526",
+        "published": "2021-11-05T11:46:50Z",
+        "url": "https://mastodon.madrid/@felix/107224289116410645",
+        "attributedTo": "https://mastodon.madrid/users/felix",
+        "to": [
+            "https://mastodon.madrid/users/felix/followers"
+        ],
+        "cc": [
+            "https://www.w3.org/ns/activitystreams#Public",
+            "https://mamot.fr/users/retiolus"
+        ],
+        "sensitive": false,
+        "atomUri": "https://mastodon.madrid/users/felix/statuses/107224289116410645",
+        "inReplyToAtomUri": "https://mamot.fr/users/retiolus/statuses/107224244380204526",
+        "conversation": "tag:mamot.fr,2021-11-05:objectId=64635960:objectType=Conversation",
+        "content": "<p><span class=\"h-card\"><a href=\"https://mamot.fr/@retiolus\" class=\"u-url mention\">@<span>retiolus</span></a></span> i have never been disappointed by a thinkpad. if you want to save money, get a model from a few years ago, there isnt a huge difference anyway.</p>",
+        "contentMap": {
+            "en": "<p><span class=\"h-card\"><a href=\"https://mamot.fr/@retiolus\" class=\"u-url mention\">@<span>retiolus</span></a></span> i have neverbeendisappointed by a thinkpad. if you want to save money, get a model from a few years ago, there isnt a huge difference anyway.</p>"
+        },
+        "attachment": [],
+        "tag": [
+            {
+                "type": "Mention",
+                "href": "https://mamot.fr/users/retiolus",
+                "name": "@retiolus@mamot.fr"
+            }
+        ],
+        "replies": {
+            "id": "https://mastodon.madrid/users/felix/statuses/107224289116410645/replies",
+            "type": "Collection",
+            "first": {
+                "type": "CollectionPage",
+                "next": "https://mastodon.madrid/users/felix/statuses/107224289116410645/replies?only_other_accounts=true&page=true",
+                "partOf": "https://mastodon.madrid/users/felix/statuses/107224289116410645/replies",
+                "items": []
+            }
+        }
+    }
+}
diff --git a/crates/apub/assets/mastodon/activities/follow.json b/crates/apub/assets/mastodon/activities/follow.json
new file mode 100644
index 00000000..9f3651e3
--- /dev/null
+++ b/crates/apub/assets/mastodon/activities/follow.json
@@ -0,0 +1,7 @@
+{
+    "@context": "https://www.w3.org/ns/activitystreams",
+    "id": "https://masto.asonix.dog/1ea87517-63c5-4118-8831-460ee641b2cf",
+    "type": "Follow",
+    "actor": "https://masto.asonix.dog/users/asonix",
+    "object": "https://ds9.lemmy.ml/c/testcom"
+}
diff --git a/crates/apub/assets/mastodon/activities/like_page.json b/crates/apub/assets/mastodon/activities/like_page.json
new file mode 100644
index 00000000..646f3c5f
--- /dev/null
+++ b/crates/apub/assets/mastodon/activities/like_page.json
@@ -0,0 +1,7 @@
+{
+    "@context": "https://www.w3.org/ns/activitystreams",
+    "id": "https://mastodon.madrid/users/felix#likes/212340",
+    "type": "Like",
+    "actor": "https://mastodon.madrid/users/felix",
+    "object": "https://ds9.lemmy.ml/post/147"
+}
diff --git a/crates/apub/assets/mastodon/activities/undo_follow.json b/crates/apub/assets/mastodon/activities/undo_follow.json
new file mode 100644
index 00000000..f269cef5
--- /dev/null
+++ b/crates/apub/assets/mastodon/activities/undo_follow.json
@@ -0,0 +1,12 @@
+{
+    "@context": "https://www.w3.org/ns/activitystreams",
+    "id": "https://masto.asonix.dog/users/asonix#follows/449/undo",
+    "type": "Undo",
+    "actor": "https://masto.asonix.dog/users/asonix",
+    "object": {
+        "id": "https://masto.asonix.dog/1ea87517-63c5-4118-8831-460ee641b2cf",
+        "type": "Follow",
+        "actor": "https://masto.asonix.dog/users/asonix",
+        "object": "https://ds9.lemmy.ml/c/testcom"
+    }
+}
diff --git a/crates/apub/assets/mastodon/activities/undo_like_page.json b/crates/apub/assets/mastodon/activities/undo_like_page.json
new file mode 100644
index 00000000..06436fda
--- /dev/null
+++ b/crates/apub/assets/mastodon/activities/undo_like_page.json
@@ -0,0 +1,12 @@
+{
+    "@context": "https://www.w3.org/ns/activitystreams",
+    "id": "https://mastodon.madrid/users/felix#likes/212341/undo",
+    "type": "Undo",
+    "actor": "https://mastodon.madrid/users/felix",
+    "object": {
+        "id": "https://mastodon.madrid/users/felix#likes/212341",
+        "type": "Like",
+        "actor": "https://mastodon.madrid/users/felix",
+        "object": "https://ds9.lemmy.ml/post/147"
+    }
+}
diff --git a/crates/apub/src/activities/activity_lists.rs b/crates/apub/src/activities/activity_lists.rs
index b33441c0..b7a39d3b 100644
--- a/crates/apub/src/activities/activity_lists.rs
+++ b/crates/apub/src/activities/activity_lists.rs
@@ -2,28 +2,23 @@ use activitypub_federation::{config::Data, traits::ActivityHandler};
 use serde::{Deserialize, Serialize};
 use url::Url;
 
-use crate::activities::{AcceptFollow, CreateOrUpdateNote, Follow, Like, UndoFollow, UndoLike};
+use crate::activities::{
+    AcceptFollow,
+    CreateOrUpdateNote,
+    Follow,
+    LikeOrAnnounce,
+    UndoFollow,
+    UndoLikeOrAnnounce,
+};
 
 #[derive(Debug, Deserialize, Serialize)]
 #[serde(untagged)]
 #[enum_delegate::implement(ActivityHandler)]
-pub enum SharedInboxActivities {
+pub enum UserInboxActivities {
     CreateOrUpdateNote(CreateOrUpdateNote),
     Follow(Follow),
     AcceptFollow(AcceptFollow),
     UndoFollow(UndoFollow),
-    Like(Like),
-    UndoLike(UndoLike),
-}
-
-#[derive(Debug, Deserialize, Serialize)]
-#[serde(untagged)]
-#[enum_delegate::implement(ActivityHandler)]
-pub enum ServiceInboxActivities {
-    CreateOrUpdateNote(CreateOrUpdateNote),
-    Follow(Follow),
-    AcceptFollow(AcceptFollow),
-    UndoFollow(UndoFollow),
-    Like(Like),
-    UndoLike(UndoLike),
+    LikeOrAnnounce(LikeOrAnnounce),
+    UndoLikeOrAnnounce(UndoLikeOrAnnounce),
 }
diff --git a/crates/apub/src/activities/like/like.rs b/crates/apub/src/activities/like/like.rs
deleted file mode 100644
index db4b887c..00000000
--- a/crates/apub/src/activities/like/like.rs
+++ /dev/null
@@ -1,77 +0,0 @@
-use std::ops::Deref;
-
-use activitypub_federation::{
-    config::Data,
-    fetch::object_id::ObjectId,
-    kinds::activity::LikeType,
-    traits::ActivityHandler,
-};
-use hatsu_db_schema::{prelude::ReceivedLike, received_like};
-use hatsu_utils::{AppData, AppError};
-use sea_orm::{
-    ActiveModelTrait,
-    ColumnTrait,
-    Condition,
-    EntityTrait,
-    IntoActiveModel,
-    QueryFilter,
-};
-use serde::{Deserialize, Serialize};
-use url::Url;
-
-use crate::{activities::ApubReceivedLike, actors::ApubUser, objects::ApubPost};
-
-#[derive(Clone, Debug, Deserialize, Serialize)]
-#[serde(rename_all = "camelCase")]
-pub struct Like {
-    #[serde(rename = "type")]
-    pub(crate) kind: LikeType,
-    pub(crate) id: Url,
-    pub(crate) actor: ObjectId<ApubUser>,
-    pub(crate) object: ObjectId<ApubPost>,
-}
-
-/// receive only
-#[async_trait::async_trait]
-impl ActivityHandler for Like {
-    type DataType = AppData;
-    type Error = AppError;
-
-    fn id(&self) -> &Url {
-        &self.id
-    }
-
-    fn actor(&self) -> &Url {
-        self.actor.inner()
-    }
-
-    async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
-        // TODO
-        Ok(())
-    }
-
-    async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
-        let actor = self.actor.dereference(data).await?;
-        let object = self.object.dereference_local(data).await?;
-
-        if ReceivedLike::find()
-            .filter(
-                Condition::all()
-                    .add(received_like::Column::Actor.eq(&actor.id))
-                    .add(received_like::Column::Object.eq(&object.id)),
-            )
-            .one(&data.conn)
-            .await?
-            .is_none()
-        {
-            ApubReceivedLike::from_json(&self)?
-                .deref()
-                .clone()
-                .into_active_model()
-                .insert(&data.conn)
-                .await?;
-        }
-
-        Ok(())
-    }
-}
diff --git a/crates/apub/src/activities/like/mod.rs b/crates/apub/src/activities/like/mod.rs
deleted file mode 100644
index f0140ad9..00000000
--- a/crates/apub/src/activities/like/mod.rs
+++ /dev/null
@@ -1,9 +0,0 @@
-mod db_received_like;
-mod db_received_like_impl;
-
-mod like;
-mod undo_like;
-
-pub use db_received_like::ApubReceivedLike;
-pub use like::Like;
-pub use undo_like::UndoLike;
diff --git a/crates/apub/src/activities/like_or_announce/db_received_announce.rs b/crates/apub/src/activities/like_or_announce/db_received_announce.rs
new file mode 100644
index 00000000..fedf0aa8
--- /dev/null
+++ b/crates/apub/src/activities/like_or_announce/db_received_announce.rs
@@ -0,0 +1,26 @@
+use std::ops::Deref;
+
+use hatsu_db_schema::received_announce::Model as DbReceivedAnnounce;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ApubReceivedAnnounce(pub(crate) DbReceivedAnnounce);
+
+impl AsRef<DbReceivedAnnounce> for ApubReceivedAnnounce {
+    fn as_ref(&self) -> &DbReceivedAnnounce {
+        &self.0
+    }
+}
+
+impl Deref for ApubReceivedAnnounce {
+    type Target = DbReceivedAnnounce;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl From<DbReceivedAnnounce> for ApubReceivedAnnounce {
+    fn from(u: DbReceivedAnnounce) -> Self {
+        Self(u)
+    }
+}
diff --git a/crates/apub/src/activities/like_or_announce/db_received_announce_impl.rs b/crates/apub/src/activities/like_or_announce/db_received_announce_impl.rs
new file mode 100644
index 00000000..42439882
--- /dev/null
+++ b/crates/apub/src/activities/like_or_announce/db_received_announce_impl.rs
@@ -0,0 +1,26 @@
+use activitypub_federation::kinds::activity::AnnounceType;
+use hatsu_db_schema::received_announce::Model as DbReceivedAnnounce;
+use hatsu_utils::AppError;
+use url::Url;
+
+use crate::activities::{ApubReceivedAnnounce, LikeOrAnnounce, LikeOrAnnounceType};
+
+impl ApubReceivedAnnounce {
+    pub fn into_json(self) -> Result<LikeOrAnnounce, AppError> {
+        Ok(LikeOrAnnounce {
+            kind: LikeOrAnnounceType::AnnounceType(AnnounceType::Announce),
+            id: Url::parse(&self.id)?,
+            actor: Url::parse(&self.actor)?.into(),
+            object: Url::parse(&self.object)?.into(),
+        })
+    }
+
+    pub fn from_json(json: &LikeOrAnnounce) -> Result<Self, AppError> {
+        Ok(DbReceivedAnnounce {
+            id: json.id.to_string(),
+            actor: json.actor.to_string(),
+            object: json.object.to_string(),
+        }
+        .into())
+    }
+}
diff --git a/crates/apub/src/activities/like/db_received_like.rs b/crates/apub/src/activities/like_or_announce/db_received_like.rs
similarity index 100%
rename from crates/apub/src/activities/like/db_received_like.rs
rename to crates/apub/src/activities/like_or_announce/db_received_like.rs
diff --git a/crates/apub/src/activities/like/db_received_like_impl.rs b/crates/apub/src/activities/like_or_announce/db_received_like_impl.rs
similarity index 63%
rename from crates/apub/src/activities/like/db_received_like_impl.rs
rename to crates/apub/src/activities/like_or_announce/db_received_like_impl.rs
index 9d74ebfd..4c0716aa 100644
--- a/crates/apub/src/activities/like/db_received_like_impl.rs
+++ b/crates/apub/src/activities/like_or_announce/db_received_like_impl.rs
@@ -3,19 +3,19 @@ use hatsu_db_schema::received_like::Model as DbReceivedLike;
 use hatsu_utils::AppError;
 use url::Url;
 
-use crate::activities::{ApubReceivedLike, Like};
+use crate::activities::{ApubReceivedLike, LikeOrAnnounce, LikeOrAnnounceType};
 
 impl ApubReceivedLike {
-    pub fn into_json(self) -> Result<Like, AppError> {
-        Ok(Like {
-            kind: LikeType::Like,
+    pub fn into_json(self) -> Result<LikeOrAnnounce, AppError> {
+        Ok(LikeOrAnnounce {
+            kind: LikeOrAnnounceType::LikeType(LikeType::Like),
             id: Url::parse(&self.id)?,
             actor: Url::parse(&self.actor)?.into(),
             object: Url::parse(&self.object)?.into(),
         })
     }
 
-    pub fn from_json(json: &Like) -> Result<Self, AppError> {
+    pub fn from_json(json: &LikeOrAnnounce) -> Result<Self, AppError> {
         Ok(DbReceivedLike {
             id: json.id.to_string(),
             actor: json.actor.to_string(),
diff --git a/crates/apub/src/activities/like_or_announce/like_or_announce.rs b/crates/apub/src/activities/like_or_announce/like_or_announce.rs
new file mode 100644
index 00000000..4fcc3daa
--- /dev/null
+++ b/crates/apub/src/activities/like_or_announce/like_or_announce.rs
@@ -0,0 +1,103 @@
+use std::ops::Deref;
+
+use activitypub_federation::{config::Data, fetch::object_id::ObjectId, traits::ActivityHandler};
+use hatsu_db_schema::{
+    prelude::{ReceivedAnnounce, ReceivedLike},
+    received_announce,
+    received_like,
+};
+use hatsu_utils::{AppData, AppError};
+use sea_orm::{
+    ActiveModelTrait,
+    ColumnTrait,
+    Condition,
+    EntityTrait,
+    IntoActiveModel,
+    QueryFilter,
+};
+use serde::{Deserialize, Serialize};
+use url::Url;
+
+use crate::{
+    activities::{ApubReceivedAnnounce, ApubReceivedLike, LikeOrAnnounceType},
+    actors::ApubUser,
+    objects::ApubPost,
+};
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct LikeOrAnnounce {
+    #[serde(rename = "type")]
+    pub(crate) kind: LikeOrAnnounceType,
+    pub(crate) id: Url,
+    pub(crate) actor: ObjectId<ApubUser>,
+    pub(crate) object: ObjectId<ApubPost>,
+}
+
+/// receive only
+#[async_trait::async_trait]
+impl ActivityHandler for LikeOrAnnounce {
+    type DataType = AppData;
+    type Error = AppError;
+
+    fn id(&self) -> &Url {
+        &self.id
+    }
+
+    fn actor(&self) -> &Url {
+        self.actor.inner()
+    }
+
+    async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
+        // TODO
+        Ok(())
+    }
+
+    async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
+        let actor = self.actor.dereference(data).await?;
+        let object = self.object.dereference_local(data).await?;
+
+        match self.kind {
+            LikeOrAnnounceType::LikeType(_) => {
+                if ReceivedLike::find()
+                    .filter(
+                        Condition::all()
+                            .add(received_like::Column::Actor.eq(&actor.id))
+                            .add(received_like::Column::Object.eq(&object.id)),
+                    )
+                    .one(&data.conn)
+                    .await?
+                    .is_none()
+                {
+                    ApubReceivedLike::from_json(&self)?
+                        .deref()
+                        .clone()
+                        .into_active_model()
+                        .insert(&data.conn)
+                        .await?;
+                }
+            },
+            LikeOrAnnounceType::AnnounceType(_) => {
+                if ReceivedAnnounce::find()
+                    .filter(
+                        Condition::all()
+                            .add(received_announce::Column::Actor.eq(&actor.id))
+                            .add(received_announce::Column::Object.eq(&object.id)),
+                    )
+                    .one(&data.conn)
+                    .await?
+                    .is_none()
+                {
+                    ApubReceivedAnnounce::from_json(&self)?
+                        .deref()
+                        .clone()
+                        .into_active_model()
+                        .insert(&data.conn)
+                        .await?;
+                }
+            },
+        }
+
+        Ok(())
+    }
+}
diff --git a/crates/apub/src/activities/like_or_announce/mod.rs b/crates/apub/src/activities/like_or_announce/mod.rs
new file mode 100644
index 00000000..d54433bf
--- /dev/null
+++ b/crates/apub/src/activities/like_or_announce/mod.rs
@@ -0,0 +1,33 @@
+use std::fmt::{Display, Formatter, Result};
+
+use activitypub_federation::kinds::activity::{AnnounceType, LikeType};
+use serde::{Deserialize, Serialize};
+
+mod db_received_announce;
+mod db_received_announce_impl;
+mod db_received_like;
+mod db_received_like_impl;
+
+mod like_or_announce;
+mod undo_like_or_announce;
+
+pub use db_received_announce::ApubReceivedAnnounce;
+pub use db_received_like::ApubReceivedLike;
+pub use like_or_announce::LikeOrAnnounce;
+pub use undo_like_or_announce::UndoLikeOrAnnounce;
+
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(untagged)]
+pub enum LikeOrAnnounceType {
+    LikeType(LikeType),
+    AnnounceType(AnnounceType),
+}
+
+impl Display for LikeOrAnnounceType {
+    fn fmt(&self, f: &mut Formatter) -> Result {
+        match self {
+            Self::LikeType(_) => f.write_str(&LikeType::Like.to_string()),
+            Self::AnnounceType(_) => f.write_str(&AnnounceType::Announce.to_string()),
+        }
+    }
+}
diff --git a/crates/apub/src/activities/like/undo_like.rs b/crates/apub/src/activities/like_or_announce/undo_like_or_announce.rs
similarity index 57%
rename from crates/apub/src/activities/like/undo_like.rs
rename to crates/apub/src/activities/like_or_announce/undo_like_or_announce.rs
index 40521243..7a4371a3 100644
--- a/crates/apub/src/activities/like/undo_like.rs
+++ b/crates/apub/src/activities/like_or_announce/undo_like_or_announce.rs
@@ -4,27 +4,28 @@ use activitypub_federation::{
     kinds::activity::UndoType,
     traits::ActivityHandler,
 };
-use hatsu_db_schema::prelude::ReceivedLike;
+use hatsu_db_schema::prelude::{ReceivedAnnounce, ReceivedLike};
 use hatsu_utils::{AppData, AppError};
 use sea_orm::EntityTrait;
 use serde::{Deserialize, Serialize};
 use url::Url;
 
-use crate::{activities::Like, actors::ApubUser};
+use super::LikeOrAnnounceType;
+use crate::{activities::LikeOrAnnounce, actors::ApubUser};
 
 #[derive(Clone, Debug, Deserialize, Serialize)]
 #[serde(rename_all = "camelCase")]
-pub struct UndoLike {
+pub struct UndoLikeOrAnnounce {
     #[serde(rename = "type")]
     pub(crate) kind: UndoType,
     pub(crate) id: Url,
     pub(crate) actor: ObjectId<ApubUser>,
-    pub(crate) object: Like,
+    pub(crate) object: LikeOrAnnounce,
 }
 
 /// receive only
 #[async_trait::async_trait]
-impl ActivityHandler for UndoLike {
+impl ActivityHandler for UndoLikeOrAnnounce {
     type DataType = AppData;
     type Error = AppError;
 
@@ -42,9 +43,16 @@ impl ActivityHandler for UndoLike {
     }
 
     async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
-        ReceivedLike::delete_by_id(self.object.id)
-            .exec(&data.conn)
-            .await?;
+        match self.object.kind {
+            LikeOrAnnounceType::LikeType(_) =>
+                ReceivedLike::delete_by_id(self.object.id)
+                    .exec(&data.conn)
+                    .await?,
+            LikeOrAnnounceType::AnnounceType(_) =>
+                ReceivedAnnounce::delete_by_id(self.object.id)
+                    .exec(&data.conn)
+                    .await?,
+        };
 
         Ok(())
     }
diff --git a/crates/apub/src/activities/mod.rs b/crates/apub/src/activities/mod.rs
index e1a88c09..6c87a83a 100644
--- a/crates/apub/src/activities/mod.rs
+++ b/crates/apub/src/activities/mod.rs
@@ -3,10 +3,16 @@ mod create_or_update;
 mod db_activity;
 mod db_activity_impl;
 mod following;
-mod like;
+mod like_or_announce;
 
-pub use activity_lists::{ServiceInboxActivities, SharedInboxActivities};
+pub use activity_lists::UserInboxActivities;
 pub use create_or_update::{CreateOrUpdateNote, CreateOrUpdateType};
 pub use db_activity::ApubActivity;
 pub use following::{AcceptFollow, ApubReceivedFollow, Follow, UndoFollow};
-pub use like::{ApubReceivedLike, Like, UndoLike};
+pub use like_or_announce::{
+    ApubReceivedAnnounce,
+    ApubReceivedLike,
+    LikeOrAnnounce,
+    LikeOrAnnounceType,
+    UndoLikeOrAnnounce,
+};
diff --git a/crates/db_migration/src/lib.rs b/crates/db_migration/src/lib.rs
index c7ee5875..2f3098a6 100644
--- a/crates/db_migration/src/lib.rs
+++ b/crates/db_migration/src/lib.rs
@@ -6,6 +6,7 @@ mod m20240131_000003_post;
 mod m20240131_000004_activity;
 mod m20240131_000005_received_follow;
 mod m20240501_000001_received_like;
+mod m20240501_000002_received_announce;
 
 pub struct Migrator;
 
@@ -19,6 +20,7 @@ impl MigratorTrait for Migrator {
             Box::new(m20240131_000004_activity::Migration),
             Box::new(m20240131_000005_received_follow::Migration),
             Box::new(m20240501_000001_received_like::Migration),
+            Box::new(m20240501_000002_received_announce::Migration),
         ]
     }
 }
diff --git a/crates/db_migration/src/m20240501_000002_received_announce.rs b/crates/db_migration/src/m20240501_000002_received_announce.rs
new file mode 100644
index 00000000..d26c2ac8
--- /dev/null
+++ b/crates/db_migration/src/m20240501_000002_received_announce.rs
@@ -0,0 +1,48 @@
+use sea_orm_migration::prelude::*;
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+        manager
+            .create_table(
+                Table::create()
+                    .table(ReceivedAnnounce::Table)
+                    .if_not_exists()
+                    .col(
+                        ColumnDef::new(ReceivedAnnounce::Id)
+                            .string()
+                            .not_null()
+                            .primary_key(),
+                    )
+                    .col(ColumnDef::new(ReceivedAnnounce::Actor).string().not_null())
+                    .col(ColumnDef::new(ReceivedAnnounce::Object).string().not_null())
+                    .to_owned(),
+            )
+            .await?;
+
+        Ok(())
+    }
+
+    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+        manager
+            .drop_table(Table::drop().table(ReceivedAnnounce::Table).to_owned())
+            .await?;
+
+        Ok(())
+    }
+}
+
+/// <https://www.w3.org/ns/activitystreams#Announce>
+#[derive(Iden)]
+enum ReceivedAnnounce {
+    Table,
+    // Announce Activity Url
+    Id,
+    // Attributed To
+    Actor,
+    // Announced Post Url
+    Object,
+}
diff --git a/crates/db_schema/src/lib.rs b/crates/db_schema/src/lib.rs
index 4626a858..5bfa26eb 100644
--- a/crates/db_schema/src/lib.rs
+++ b/crates/db_schema/src/lib.rs
@@ -4,6 +4,7 @@ pub mod prelude;
 
 pub mod activity;
 pub mod post;
+pub mod received_announce;
 pub mod received_follow;
 pub mod received_like;
 pub mod user;
diff --git a/crates/db_schema/src/post.rs b/crates/db_schema/src/post.rs
index 5ad52fcd..56697fbb 100644
--- a/crates/db_schema/src/post.rs
+++ b/crates/db_schema/src/post.rs
@@ -19,6 +19,8 @@ pub struct Model {
 
 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
 pub enum Relation {
+    #[sea_orm(has_many = "super::received_announce::Entity")]
+    ReceivedAnnounce,
     #[sea_orm(has_many = "super::received_like::Entity")]
     ReceivedLike,
     #[sea_orm(
@@ -43,6 +45,12 @@ pub struct SelfReferencingLink;
 
 impl ActiveModelBehavior for ActiveModel {}
 
+impl Related<super::received_announce::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::ReceivedAnnounce.def()
+    }
+}
+
 impl Related<super::received_like::Entity> for Entity {
     fn to() -> RelationDef {
         Relation::ReceivedLike.def()
diff --git a/crates/db_schema/src/prelude.rs b/crates/db_schema/src/prelude.rs
index b8b356a4..29f3cd06 100644
--- a/crates/db_schema/src/prelude.rs
+++ b/crates/db_schema/src/prelude.rs
@@ -3,6 +3,7 @@
 pub use super::{
     activity::Entity as Activity,
     post::Entity as Post,
+    received_announce::Entity as ReceivedAnnounce,
     received_follow::Entity as ReceivedFollow,
     received_like::Entity as ReceivedLike,
     user::Entity as User,
diff --git a/crates/db_schema/src/received_announce.rs b/crates/db_schema/src/received_announce.rs
new file mode 100644
index 00000000..a575bd32
--- /dev/null
+++ b/crates/db_schema/src/received_announce.rs
@@ -0,0 +1,43 @@
+//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.2
+
+use sea_orm::entity::prelude::*;
+
+#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
+#[sea_orm(table_name = "received_announce")]
+pub struct Model {
+    #[sea_orm(primary_key, auto_increment = false)]
+    pub id: String,
+    pub actor: String,
+    pub object: String,
+}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+
+pub enum Relation {
+    #[sea_orm(
+        belongs_to = "super::post::Entity",
+        from = "Column::Object",
+        to = "super::post::Column::Id"
+    )]
+    Post,
+    #[sea_orm(
+        belongs_to = "super::user::Entity",
+        from = "Column::Actor",
+        to = "super::user::Column::Id"
+    )]
+    User,
+}
+
+impl ActiveModelBehavior for ActiveModel {}
+
+impl Related<super::post::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::Post.def()
+    }
+}
+
+impl Related<super::user::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::User.def()
+    }
+}
diff --git a/crates/db_schema/src/user.rs b/crates/db_schema/src/user.rs
index 859e98f8..2c67531e 100644
--- a/crates/db_schema/src/user.rs
+++ b/crates/db_schema/src/user.rs
@@ -31,6 +31,8 @@ pub enum Relation {
     Activity,
     #[sea_orm(has_many = "super::post::Entity")]
     Post,
+    #[sea_orm(has_many = "super::received_announce::Entity")]
+    ReceivedAnnounce,
     #[sea_orm(has_many = "super::received_follow::Entity")]
     ReceivedFollow,
     #[sea_orm(has_many = "super::received_like::Entity")]
@@ -53,6 +55,12 @@ impl Related<super::post::Entity> for Entity {
     }
 }
 
+impl Related<super::received_announce::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::ReceivedAnnounce.def()
+    }
+}
+
 impl Related<super::received_follow::Entity> for Entity {
     fn to() -> RelationDef {
         Relation::ReceivedFollow.def()
diff --git a/docs/src/others/federation.md b/docs/src/others/federation.md
index b34ee929..418f6dfd 100644
--- a/docs/src/others/federation.md
+++ b/docs/src/others/federation.md
@@ -32,9 +32,9 @@ The following activities and object types are supported:
 - `Follow(Actor)`, `Undo(Follow)`
 - `Create(Note)`
 - `Like(Note)`, `Undo(Like)`
+- `Announce(Note)`, `Undo(Announce)`
 
 <!-- - `Create(Note)`, `Update(Note)`, `Delete(Note)` -->
-<!-- - `Announce(Note)`, `Undo(Announce)` -->
 
 Activities are implemented in way that is compatible with Mastodon and other
 popular ActivityPub servers.


Test patch:
diff --git a/crates/apub/tests/activities.rs b/crates/apub/tests/activities.rs
new file mode 100644
index 00000000..95a463f0
--- /dev/null
+++ b/crates/apub/tests/activities.rs
@@ -0,0 +1,16 @@
+use hatsu_apub::{
+    activities::{CreateOrUpdateNote, Follow, LikeOrAnnounce, UndoFollow, UndoLikeOrAnnounce},
+    tests::test_asset,
+};
+use hatsu_utils::AppError;
+
+#[test]
+fn test_parse_activities() -> Result<(), AppError> {
+    test_asset::<CreateOrUpdateNote>("assets/mastodon/activities/create_note.json")?;
+    test_asset::<Follow>("assets/mastodon/activities/follow.json")?;
+    test_asset::<LikeOrAnnounce>("assets/mastodon/activities/like_page.json")?;
+    test_asset::<UndoFollow>("assets/mastodon/activities/undo_follow.json")?;
+    test_asset::<UndoLikeOrAnnounce>("assets/mastodon/activities/undo_like_page.json")?;
+
+    Ok(())
+}