Parakeet is a Rust-based Bluesky AppView aiming to implement most of the functionality required to support the Bluesky client

Merge branch 'feat-mutes' into 'main'

Mutes

See merge request parakeet-social/parakeet!20

Changed files
+264 -2
migrations
2025-08-03-125504_mutes
parakeet
src
xrpc
parakeet-db
+2
migrations/2025-08-03-125504_mutes/down.sql
··· 1 + drop table list_mutes; 2 + drop table mutes;
+22
migrations/2025-08-03-125504_mutes/up.sql
··· 1 + create table list_mutes 2 + ( 3 + did text not null references actors (did), 4 + list_uri text not null, 5 + created_at timestamptz not null default now(), 6 + 7 + primary key (did, list_uri) 8 + ); 9 + 10 + create index listmutes_list_index on list_mutes using hash (list_uri); 11 + create index listmutes_did_index on list_mutes using hash (did); 12 + 13 + create table mutes 14 + ( 15 + did text not null references actors (did), 16 + subject text not null, 17 + created_at timestamptz not null default now(), 18 + 19 + primary key (did, subject) 20 + ); 21 + 22 + create index mutes_subject_index on mutes (subject);
+16
parakeet-db/src/models.rs
··· 367 367 pub created_at: DateTime<Utc>, 368 368 pub indexed_at: NaiveDateTime, 369 369 } 370 + 371 + #[derive(Debug, Insertable, AsChangeset)] 372 + #[diesel(table_name = crate::schema::mutes)] 373 + #[diesel(check_for_backend(diesel::pg::Pg))] 374 + pub struct NewMute<'a> { 375 + pub did: &'a str, 376 + pub subject: &'a str, 377 + } 378 + 379 + #[derive(Debug, Insertable, AsChangeset)] 380 + #[diesel(table_name = crate::schema::list_mutes)] 381 + #[diesel(check_for_backend(diesel::pg::Pg))] 382 + pub struct NewListMute<'a> { 383 + pub did: &'a str, 384 + pub list_uri: &'a str, 385 + }
+20
parakeet-db/src/schema.rs
··· 151 151 } 152 152 153 153 diesel::table! { 154 + list_mutes (did, list_uri) { 155 + did -> Text, 156 + list_uri -> Text, 157 + created_at -> Timestamptz, 158 + } 159 + } 160 + 161 + diesel::table! { 154 162 lists (at_uri) { 155 163 at_uri -> Text, 156 164 owner -> Text, ··· 162 170 avatar_cid -> Nullable<Text>, 163 171 created_at -> Timestamptz, 164 172 indexed_at -> Timestamp, 173 + } 174 + } 175 + 176 + diesel::table! { 177 + mutes (did, subject) { 178 + did -> Text, 179 + subject -> Text, 180 + created_at -> Timestamptz, 165 181 } 166 182 } 167 183 ··· 366 382 diesel::joinable!(labelers -> actors (did)); 367 383 diesel::joinable!(likes -> actors (did)); 368 384 diesel::joinable!(list_blocks -> actors (did)); 385 + diesel::joinable!(list_mutes -> actors (did)); 369 386 diesel::joinable!(lists -> actors (owner)); 387 + diesel::joinable!(mutes -> actors (did)); 370 388 diesel::joinable!(notif_decl -> actors (did)); 371 389 diesel::joinable!(post_embed_ext -> posts (post_uri)); 372 390 diesel::joinable!(post_embed_images -> posts (post_uri)); ··· 396 414 likes, 397 415 list_blocks, 398 416 list_items, 417 + list_mutes, 399 418 lists, 419 + mutes, 400 420 notif_decl, 401 421 post_embed_ext, 402 422 post_embed_images,
+47 -1
parakeet/src/xrpc/app_bsky/graph/lists.rs
··· 1 1 use crate::hydration::StatefulHydrator; 2 2 use crate::xrpc::error::{Error, XrpcResult}; 3 3 use crate::xrpc::extract::{AtpAcceptLabelers, AtpAuth}; 4 - use crate::xrpc::{check_actor_status, datetime_cursor, get_actor_did, ActorWithCursorQuery}; 4 + use crate::xrpc::{ 5 + check_actor_status, datetime_cursor, get_actor_did, ActorWithCursorQuery, CursorQuery, 6 + }; 5 7 use crate::GlobalState; 6 8 use axum::extract::{Query, State}; 7 9 use axum::Json; ··· 135 137 items, 136 138 })) 137 139 } 140 + 141 + #[derive(Debug, Serialize)] 142 + pub struct GetListMutesRes { 143 + #[serde(skip_serializing_if = "Option::is_none")] 144 + cursor: Option<String>, 145 + lists: Vec<ListView>, 146 + } 147 + 148 + pub async fn get_list_mutes( 149 + State(state): State<GlobalState>, 150 + AtpAcceptLabelers(labelers): AtpAcceptLabelers, 151 + auth: AtpAuth, 152 + Query(query): Query<CursorQuery>, 153 + ) -> XrpcResult<Json<GetListMutesRes>> { 154 + let mut conn = state.pool.get().await?; 155 + let did = auth.0.clone(); 156 + let hyd = StatefulHydrator::new(&state.dataloaders, &state.cdn, &labelers, Some(auth)); 157 + 158 + let limit = query.limit.unwrap_or(50).clamp(1, 100); 159 + 160 + let mut mutes_query = schema::list_mutes::table 161 + .select(schema::list_mutes::list_uri) 162 + .filter(schema::list_mutes::did.eq(did)) 163 + .into_boxed(); 164 + 165 + if let Some(cursor) = query.cursor { 166 + mutes_query = mutes_query.filter(schema::list_mutes::list_uri.lt(cursor)); 167 + } 168 + 169 + let mutes = mutes_query 170 + .order(schema::list_mutes::list_uri.desc()) 171 + .limit(limit as i64) 172 + .load(&mut conn) 173 + .await?; 174 + 175 + let lists = hyd.hydrate_lists(mutes).await; 176 + let mutes = lists.into_values().collect::<Vec<_>>(); 177 + let cursor = mutes.last().map(|v| v.uri.clone()); 178 + 179 + Ok(Json(GetListMutesRes { 180 + cursor, 181 + lists: mutes, 182 + })) 183 + }
+1
parakeet/src/xrpc/app_bsky/graph/mod.rs
··· 1 1 pub mod lists; 2 + pub mod mutes; 2 3 pub mod relations; 3 4 pub mod starter_packs;
+143
parakeet/src/xrpc/app_bsky/graph/mutes.rs
··· 1 + use crate::hydration::StatefulHydrator; 2 + use crate::xrpc::error::XrpcResult; 3 + use crate::xrpc::extract::{AtpAcceptLabelers, AtpAuth}; 4 + use crate::xrpc::CursorQuery; 5 + use crate::GlobalState; 6 + use axum::extract::{Query, State}; 7 + use axum::Json; 8 + use diesel::prelude::*; 9 + use diesel_async::RunQueryDsl; 10 + use lexica::app_bsky::actor::ProfileView; 11 + use parakeet_db::{models, schema}; 12 + use serde::{Deserialize, Serialize}; 13 + 14 + #[derive(Debug, Serialize)] 15 + pub struct GetMutesRes { 16 + #[serde(skip_serializing_if = "Option::is_none")] 17 + cursor: Option<String>, 18 + mutes: Vec<ProfileView>, 19 + } 20 + 21 + pub async fn get_mutes( 22 + State(state): State<GlobalState>, 23 + AtpAcceptLabelers(labelers): AtpAcceptLabelers, 24 + auth: AtpAuth, 25 + Query(query): Query<CursorQuery>, 26 + ) -> XrpcResult<Json<GetMutesRes>> { 27 + let mut conn = state.pool.get().await?; 28 + let did = auth.0.clone(); 29 + let hyd = StatefulHydrator::new(&state.dataloaders, &state.cdn, &labelers, Some(auth)); 30 + 31 + let limit = query.limit.unwrap_or(50).clamp(1, 100); 32 + 33 + let mut muted_query = schema::mutes::table 34 + .select(schema::mutes::subject) 35 + .filter(schema::mutes::did.eq(did)) 36 + .into_boxed(); 37 + 38 + if let Some(cursor) = query.cursor { 39 + muted_query = muted_query.filter(schema::mutes::subject.lt(cursor)); 40 + } 41 + 42 + let muted = muted_query 43 + .order(schema::mutes::subject.desc()) 44 + .limit(limit as i64) 45 + .load(&mut conn) 46 + .await?; 47 + 48 + let profiles = hyd.hydrate_profiles(muted).await; 49 + let mutes = profiles.into_values().collect::<Vec<_>>(); 50 + let cursor = mutes.last().map(|v| v.did.clone()); 51 + 52 + Ok(Json(GetMutesRes { cursor, mutes })) 53 + } 54 + 55 + #[derive(Debug, Deserialize)] 56 + pub struct MuteActorReq { 57 + pub actor: String, 58 + } 59 + 60 + #[derive(Debug, Deserialize)] 61 + pub struct MuteActorListReq { 62 + pub list: String, 63 + } 64 + 65 + pub async fn mute_actor( 66 + State(state): State<GlobalState>, 67 + auth: AtpAuth, 68 + Json(form): Json<MuteActorReq>, 69 + ) -> XrpcResult<()> { 70 + let mut conn = state.pool.get().await?; 71 + 72 + let data = models::NewMute { 73 + did: &auth.0, 74 + subject: &form.actor, 75 + }; 76 + 77 + diesel::insert_into(schema::mutes::table) 78 + .values(&data) 79 + .on_conflict_do_nothing() 80 + .execute(&mut conn) 81 + .await?; 82 + 83 + Ok(()) 84 + } 85 + 86 + pub async fn mute_actor_list( 87 + State(state): State<GlobalState>, 88 + auth: AtpAuth, 89 + Json(form): Json<MuteActorListReq>, 90 + ) -> XrpcResult<()> { 91 + let mut conn = state.pool.get().await?; 92 + 93 + let data = models::NewListMute { 94 + did: &auth.0, 95 + list_uri: &form.list, 96 + }; 97 + 98 + diesel::insert_into(schema::list_mutes::table) 99 + .values(&data) 100 + .on_conflict_do_nothing() 101 + .execute(&mut conn) 102 + .await?; 103 + 104 + Ok(()) 105 + } 106 + 107 + pub async fn unmute_actor( 108 + State(state): State<GlobalState>, 109 + auth: AtpAuth, 110 + Json(form): Json<MuteActorReq>, 111 + ) -> XrpcResult<()> { 112 + let mut conn = state.pool.get().await?; 113 + 114 + diesel::delete(schema::mutes::table) 115 + .filter( 116 + schema::mutes::did 117 + .eq(&auth.0) 118 + .and(schema::mutes::subject.eq(&form.actor)), 119 + ) 120 + .execute(&mut conn) 121 + .await?; 122 + 123 + Ok(()) 124 + } 125 + 126 + pub async fn unmute_actor_list( 127 + State(state): State<GlobalState>, 128 + auth: AtpAuth, 129 + Json(form): Json<MuteActorListReq>, 130 + ) -> XrpcResult<()> { 131 + let mut conn = state.pool.get().await?; 132 + 133 + diesel::delete(schema::list_mutes::table) 134 + .filter( 135 + schema::list_mutes::did 136 + .eq(&auth.0) 137 + .and(schema::list_mutes::list_uri.eq(&form.list)), 138 + ) 139 + .execute(&mut conn) 140 + .await?; 141 + 142 + Ok(()) 143 + }
+7 -1
parakeet/src/xrpc/app_bsky/mod.rs
··· 1 - use axum::routing::get; 1 + use axum::routing::{get, post}; 2 2 use axum::Router; 3 3 4 4 mod actor; ··· 27 27 .route("/app.bsky.graph.getFollowers", get(graph::relations::get_followers)) 28 28 .route("/app.bsky.graph.getFollows", get(graph::relations::get_follows)) 29 29 .route("/app.bsky.graph.getList", get(graph::lists::get_list)) 30 + .route("/app.bsky.graph.getListMutes", get(graph::lists::get_list_mutes)) 30 31 .route("/app.bsky.graph.getLists", get(graph::lists::get_lists)) 32 + .route("/app.bsky.graph.getMutes", get(graph::mutes::get_mutes)) 31 33 .route("/app.bsky.graph.getStarterPack", get(graph::starter_packs::get_starter_pack)) 32 34 .route("/app.bsky.graph.getStarterPacks", get(graph::starter_packs::get_starter_packs)) 35 + .route("/app.bsky.graph.muteActor", post(graph::mutes::mute_actor)) 36 + .route("/app.bsky.graph.muteActorList", post(graph::mutes::mute_actor_list)) 37 + .route("/app.bsky.graph.unmuteActor", post(graph::mutes::unmute_actor)) 38 + .route("/app.bsky.graph.unmuteActorList", post(graph::mutes::unmute_actor_list)) 33 39 .route("/app.bsky.labeler.getServices", get(labeler::get_services)) 34 40 }
+6
parakeet/src/xrpc/mod.rs
··· 95 95 } 96 96 97 97 #[derive(Debug, Deserialize)] 98 + pub struct CursorQuery { 99 + pub limit: Option<u8>, 100 + pub cursor: Option<String>, 101 + } 102 + 103 + #[derive(Debug, Deserialize)] 98 104 pub struct ActorWithCursorQuery { 99 105 pub actor: String, 100 106 pub limit: Option<u8>,