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 169 #[serde(skip_serializing_if = "Vec::is_empty", default)] 170 170 uris: EventLinks, 171 171 172 - #[serde( 173 - skip_serializing_if = "Vec::is_empty", 174 - default 175 - )] 172 + #[serde(skip_serializing_if = "Vec::is_empty", default)] 176 173 media: MediaList, 177 174 178 175 // This is a catch-all for any elements that are not implemented by
+4
src/consumer.rs
··· 12 12 pub enum SmokeSignalEvent { 13 13 Commit { 14 14 did: String, 15 + collection: String, 15 16 rkey: String, 16 17 cid: String, 17 18 record: serde_json::Value, 18 19 }, 19 20 Delete { 20 21 did: String, 22 + collection: String, 21 23 rkey: String, 22 24 }, 23 25 } ··· 43 45 44 46 SmokeSignalEvent::Commit { 45 47 did, 48 + collection: commit.collection, 46 49 rkey: commit.rkey, 47 50 cid: commit.cid, 48 51 record: commit.record, ··· 54 57 } 55 58 SmokeSignalEvent::Delete { 56 59 did, 60 + collection: commit.collection, 57 61 rkey: commit.rkey, 58 62 } 59 63 }
+7 -3
src/http/event_view.rs
··· 284 284 } 285 285 } 286 286 287 - use crate::atproto::lexicon::community::lexicon::calendar::event::{MediaList, Media}; 287 + use crate::atproto::lexicon::community::lexicon::calendar::event::{Media, MediaList}; 288 288 289 289 fn find_header(event: &Event) -> Option<(String, String)> { 290 290 let all_media = if let Some(value) = event ··· 308 308 alt, 309 309 role, 310 310 aspect_ratio, 311 - } if role == "header" && aspect_ratio.is_some() => (content, aspect_ratio.clone().unwrap(), alt.clone()), 311 + } if role == "header" && aspect_ratio.is_some() => { 312 + (content, aspect_ratio.clone().unwrap(), alt.clone()) 313 + } 312 314 _ => continue, 313 315 }; 314 316 315 317 let (reported_height, reported_width) = (aspect_ratio.height, aspect_ratio.width); 316 - if !(755..=12000).contains(&reported_height) || !(reported_height..=12000).contains(&reported_width) { 318 + if !(755..=12000).contains(&reported_height) 319 + || !(reported_height..=12000).contains(&reported_width) 320 + { 317 321 continue; 318 322 } 319 323 let is_16_9 =
+8 -4
src/http/handle_content.rs
··· 1 - use axum::{body::Body, extract::{Path, State}, response::{IntoResponse, Response}}; 2 1 use anyhow::Result; 3 - use http::{header, StatusCode}; 2 + use axum::{ 3 + body::Body, 4 + extract::{Path, State}, 5 + response::{IntoResponse, Response}, 6 + }; 7 + use http::{StatusCode, header}; 4 8 5 9 use crate::http::{context::WebContext, errors::WebError}; 6 10 7 11 /// GET /content/{cid} - Handle content requests. 8 12 /// Gets content from content storage and returns it as a response. 9 - pub (crate) async fn handle_content( 13 + pub(crate) async fn handle_content( 10 14 State(web_context): State<WebContext>, 11 15 Path(cid): Path<String>, 12 16 ) -> Result<impl IntoResponse, WebError> { ··· 34 38 .body(Body::from(content_data)) 35 39 .unwrap() 36 40 .into_response()) 37 - } 41 + }
+128
src/http/handle_settings.rs
··· 36 36 email: Option<String>, 37 37 } 38 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 + 39 49 pub(crate) async fn handle_settings( 40 50 State(web_context): State<WebContext>, 41 51 Language(language): Language, ··· 295 305 ) 296 306 .into_response()) 297 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 152 let standard_aturi = format!("at://{}/{}/{}", profile.did, NSID, event_rkey); 153 153 154 154 // Try to fetch the standard event 155 - standard_event_exists = event_get(&ctx.web_context.pool, &standard_aturi).await.is_ok(); 155 + standard_event_exists = event_get(&ctx.web_context.pool, &standard_aturi) 156 + .await 157 + .is_ok(); 156 158 // Legacy events are never migrated 157 159 has_been_migrated = false; 158 160 } else { ··· 164 166 "at://{}/{}/{}", 165 167 profile.did, SMOKESIGNAL_EVENT_NSID, event_rkey 166 168 ); 167 - has_been_migrated = event_get(&ctx.web_context.pool, &legacy_aturi).await.is_ok(); 169 + has_been_migrated = event_get(&ctx.web_context.pool, &legacy_aturi) 170 + .await 171 + .is_ok(); 168 172 }; 169 173 170 174 // Try to get the event from the requested collection
+1 -1
src/http/mod.rs
··· 13 13 pub mod handle_admin_index; 14 14 pub mod handle_admin_rsvp; 15 15 pub mod handle_admin_rsvps; 16 + pub mod handle_content; 16 17 pub mod handle_create_event; 17 18 pub mod handle_create_rsvp; 18 19 pub mod handle_delete_event; ··· 44 45 pub mod templates; 45 46 pub mod timezones; 46 47 pub mod utils; 47 - pub mod handle_content;
+40 -6
src/http/server.rs
··· 16 16 use tracing::Span; 17 17 18 18 use crate::http::{ 19 - context::WebContext, handle_admin_denylist::{ 19 + context::WebContext, 20 + handle_admin_denylist::{ 20 21 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 + }, 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::{ 22 33 handle_create_event, handle_link_at_builder, handle_location_at_builder, 23 34 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::{ 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::{ 25 46 handle_acknowledgement, handle_cookie_policy, handle_privacy_policy, 26 47 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 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, 30 56 }; 31 57 use crate::{config::OAuthBackendConfig, http::handle_view_event::handle_event_ics}; 32 58 ··· 89 115 .route("/settings/timezone", post(handle_timezone_update)) 90 116 .route("/settings/language", post(handle_language_update)) 91 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 + ) 92 126 .route("/import", get(handle_import)) 93 127 .route("/import", post(handle_import_submit)) 94 128 .route("/event", get(handle_create_event))
+137 -14
src/processor.rs
··· 9 9 10 10 use crate::atproto::lexicon::community::lexicon::calendar::event::Event; 11 11 use crate::atproto::lexicon::community::lexicon::calendar::event::Media; 12 - use crate::atproto::lexicon::community::lexicon::calendar::event::NSID; 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; 13 16 use crate::consumer::SmokeSignalEvent; 14 17 use crate::consumer::SmokeSignalEventReceiver; 15 18 use crate::storage::StoragePool; 16 19 use crate::storage::content::ContentStorage; 20 + use crate::storage::event::RsvpInsertParams; 17 21 use crate::storage::event::event_delete; 22 + use crate::storage::event::event_exists; 18 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; 19 28 20 29 pub struct ContentFetcher { 21 30 pool: StoragePool, ··· 65 74 match &event { 66 75 SmokeSignalEvent::Commit { 67 76 did, 77 + collection, 68 78 cid, 69 79 record, 70 80 rkey, 71 81 .. 72 82 } => { 73 - if let Err(e) = self.handle_commit(did, rkey, cid, record).await { 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 { 74 93 tracing::error!(error = ?e, "error handling commit"); 75 94 } 76 95 } 77 - SmokeSignalEvent::Delete { did, rkey, .. } => { 78 - if let Err(e) = self.handle_delete(did, rkey).await { 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 { 79 112 tracing::error!(error = ?e, "error handling delete"); 80 113 } 81 114 } ··· 86 119 Ok(()) 87 120 } 88 121 89 - async fn handle_commit(&self, did: &str, rkey: &str, cid: &str, record: &Value) -> Result<()> { 90 - tracing::info!("Processing award: {} for {}", rkey, did); 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); 91 130 92 - let aturi = format!("at://{did}/{NSID}/{rkey}"); 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}"); 93 140 94 141 let event_record: Event = serde_json::from_value(record.clone())?; 95 142 ··· 103 150 Event::Current { name, .. } => name.clone(), 104 151 }; 105 152 106 - event_insert_with_metadata(&self.pool, &aturi, cid, did, NSID, &event_record, &name) 107 - .await?; 153 + event_insert_with_metadata( 154 + &self.pool, 155 + &aturi, 156 + cid, 157 + did, 158 + LexiconCommunityEventNSID, 159 + &event_record, 160 + &name, 161 + ) 162 + .await?; 108 163 109 164 let all_media = match event_record { 110 165 Event::Current { media, .. } => media, ··· 119 174 Ok(()) 120 175 } 121 176 122 - async fn handle_delete(&self, did: &str, rkey: &str) -> Result<()> { 123 - let aturi = format!("at://{did}/{NSID}/{rkey}"); 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}"); 124 239 125 240 event_delete(&self.pool, &aturi).await?; 126 241 127 242 Ok(()) 128 243 } 129 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 + 130 253 async fn download_media(&self, pds: &str, did: &str, event_image: &Media) -> Result<()> { 131 254 tracing::info!(?event_image, "event_image"); 132 255 ··· 140 263 }; 141 264 142 265 if role != "header" { 143 - return Ok(()) 266 + return Ok(()); 144 267 } 145 268 146 269 match content.mime_type.as_str() { 147 - "image/png" | "image/jpeg" | "image/webp" => {} 148 - _ => return Ok(()) 270 + "image/png" | "image/jpeg" | "image/webp" => {} 271 + _ => return Ok(()), 149 272 } 150 273 151 274 let (reported_height, reported_width) = aspect_ratio
+19 -8
src/storage/event.rs
··· 961 961 .await 962 962 .map_err(StorageError::UnableToExecuteQuery)?; 963 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 - // } 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)?; 972 983 973 984 tx.commit() 974 985 .await
+78
src/storage/identity_profile.rs
··· 23 23 pub language: String, 24 24 pub tz: String, 25 25 pub email: Option<String>, 26 + pub discover_events: bool, 27 + pub discover_rsvps: bool, 26 28 27 29 pub created_at: DateTime<Utc>, 28 30 pub updated_at: DateTime<Utc>, ··· 107 109 Language(Cow<'static, str>), 108 110 Timezone(Cow<'static, str>), 109 111 ActiveNow, 112 + DiscoverEvents(bool), 113 + DiscoverRsvps(bool), 110 114 } 111 115 112 116 pub async fn handle_update_field( ··· 130 134 } 131 135 HandleField::ActiveNow => { 132 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" 133 143 } 134 144 }; 135 145 ··· 145 155 HandleField::ActiveNow => { 146 156 query_builder = query_builder.bind(now); 147 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 + } 148 164 } 149 165 150 166 query_builder ··· 189 205 tx.commit() 190 206 .await 191 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) 192 270 } 193 271 194 272 pub async fn handle_for_did(
+14
templates/en-us/settings.common.html
··· 44 44 </div> 45 45 </div> 46 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> 47 61 </div> 48 62 </div> 49 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>