The smokesignal.events web application

feature: Added discover_event and discover_rsvps identity profile configuration

Signed-off-by: Nick Gerakines <nick.gerakines@gmail.com>

+493 -42
+4
migrations/20250715000000_add_discovery_settings_to_identity_profiles.sql
···
··· 1 + -- Add discovery settings to identity_profiles 2 + ALTER TABLE identity_profiles 3 + ADD COLUMN discover_events BOOLEAN NOT NULL DEFAULT true, 4 + ADD COLUMN discover_rsvps BOOLEAN NOT NULL DEFAULT true;
+1 -4
src/atproto/lexicon/community_lexicon_calendar_event.rs
··· 169 #[serde(skip_serializing_if = "Vec::is_empty", default)] 170 uris: EventLinks, 171 172 - #[serde( 173 - skip_serializing_if = "Vec::is_empty", 174 - default 175 - )] 176 media: MediaList, 177 178 // This is a catch-all for any elements that are not implemented by
··· 169 #[serde(skip_serializing_if = "Vec::is_empty", default)] 170 uris: EventLinks, 171 172 + #[serde(skip_serializing_if = "Vec::is_empty", default)] 173 media: MediaList, 174 175 // This is a catch-all for any elements that are not implemented by
+4
src/consumer.rs
··· 12 pub enum SmokeSignalEvent { 13 Commit { 14 did: String, 15 rkey: String, 16 cid: String, 17 record: serde_json::Value, 18 }, 19 Delete { 20 did: String, 21 rkey: String, 22 }, 23 } ··· 43 44 SmokeSignalEvent::Commit { 45 did, 46 rkey: commit.rkey, 47 cid: commit.cid, 48 record: commit.record, ··· 54 } 55 SmokeSignalEvent::Delete { 56 did, 57 rkey: commit.rkey, 58 } 59 }
··· 12 pub enum SmokeSignalEvent { 13 Commit { 14 did: String, 15 + collection: String, 16 rkey: String, 17 cid: String, 18 record: serde_json::Value, 19 }, 20 Delete { 21 did: String, 22 + collection: String, 23 rkey: String, 24 }, 25 } ··· 45 46 SmokeSignalEvent::Commit { 47 did, 48 + collection: commit.collection, 49 rkey: commit.rkey, 50 cid: commit.cid, 51 record: commit.record, ··· 57 } 58 SmokeSignalEvent::Delete { 59 did, 60 + collection: commit.collection, 61 rkey: commit.rkey, 62 } 63 }
+7 -3
src/http/event_view.rs
··· 284 } 285 } 286 287 - use crate::atproto::lexicon::community::lexicon::calendar::event::{MediaList, Media}; 288 289 fn find_header(event: &Event) -> Option<(String, String)> { 290 let all_media = if let Some(value) = event ··· 308 alt, 309 role, 310 aspect_ratio, 311 - } if role == "header" && aspect_ratio.is_some() => (content, aspect_ratio.clone().unwrap(), alt.clone()), 312 _ => continue, 313 }; 314 315 let (reported_height, reported_width) = (aspect_ratio.height, aspect_ratio.width); 316 - if !(755..=12000).contains(&reported_height) || !(reported_height..=12000).contains(&reported_width) { 317 continue; 318 } 319 let is_16_9 =
··· 284 } 285 } 286 287 + use crate::atproto::lexicon::community::lexicon::calendar::event::{Media, MediaList}; 288 289 fn find_header(event: &Event) -> Option<(String, String)> { 290 let all_media = if let Some(value) = event ··· 308 alt, 309 role, 310 aspect_ratio, 311 + } if role == "header" && aspect_ratio.is_some() => { 312 + (content, aspect_ratio.clone().unwrap(), alt.clone()) 313 + } 314 _ => continue, 315 }; 316 317 let (reported_height, reported_width) = (aspect_ratio.height, aspect_ratio.width); 318 + if !(755..=12000).contains(&reported_height) 319 + || !(reported_height..=12000).contains(&reported_width) 320 + { 321 continue; 322 } 323 let is_16_9 =
+8 -4
src/http/handle_content.rs
··· 1 - use axum::{body::Body, extract::{Path, State}, response::{IntoResponse, Response}}; 2 use anyhow::Result; 3 - use http::{header, StatusCode}; 4 5 use crate::http::{context::WebContext, errors::WebError}; 6 7 /// GET /content/{cid} - Handle content requests. 8 /// Gets content from content storage and returns it as a response. 9 - pub (crate) async fn handle_content( 10 State(web_context): State<WebContext>, 11 Path(cid): Path<String>, 12 ) -> Result<impl IntoResponse, WebError> { ··· 34 .body(Body::from(content_data)) 35 .unwrap() 36 .into_response()) 37 - }
··· 1 use anyhow::Result; 2 + use axum::{ 3 + body::Body, 4 + extract::{Path, State}, 5 + response::{IntoResponse, Response}, 6 + }; 7 + use http::{StatusCode, header}; 8 9 use crate::http::{context::WebContext, errors::WebError}; 10 11 /// GET /content/{cid} - Handle content requests. 12 /// Gets content from content storage and returns it as a response. 13 + pub(crate) async fn handle_content( 14 State(web_context): State<WebContext>, 15 Path(cid): Path<String>, 16 ) -> Result<impl IntoResponse, WebError> { ··· 38 .body(Body::from(content_data)) 39 .unwrap() 40 .into_response()) 41 + }
+128
src/http/handle_settings.rs
··· 36 email: Option<String>, 37 } 38 39 pub(crate) async fn handle_settings( 40 State(web_context): State<WebContext>, 41 Language(language): Language, ··· 295 ) 296 .into_response()) 297 }
··· 36 email: Option<String>, 37 } 38 39 + #[derive(Deserialize, Clone, Debug)] 40 + pub(crate) struct DiscoverEventsForm { 41 + discover_events: Option<String>, 42 + } 43 + 44 + #[derive(Deserialize, Clone, Debug)] 45 + pub(crate) struct DiscoverRsvpsForm { 46 + discover_rsvps: Option<String>, 47 + } 48 + 49 pub(crate) async fn handle_settings( 50 State(web_context): State<WebContext>, 51 Language(language): Language, ··· 305 ) 306 .into_response()) 307 } 308 + 309 + #[tracing::instrument(skip_all, err)] 310 + pub(crate) async fn handle_discover_events_update( 311 + State(web_context): State<WebContext>, 312 + Language(language): Language, 313 + Cached(auth): Cached<Auth>, 314 + Form(discover_form): Form<DiscoverEventsForm>, 315 + ) -> Result<impl IntoResponse, WebError> { 316 + let current_handle = auth.require_flat()?; 317 + 318 + let default_context = template_context! { 319 + current_handle => current_handle.clone(), 320 + language => language.to_string(), 321 + }; 322 + 323 + let error_template = select_template!(false, true, language); 324 + let render_template = format!( 325 + "{}/settings.discover_events.html", 326 + language.to_string().to_lowercase() 327 + ); 328 + 329 + // Parse the boolean value from the form 330 + let discover_events = discover_form 331 + .discover_events 332 + .as_ref() 333 + .map(|s| s == "true") 334 + .unwrap_or(false); 335 + 336 + if let Err(err) = handle_update_field( 337 + &web_context.pool, 338 + &current_handle.did, 339 + HandleField::DiscoverEvents(discover_events), 340 + ) 341 + .await 342 + { 343 + return contextual_error!(web_context, language, error_template, default_context, err); 344 + } 345 + 346 + let current_handle = match handle_for_did(&web_context.pool, &current_handle.did).await { 347 + Ok(value) => value, 348 + Err(err) => { 349 + return contextual_error!(web_context, language, error_template, default_context, err); 350 + } 351 + }; 352 + 353 + Ok(( 354 + StatusCode::OK, 355 + RenderHtml( 356 + &render_template, 357 + web_context.engine.clone(), 358 + template_context! { 359 + current_handle, 360 + discover_events_updated => true, 361 + ..default_context 362 + }, 363 + ), 364 + ) 365 + .into_response()) 366 + } 367 + 368 + #[tracing::instrument(skip_all, err)] 369 + pub(crate) async fn handle_discover_rsvps_update( 370 + State(web_context): State<WebContext>, 371 + Language(language): Language, 372 + Cached(auth): Cached<Auth>, 373 + Form(discover_form): Form<DiscoverRsvpsForm>, 374 + ) -> Result<impl IntoResponse, WebError> { 375 + let current_handle = auth.require_flat()?; 376 + 377 + let default_context = template_context! { 378 + current_handle => current_handle.clone(), 379 + language => language.to_string(), 380 + }; 381 + 382 + let error_template = select_template!(false, true, language); 383 + let render_template = format!( 384 + "{}/settings.discover_rsvps.html", 385 + language.to_string().to_lowercase() 386 + ); 387 + 388 + // Parse the boolean value from the form 389 + let discover_rsvps = discover_form 390 + .discover_rsvps 391 + .as_ref() 392 + .map(|s| s == "true") 393 + .unwrap_or(false); 394 + 395 + if let Err(err) = handle_update_field( 396 + &web_context.pool, 397 + &current_handle.did, 398 + HandleField::DiscoverRsvps(discover_rsvps), 399 + ) 400 + .await 401 + { 402 + return contextual_error!(web_context, language, error_template, default_context, err); 403 + } 404 + 405 + let current_handle = match handle_for_did(&web_context.pool, &current_handle.did).await { 406 + Ok(value) => value, 407 + Err(err) => { 408 + return contextual_error!(web_context, language, error_template, default_context, err); 409 + } 410 + }; 411 + 412 + Ok(( 413 + StatusCode::OK, 414 + RenderHtml( 415 + &render_template, 416 + web_context.engine.clone(), 417 + template_context! { 418 + current_handle, 419 + discover_rsvps_updated => true, 420 + ..default_context 421 + }, 422 + ), 423 + ) 424 + .into_response()) 425 + }
+6 -2
src/http/handle_view_event.rs
··· 152 let standard_aturi = format!("at://{}/{}/{}", profile.did, NSID, event_rkey); 153 154 // Try to fetch the standard event 155 - standard_event_exists = event_get(&ctx.web_context.pool, &standard_aturi).await.is_ok(); 156 // Legacy events are never migrated 157 has_been_migrated = false; 158 } else { ··· 164 "at://{}/{}/{}", 165 profile.did, SMOKESIGNAL_EVENT_NSID, event_rkey 166 ); 167 - has_been_migrated = event_get(&ctx.web_context.pool, &legacy_aturi).await.is_ok(); 168 }; 169 170 // Try to get the event from the requested collection
··· 152 let standard_aturi = format!("at://{}/{}/{}", profile.did, NSID, event_rkey); 153 154 // Try to fetch the standard event 155 + standard_event_exists = event_get(&ctx.web_context.pool, &standard_aturi) 156 + .await 157 + .is_ok(); 158 // Legacy events are never migrated 159 has_been_migrated = false; 160 } else { ··· 166 "at://{}/{}/{}", 167 profile.did, SMOKESIGNAL_EVENT_NSID, event_rkey 168 ); 169 + has_been_migrated = event_get(&ctx.web_context.pool, &legacy_aturi) 170 + .await 171 + .is_ok(); 172 }; 173 174 // Try to get the event from the requested collection
+1 -1
src/http/mod.rs
··· 13 pub mod handle_admin_index; 14 pub mod handle_admin_rsvp; 15 pub mod handle_admin_rsvps; 16 pub mod handle_create_event; 17 pub mod handle_create_rsvp; 18 pub mod handle_delete_event; ··· 44 pub mod templates; 45 pub mod timezones; 46 pub mod utils; 47 - pub mod handle_content;
··· 13 pub mod handle_admin_index; 14 pub mod handle_admin_rsvp; 15 pub mod handle_admin_rsvps; 16 + pub mod handle_content; 17 pub mod handle_create_event; 18 pub mod handle_create_rsvp; 19 pub mod handle_delete_event; ··· 45 pub mod templates; 46 pub mod timezones; 47 pub mod utils;
+40 -6
src/http/server.rs
··· 16 use tracing::Span; 17 18 use crate::http::{ 19 - context::WebContext, handle_admin_denylist::{ 20 handle_admin_denylist, handle_admin_denylist_add, handle_admin_denylist_remove, 21 - }, handle_admin_event::handle_admin_event, handle_admin_events::handle_admin_events, handle_admin_handles::{handle_admin_handles, handle_admin_nuke_identity}, handle_admin_import_event::handle_admin_import_event, handle_admin_import_rsvp::handle_admin_import_rsvp, handle_admin_index::handle_admin_index, handle_admin_rsvp::handle_admin_rsvp, handle_admin_rsvps::handle_admin_rsvps, handle_content::handle_content, handle_create_event::{ 22 handle_create_event, handle_link_at_builder, handle_location_at_builder, 23 handle_location_datalist, handle_starts_at_builder, 24 - }, handle_create_rsvp::handle_create_rsvp, handle_delete_event::handle_delete_event, handle_edit_event::handle_edit_event, handle_export_rsvps::handle_export_rsvps, handle_import::{handle_import, handle_import_submit}, handle_index::handle_index, handle_migrate_event::handle_migrate_event, handle_migrate_rsvp::handle_migrate_rsvp, handle_oauth_logout::handle_logout, handle_policy::{ 25 handle_acknowledgement, handle_cookie_policy, handle_privacy_policy, 26 handle_terms_of_service, 27 - }, handle_profile::handle_profile_view, handle_set_language::handle_set_language, handle_settings::{ 28 - handle_email_update, handle_language_update, handle_settings, handle_timezone_update, 29 - }, handle_view_event::handle_view_event 30 }; 31 use crate::{config::OAuthBackendConfig, http::handle_view_event::handle_event_ics}; 32 ··· 89 .route("/settings/timezone", post(handle_timezone_update)) 90 .route("/settings/language", post(handle_language_update)) 91 .route("/settings/email", post(handle_email_update)) 92 .route("/import", get(handle_import)) 93 .route("/import", post(handle_import_submit)) 94 .route("/event", get(handle_create_event))
··· 16 use tracing::Span; 17 18 use crate::http::{ 19 + context::WebContext, 20 + handle_admin_denylist::{ 21 handle_admin_denylist, handle_admin_denylist_add, handle_admin_denylist_remove, 22 + }, 23 + handle_admin_event::handle_admin_event, 24 + handle_admin_events::handle_admin_events, 25 + handle_admin_handles::{handle_admin_handles, handle_admin_nuke_identity}, 26 + handle_admin_import_event::handle_admin_import_event, 27 + handle_admin_import_rsvp::handle_admin_import_rsvp, 28 + handle_admin_index::handle_admin_index, 29 + handle_admin_rsvp::handle_admin_rsvp, 30 + handle_admin_rsvps::handle_admin_rsvps, 31 + handle_content::handle_content, 32 + handle_create_event::{ 33 handle_create_event, handle_link_at_builder, handle_location_at_builder, 34 handle_location_datalist, handle_starts_at_builder, 35 + }, 36 + handle_create_rsvp::handle_create_rsvp, 37 + handle_delete_event::handle_delete_event, 38 + handle_edit_event::handle_edit_event, 39 + handle_export_rsvps::handle_export_rsvps, 40 + handle_import::{handle_import, handle_import_submit}, 41 + handle_index::handle_index, 42 + handle_migrate_event::handle_migrate_event, 43 + handle_migrate_rsvp::handle_migrate_rsvp, 44 + handle_oauth_logout::handle_logout, 45 + handle_policy::{ 46 handle_acknowledgement, handle_cookie_policy, handle_privacy_policy, 47 handle_terms_of_service, 48 + }, 49 + handle_profile::handle_profile_view, 50 + handle_set_language::handle_set_language, 51 + handle_settings::{ 52 + handle_discover_events_update, handle_discover_rsvps_update, handle_email_update, 53 + handle_language_update, handle_settings, handle_timezone_update, 54 + }, 55 + handle_view_event::handle_view_event, 56 }; 57 use crate::{config::OAuthBackendConfig, http::handle_view_event::handle_event_ics}; 58 ··· 115 .route("/settings/timezone", post(handle_timezone_update)) 116 .route("/settings/language", post(handle_language_update)) 117 .route("/settings/email", post(handle_email_update)) 118 + .route( 119 + "/settings/discover_events", 120 + post(handle_discover_events_update), 121 + ) 122 + .route( 123 + "/settings/discover_rsvps", 124 + post(handle_discover_rsvps_update), 125 + ) 126 .route("/import", get(handle_import)) 127 .route("/import", post(handle_import_submit)) 128 .route("/event", get(handle_create_event))
+137 -14
src/processor.rs
··· 9 10 use crate::atproto::lexicon::community::lexicon::calendar::event::Event; 11 use crate::atproto::lexicon::community::lexicon::calendar::event::Media; 12 - use crate::atproto::lexicon::community::lexicon::calendar::event::NSID; 13 use crate::consumer::SmokeSignalEvent; 14 use crate::consumer::SmokeSignalEventReceiver; 15 use crate::storage::StoragePool; 16 use crate::storage::content::ContentStorage; 17 use crate::storage::event::event_delete; 18 use crate::storage::event::event_insert_with_metadata; 19 20 pub struct ContentFetcher { 21 pool: StoragePool, ··· 65 match &event { 66 SmokeSignalEvent::Commit { 67 did, 68 cid, 69 record, 70 rkey, 71 .. 72 } => { 73 - if let Err(e) = self.handle_commit(did, rkey, cid, record).await { 74 tracing::error!(error = ?e, "error handling commit"); 75 } 76 } 77 - SmokeSignalEvent::Delete { did, rkey, .. } => { 78 - if let Err(e) = self.handle_delete(did, rkey).await { 79 tracing::error!(error = ?e, "error handling delete"); 80 } 81 } ··· 86 Ok(()) 87 } 88 89 - async fn handle_commit(&self, did: &str, rkey: &str, cid: &str, record: &Value) -> Result<()> { 90 - tracing::info!("Processing award: {} for {}", rkey, did); 91 92 - let aturi = format!("at://{did}/{NSID}/{rkey}"); 93 94 let event_record: Event = serde_json::from_value(record.clone())?; 95 ··· 103 Event::Current { name, .. } => name.clone(), 104 }; 105 106 - event_insert_with_metadata(&self.pool, &aturi, cid, did, NSID, &event_record, &name) 107 - .await?; 108 109 let all_media = match event_record { 110 Event::Current { media, .. } => media, ··· 119 Ok(()) 120 } 121 122 - async fn handle_delete(&self, did: &str, rkey: &str) -> Result<()> { 123 - let aturi = format!("at://{did}/{NSID}/{rkey}"); 124 125 event_delete(&self.pool, &aturi).await?; 126 127 Ok(()) 128 } 129 130 async fn download_media(&self, pds: &str, did: &str, event_image: &Media) -> Result<()> { 131 tracing::info!(?event_image, "event_image"); 132 ··· 140 }; 141 142 if role != "header" { 143 - return Ok(()) 144 } 145 146 match content.mime_type.as_str() { 147 - "image/png" | "image/jpeg" | "image/webp" => {} 148 - _ => return Ok(()) 149 } 150 151 let (reported_height, reported_width) = aspect_ratio
··· 9 10 use crate::atproto::lexicon::community::lexicon::calendar::event::Event; 11 use crate::atproto::lexicon::community::lexicon::calendar::event::Media; 12 + use crate::atproto::lexicon::community::lexicon::calendar::event::NSID as LexiconCommunityEventNSID; 13 + use crate::atproto::lexicon::community::lexicon::calendar::rsvp::NSID as LexiconCommunityRSVPNSID; 14 + use crate::atproto::lexicon::community::lexicon::calendar::rsvp::Rsvp; 15 + use crate::atproto::lexicon::community::lexicon::calendar::rsvp::RsvpStatus; 16 use crate::consumer::SmokeSignalEvent; 17 use crate::consumer::SmokeSignalEventReceiver; 18 use crate::storage::StoragePool; 19 use crate::storage::content::ContentStorage; 20 + use crate::storage::event::RsvpInsertParams; 21 use crate::storage::event::event_delete; 22 + use crate::storage::event::event_exists; 23 use crate::storage::event::event_insert_with_metadata; 24 + use crate::storage::event::rsvp_delete; 25 + use crate::storage::event::rsvp_insert_with_metadata; 26 + use crate::storage::identity_profile::identity_profile_allow_discover_events; 27 + use crate::storage::identity_profile::identity_profile_allow_discover_rsvps; 28 29 pub struct ContentFetcher { 30 pool: StoragePool, ··· 74 match &event { 75 SmokeSignalEvent::Commit { 76 did, 77 + collection, 78 cid, 79 record, 80 rkey, 81 .. 82 } => { 83 + let result = match collection.as_str() { 84 + "community.lexicon.calendar.event" => { 85 + self.handle_event_commit(did, rkey, cid, record).await 86 + } 87 + "community.lexicon.calendar.rsvp" => { 88 + self.handle_rsvp_commit(did, rkey, cid, record).await 89 + } 90 + _ => Ok(()), 91 + }; 92 + if let Err(e) = result { 93 tracing::error!(error = ?e, "error handling commit"); 94 } 95 } 96 + SmokeSignalEvent::Delete { 97 + did, 98 + collection, 99 + rkey, 100 + .. 101 + } => { 102 + let result = match collection.as_str() { 103 + "community.lexicon.calendar.event" => { 104 + self.handle_event_delete(did, rkey).await 105 + } 106 + "community.lexicon.calendar.rsvp" => { 107 + self.handle_rsvp_delete(did, rkey).await 108 + } 109 + _ => Ok(()), 110 + }; 111 + if let Err(e) = result { 112 tracing::error!(error = ?e, "error handling delete"); 113 } 114 } ··· 119 Ok(()) 120 } 121 122 + async fn handle_event_commit( 123 + &self, 124 + did: &str, 125 + rkey: &str, 126 + cid: &str, 127 + record: &Value, 128 + ) -> Result<()> { 129 + tracing::info!("Processing event: {} for {}", rkey, did); 130 131 + // Check if the user allows event discovery 132 + let allow_discover = identity_profile_allow_discover_events(&self.pool, did).await?; 133 + // Default to false if the profile doesn't exist yet 134 + if !allow_discover.unwrap_or(false) { 135 + tracing::info!("User {} has opted out of event discovery", did); 136 + return Ok(()); 137 + } 138 + 139 + let aturi = format!("at://{did}/{LexiconCommunityEventNSID}/{rkey}"); 140 141 let event_record: Event = serde_json::from_value(record.clone())?; 142 ··· 150 Event::Current { name, .. } => name.clone(), 151 }; 152 153 + event_insert_with_metadata( 154 + &self.pool, 155 + &aturi, 156 + cid, 157 + did, 158 + LexiconCommunityEventNSID, 159 + &event_record, 160 + &name, 161 + ) 162 + .await?; 163 164 let all_media = match event_record { 165 Event::Current { media, .. } => media, ··· 174 Ok(()) 175 } 176 177 + async fn handle_rsvp_commit( 178 + &self, 179 + did: &str, 180 + rkey: &str, 181 + cid: &str, 182 + record: &Value, 183 + ) -> Result<()> { 184 + tracing::info!("Processing rsvp: {} for {}", rkey, did); 185 + 186 + let allow_discover = identity_profile_allow_discover_rsvps(&self.pool, did).await?; 187 + if !allow_discover.unwrap_or(true) { 188 + tracing::info!("User {} has opted out of event discovery", did); 189 + return Ok(()); 190 + } 191 + 192 + let aturi = format!("at://{did}/{LexiconCommunityRSVPNSID}/{rkey}"); 193 + 194 + let rsvp_record: Rsvp = serde_json::from_value(record.clone())?; 195 + 196 + let (event_aturi, event_cid, status) = match &rsvp_record { 197 + Rsvp::Current { 198 + subject, status, .. 199 + } => { 200 + let event_aturi = subject.uri.clone(); 201 + let event_cid = subject.cid.clone(); 202 + let status = match status { 203 + RsvpStatus::Going => "going", 204 + RsvpStatus::Interested => "interested", 205 + RsvpStatus::NotGoing => "notgoing", 206 + }; 207 + (event_aturi, event_cid, status) 208 + } 209 + }; 210 + 211 + match event_exists(&self.pool, &event_aturi).await { 212 + Ok(true) => {} 213 + _ => return Ok(()), 214 + }; 215 + 216 + let _ = self.ensure_identity_stored(did).await?; 217 + 218 + rsvp_insert_with_metadata( 219 + &self.pool, 220 + RsvpInsertParams { 221 + aturi: &aturi, 222 + cid, 223 + did, 224 + lexicon: LexiconCommunityRSVPNSID, 225 + record: &rsvp_record, 226 + event_aturi: &event_aturi, 227 + event_cid: &event_cid, 228 + status, 229 + email_shared: false, 230 + }, 231 + ) 232 + .await?; 233 + 234 + Ok(()) 235 + } 236 + 237 + async fn handle_event_delete(&self, did: &str, rkey: &str) -> Result<()> { 238 + let aturi = format!("at://{did}/{LexiconCommunityEventNSID}/{rkey}"); 239 240 event_delete(&self.pool, &aturi).await?; 241 242 Ok(()) 243 } 244 245 + async fn handle_rsvp_delete(&self, did: &str, rkey: &str) -> Result<()> { 246 + let aturi = format!("at://{did}/{LexiconCommunityEventNSID}/{rkey}"); 247 + 248 + rsvp_delete(&self.pool, &aturi).await?; 249 + 250 + Ok(()) 251 + } 252 + 253 async fn download_media(&self, pds: &str, did: &str, event_image: &Media) -> Result<()> { 254 tracing::info!(?event_image, "event_image"); 255 ··· 263 }; 264 265 if role != "header" { 266 + return Ok(()); 267 } 268 269 match content.mime_type.as_str() { 270 + "image/png" | "image/jpeg" | "image/webp" => {} 271 + _ => return Ok(()), 272 } 273 274 let (reported_height, reported_width) = aspect_ratio
+19 -8
src/storage/event.rs
··· 961 .await 962 .map_err(StorageError::UnableToExecuteQuery)?; 963 964 - // if result.rows_affected() == 0 { 965 - // // Rollback the transaction - we don't need to map the error 966 - // let _ = tx.rollback().await; 967 - // return Err(StorageError::RowNotFound( 968 - // "Event not found".to_string(), 969 - // sqlx::Error::RowNotFound, 970 - // )); 971 - // } 972 973 tx.commit() 974 .await
··· 961 .await 962 .map_err(StorageError::UnableToExecuteQuery)?; 963 964 + tx.commit() 965 + .await 966 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 967 + 968 + Ok(()) 969 + } 970 + 971 + pub async fn rsvp_delete(pool: &StoragePool, aturi: &str) -> Result<(), StorageError> { 972 + let mut tx = pool 973 + .begin() 974 + .await 975 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 976 + 977 + // Delete only the event record (RSVPs are preserved) 978 + sqlx::query("DELETE FROM rsvps WHERE aturi = $1") 979 + .bind(aturi) 980 + .execute(tx.as_mut()) 981 + .await 982 + .map_err(StorageError::UnableToExecuteQuery)?; 983 984 tx.commit() 985 .await
+78
src/storage/identity_profile.rs
··· 23 pub language: String, 24 pub tz: String, 25 pub email: Option<String>, 26 27 pub created_at: DateTime<Utc>, 28 pub updated_at: DateTime<Utc>, ··· 107 Language(Cow<'static, str>), 108 Timezone(Cow<'static, str>), 109 ActiveNow, 110 } 111 112 pub async fn handle_update_field( ··· 130 } 131 HandleField::ActiveNow => { 132 "UPDATE identity_profiles SET active_at = $1, updated_at = $2 WHERE did = $3" 133 } 134 }; 135 ··· 145 HandleField::ActiveNow => { 146 query_builder = query_builder.bind(now); 147 } 148 } 149 150 query_builder ··· 189 tx.commit() 190 .await 191 .map_err(StorageError::CannotCommitDatabaseTransaction) 192 } 193 194 pub async fn handle_for_did(
··· 23 pub language: String, 24 pub tz: String, 25 pub email: Option<String>, 26 + pub discover_events: bool, 27 + pub discover_rsvps: bool, 28 29 pub created_at: DateTime<Utc>, 30 pub updated_at: DateTime<Utc>, ··· 109 Language(Cow<'static, str>), 110 Timezone(Cow<'static, str>), 111 ActiveNow, 112 + DiscoverEvents(bool), 113 + DiscoverRsvps(bool), 114 } 115 116 pub async fn handle_update_field( ··· 134 } 135 HandleField::ActiveNow => { 136 "UPDATE identity_profiles SET active_at = $1, updated_at = $2 WHERE did = $3" 137 + } 138 + HandleField::DiscoverEvents(_) => { 139 + "UPDATE identity_profiles SET discover_events = $1, updated_at = $2 WHERE did = $3" 140 + } 141 + HandleField::DiscoverRsvps(_) => { 142 + "UPDATE identity_profiles SET discover_rsvps = $1, updated_at = $2 WHERE did = $3" 143 } 144 }; 145 ··· 155 HandleField::ActiveNow => { 156 query_builder = query_builder.bind(now); 157 } 158 + HandleField::DiscoverEvents(value) => { 159 + query_builder = query_builder.bind(value); 160 + } 161 + HandleField::DiscoverRsvps(value) => { 162 + query_builder = query_builder.bind(value); 163 + } 164 } 165 166 query_builder ··· 205 tx.commit() 206 .await 207 .map_err(StorageError::CannotCommitDatabaseTransaction) 208 + } 209 + 210 + pub async fn identity_profile_allow_discover_events( 211 + pool: &StoragePool, 212 + did: &str, 213 + ) -> Result<Option<bool>, StorageError> { 214 + // Validate DID is not empty 215 + if did.trim().is_empty() { 216 + return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 217 + "DID cannot be empty".into(), 218 + ))); 219 + } 220 + 221 + let mut tx = pool 222 + .begin() 223 + .await 224 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 225 + 226 + let discover_events = sqlx::query_scalar::<_, bool>( 227 + "SELECT discover_events FROM identity_profiles WHERE did = $1", 228 + ) 229 + .bind(did) 230 + .fetch_optional(tx.as_mut()) 231 + .await 232 + .map_err(StorageError::UnableToExecuteQuery)?; 233 + 234 + tx.commit() 235 + .await 236 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 237 + 238 + Ok(discover_events) 239 + } 240 + 241 + pub async fn identity_profile_allow_discover_rsvps( 242 + pool: &StoragePool, 243 + did: &str, 244 + ) -> Result<Option<bool>, StorageError> { 245 + // Validate DID is not empty 246 + if did.trim().is_empty() { 247 + return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 248 + "DID cannot be empty".into(), 249 + ))); 250 + } 251 + 252 + let mut tx = pool 253 + .begin() 254 + .await 255 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 256 + 257 + let discover_rsvps = sqlx::query_scalar::<_, bool>( 258 + "SELECT discover_rsvps FROM identity_profiles WHERE did = $1", 259 + ) 260 + .bind(did) 261 + .fetch_optional(tx.as_mut()) 262 + .await 263 + .map_err(StorageError::UnableToExecuteQuery)?; 264 + 265 + tx.commit() 266 + .await 267 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 268 + 269 + Ok(discover_rsvps) 270 } 271 272 pub async fn handle_for_did(
+14
templates/en-us/settings.common.html
··· 44 </div> 45 </div> 46 </div> 47 </div> 48 </div> 49 </div>
··· 44 </div> 45 </div> 46 </div> 47 + 48 + <hr> 49 + 50 + <div class="columns"> 51 + <div class="column"> 52 + <h2 class="subtitle">Discovery Settings</h2> 53 + <div id="discover-events-form" class="mb-4"> 54 + {% include "en-us/settings.discover_events.html" %} 55 + </div> 56 + <div id="discover-rsvps-form"> 57 + {% include "en-us/settings.discover_rsvps.html" %} 58 + </div> 59 + </div> 60 + </div> 61 </div> 62 </div> 63 </div>
+23
templates/en-us/settings.discover_events.html
···
··· 1 + <div class="field"> 2 + <label class="label">Event Discovery</label> 3 + <div class="control"> 4 + <form hx-post="/settings/discover_events" hx-target="#discover-events-form" hx-swap="innerHTML"> 5 + <label class="radio"> 6 + <input type="radio" name="discover_events" value="true" {% if current_handle.discover_events %}checked{% endif %} 7 + hx-post="/settings/discover_events" hx-target="#discover-events-form" hx-swap="innerHTML" 8 + data-loading-disable data-loading-aria-busy> 9 + Allow my events from outside of Smoke Signal to be displayed in Smoke Signal 10 + </label> 11 + <br> 12 + <label class="radio"> 13 + <input type="radio" name="discover_events" value="false" {% if not current_handle.discover_events %}checked{% endif %} 14 + hx-post="/settings/discover_events" hx-target="#discover-events-form" hx-swap="innerHTML" 15 + data-loading-disable data-loading-aria-busy> 16 + Disallow my events from outside of Smoke Signal to be displayed in Smoke Signal 17 + </label> 18 + </form> 19 + </div> 20 + {% if discover_events_updated %} 21 + <p class="help is-success mt-2">Event discovery preference updated successfully.</p> 22 + {% endif %} 23 + </div>
+23
templates/en-us/settings.discover_rsvps.html
···
··· 1 + <div class="field"> 2 + <label class="label">RSVP Discovery</label> 3 + <div class="control"> 4 + <form hx-post="/settings/discover_rsvps" hx-target="#discover-rsvps-form" hx-swap="innerHTML"> 5 + <label class="radio"> 6 + <input type="radio" name="discover_rsvps" value="true" {% if current_handle.discover_rsvps %}checked{% endif %} 7 + hx-post="/settings/discover_rsvps" hx-target="#discover-rsvps-form" hx-swap="innerHTML" 8 + data-loading-disable data-loading-aria-busy> 9 + Allow my RSVPs from outside of Smoke Signal to be displayed in Smoke Signal 10 + </label> 11 + <br> 12 + <label class="radio"> 13 + <input type="radio" name="discover_rsvps" value="false" {% if not current_handle.discover_rsvps %}checked{% endif %} 14 + hx-post="/settings/discover_rsvps" hx-target="#discover-rsvps-form" hx-swap="innerHTML" 15 + data-loading-disable data-loading-aria-busy> 16 + Disallow my RSVPs from outside of Smoke Signal to be displayed in Smoke Signal 17 + </label> 18 + </form> 19 + </div> 20 + {% if discover_rsvps_updated %} 21 + <p class="help is-success mt-2">RSVP discovery preference updated successfully.</p> 22 + {% endif %} 23 + </div>