diff --git a/src/config.rs b/src/config.rs
index aea3f52..5fb7899 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -204,7 +204,7 @@ impl Config {
/// Returns the OAuth scope string with the external_base interpolated
pub fn oauth_scope(&self) -> String {
format!(
- "atproto include:community.lexicon.calendar.authFull?aud=did:web:{}#smokesignal include:events.smokesignal.authFull blob:*/* account:email",
+ "atproto include:community.lexicon.calendar.authFull?aud=did:web:{}#smokesignal include:events.smokesignal.authFull include:app.bsky.authCreatePosts blob:*/* account:email",
self.external_base
)
}
diff --git a/src/http/errors/mod.rs b/src/http/errors/mod.rs
index a542c45..997b9ed 100644
--- a/src/http/errors/mod.rs
+++ b/src/http/errors/mod.rs
@@ -11,6 +11,7 @@ pub mod lfg_error;
pub mod login_error;
pub mod middleware_errors;
pub mod profile_import_error;
+pub mod share_bluesky_error;
pub mod url_error;
pub mod view_event_error;
pub mod web_error;
@@ -24,6 +25,7 @@ pub(crate) use lfg_error::LfgError;
pub(crate) use login_error::LoginError;
pub(crate) use middleware_errors::WebSessionError;
pub(crate) use profile_import_error::ProfileImportError;
+pub(crate) use share_bluesky_error::ShareBlueskyError;
pub(crate) use url_error::UrlError;
pub(crate) use view_event_error::ViewEventError;
pub(crate) use web_error::WebError;
diff --git a/src/http/errors/share_bluesky_error.rs b/src/http/errors/share_bluesky_error.rs
new file mode 100644
index 0000000..9b27f97
--- /dev/null
+++ b/src/http/errors/share_bluesky_error.rs
@@ -0,0 +1,17 @@
+use thiserror::Error;
+
+/// Represents errors that can occur when sharing an RSVP to Bluesky.
+#[derive(Debug, Error)]
+pub(crate) enum ShareBlueskyError {
+ /// Error when the AT Protocol server returns an error response.
+ #[error("error-smokesignal-share-bluesky-1 Failed to create post: {message}")]
+ PostCreationFailed { message: String },
+
+ /// Error when the event is not found.
+ #[error("error-smokesignal-share-bluesky-2 Event not found: {aturi}")]
+ EventNotFound { aturi: String },
+
+ /// Error when the user does not have a "going" RSVP to the event.
+ #[error("error-smokesignal-share-bluesky-3 No going RSVP found for event")]
+ NoGoingRsvp,
+}
diff --git a/src/http/errors/web_error.rs b/src/http/errors/web_error.rs
index b8e95ed..c724c0b 100644
--- a/src/http/errors/web_error.rs
+++ b/src/http/errors/web_error.rs
@@ -20,6 +20,7 @@ use super::import_error::ImportError;
use super::lfg_error::LfgError;
use super::login_error::LoginError;
use super::middleware_errors::MiddlewareAuthError;
+use super::share_bluesky_error::ShareBlueskyError;
use super::url_error::UrlError;
/// Represents all possible errors that can occur in the HTTP layer.
@@ -158,6 +159,13 @@ pub(crate) enum WebError {
/// such as creating, viewing, or deactivating LFG records.
#[error(transparent)]
LfgError(#[from] LfgError),
+
+ /// Share to Bluesky errors.
+ ///
+ /// This error occurs when there are issues with sharing RSVP to Bluesky,
+ /// such as post creation failures or event not found.
+ #[error(transparent)]
+ ShareBlueskyError(#[from] ShareBlueskyError),
}
/// Implementation of Axum's `IntoResponse` trait for WebError.
diff --git a/src/http/handle_create_rsvp.rs b/src/http/handle_create_rsvp.rs
index 681befc..da915f7 100644
--- a/src/http/handle_create_rsvp.rs
+++ b/src/http/handle_create_rsvp.rs
@@ -446,6 +446,9 @@ pub(crate) async fn handle_create_rsvp(
None
};
+ // Get event AT-URI for share button
+ let event_aturi = build_rsvp_form.subject_aturi.clone();
+
return Ok(RenderHtml(
&render_template,
web_context.engine.clone(),
@@ -453,6 +456,8 @@ pub(crate) async fn handle_create_rsvp(
build_rsvp_form,
event_url,
redirect_url,
+ is_going_rsvp,
+ event_aturi,
}},
)
.into_response());
diff --git a/src/http/handle_share_rsvp_bluesky.rs b/src/http/handle_share_rsvp_bluesky.rs
new file mode 100644
index 0000000..bf94881
--- /dev/null
+++ b/src/http/handle_share_rsvp_bluesky.rs
@@ -0,0 +1,165 @@
+use std::collections::HashSet;
+
+use ammonia::Builder;
+use axum::{extract::State, response::IntoResponse};
+use axum_extra::extract::{Cached, Form};
+use axum_htmx::HxRequest;
+use axum_template::RenderHtml;
+use chrono::Utc;
+use minijinja::context as template_context;
+use serde::Deserialize;
+use serde_json::json;
+
+use crate::{
+ atproto::auth::create_dpop_auth_from_session,
+ http::{
+ context::WebContext,
+ errors::{CommonError, ShareBlueskyError, WebError},
+ middleware_auth::Auth,
+ middleware_i18n::Language,
+ utils::{truncate_text, url_from_aturi},
+ },
+ storage::event::{event_get, extract_event_details, rsvp_get_by_event_and_did},
+};
+use atproto_client::com::atproto::repo::{CreateRecordRequest, CreateRecordResponse, create_record};
+
+#[derive(Deserialize)]
+pub(crate) struct ShareRsvpBlueskyRequest {
+ event_aturi: String,
+}
+
+pub(crate) async fn handle_share_rsvp_bluesky(
+ State(web_context): State,
+ Language(language): Language,
+ Cached(auth): Cached,
+ HxRequest(_hx_request): HxRequest,
+ Form(request): Form,
+) -> Result {
+ let current_handle = auth.require("/rsvp")?;
+ let session = auth.session().ok_or(CommonError::NotAuthorized)?;
+
+ let render_template = format!("{}/share_rsvp_bluesky.partial.html", language);
+
+ // Fetch event details
+ let event = event_get(&web_context.pool, &request.event_aturi)
+ .await
+ .map_err(|_| ShareBlueskyError::EventNotFound {
+ aturi: request.event_aturi.clone(),
+ })?;
+
+ // Verify the user has a "going" RSVP to this event
+ let rsvp = rsvp_get_by_event_and_did(&web_context.pool, &request.event_aturi, ¤t_handle.did)
+ .await?
+ .ok_or(ShareBlueskyError::NoGoingRsvp)?;
+
+ if rsvp.status != "going" {
+ return Err(ShareBlueskyError::NoGoingRsvp.into());
+ }
+
+ // Build event URL
+ let event_url = url_from_aturi(&web_context.config.external_base, &request.event_aturi)?;
+
+ // Extract event details for description
+ let details = extract_event_details(&event);
+ let description_short = if details.description.is_empty() {
+ String::new()
+ } else {
+ let truncated = truncate_text(&details.description, 200, Some("...".to_string()));
+ Builder::new()
+ .tags(HashSet::new())
+ .clean(&truncated)
+ .to_string()
+ };
+
+ // Create DPoP auth
+ let dpop_auth = create_dpop_auth_from_session(session)?;
+
+ // Build the post text
+ let post_text = format!("I'm going to \"{}\"!", event.name);
+
+ // Build the external embed
+ let external_embed = json!({
+ "$type": "app.bsky.embed.external",
+ "external": {
+ "uri": event_url,
+ "title": event.name,
+ "description": description_short
+ }
+ });
+
+ // Build the post record
+ let post_record = json!({
+ "$type": "app.bsky.feed.post",
+ "text": post_text,
+ "createdAt": Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
+ "embed": external_embed,
+ "langs": ["en"]
+ });
+
+ // Create the record
+ let create_request = CreateRecordRequest {
+ repo: current_handle.did.clone(),
+ collection: "app.bsky.feed.post".to_string(),
+ validate: true,
+ record_key: None,
+ record: post_record,
+ swap_commit: None,
+ };
+
+ let result = create_record(
+ &web_context.http_client,
+ &atproto_client::client::Auth::DPoP(dpop_auth),
+ ¤t_handle.pds,
+ create_request,
+ )
+ .await;
+
+ match result {
+ Ok(CreateRecordResponse::StrongRef { uri, .. }) => {
+ // Build Bluesky post URL
+ let post_url = build_bluesky_post_url(¤t_handle.handle, &uri);
+
+ Ok(RenderHtml(
+ &render_template,
+ web_context.engine.clone(),
+ template_context! {
+ success => true,
+ post_url,
+ language => language.to_string(),
+ },
+ )
+ .into_response())
+ }
+ Ok(CreateRecordResponse::Error(err)) => Ok(RenderHtml(
+ &render_template,
+ web_context.engine.clone(),
+ template_context! {
+ success => false,
+ error_message => err.error_message(),
+ language => language.to_string(),
+ },
+ )
+ .into_response()),
+ Err(err) => Ok(RenderHtml(
+ &render_template,
+ web_context.engine.clone(),
+ template_context! {
+ success => false,
+ error_message => err.to_string(),
+ language => language.to_string(),
+ },
+ )
+ .into_response()),
+ }
+}
+
+/// Build a Bluesky post URL from handle and AT-URI.
+/// AT-URI format: at://did:plc:xxx/app.bsky.feed.post/rkey
+/// Result: https://bsky.app/profile/{handle}/post/{rkey}
+fn build_bluesky_post_url(handle: &str, aturi: &str) -> String {
+ let rkey = aturi
+ .strip_prefix("at://")
+ .and_then(|s| s.split('/').last())
+ .unwrap_or("");
+ format!("https://bsky.app/profile/{}/post/{}", handle, rkey)
+}
diff --git a/src/http/middleware_auth.rs b/src/http/middleware_auth.rs
index b774ee9..0257b68 100644
--- a/src/http/middleware_auth.rs
+++ b/src/http/middleware_auth.rs
@@ -16,7 +16,7 @@ use super::errors::middleware_errors::MiddlewareAuthError;
/// Cookie name for session storage.
/// Updated version to force re-authentication when cookie format changes.
-pub(crate) const AUTH_COOKIE_NAME: &str = "session20260118";
+pub(crate) const AUTH_COOKIE_NAME: &str = "session20260122";
#[derive(Clone)]
pub(crate) enum Auth {
diff --git a/src/http/mod.rs b/src/http/mod.rs
index c218d29..5d1968b 100644
--- a/src/http/mod.rs
+++ b/src/http/mod.rs
@@ -62,6 +62,7 @@ pub mod handle_quick_event;
pub mod handle_search;
pub mod handle_set_language;
pub mod handle_settings;
+pub mod handle_share_rsvp_bluesky;
pub mod handle_unaccept_rsvp;
pub mod handle_unsubscribe;
pub mod handle_view_event;
diff --git a/src/http/server.rs b/src/http/server.rs
index 248c3ba..29bb739 100644
--- a/src/http/server.rs
+++ b/src/http/server.rs
@@ -107,6 +107,7 @@ use crate::http::{
handle_notification_preferences_update, handle_profile_update, handle_settings,
handle_timezone_update,
},
+ handle_share_rsvp_bluesky::handle_share_rsvp_bluesky,
handle_unaccept_rsvp::handle_unaccept_rsvp,
handle_unsubscribe::handle_unsubscribe,
handle_view_event::handle_view_event,
@@ -347,6 +348,7 @@ pub fn build_router(web_context: WebContext) -> Router {
.route("/accept_rsvp", post(handle_accept_rsvp))
.route("/bulk_accept_rsvps", post(handle_bulk_accept_rsvps))
.route("/unaccept_rsvp", post(handle_unaccept_rsvp))
+ .route("/share_rsvp_bluesky", post(handle_share_rsvp_bluesky))
.route("/finalize_acceptance", post(handle_finalize_acceptance))
.route(
"/event/upload-header",
diff --git a/templates/en-us/create_rsvp.partial.html b/templates/en-us/create_rsvp.partial.html
index cfd588a..3797ecc 100644
--- a/templates/en-us/create_rsvp.partial.html
+++ b/templates/en-us/create_rsvp.partial.html
@@ -15,6 +15,22 @@
{% else %}
The RSVP has been recorded!
{% endif %}
+ {% if is_going_rsvp %}
+
+
Let others know you're going!
+
+
+ {% endif %}
{% else %}
@@ -36,6 +52,22 @@
View Event
+ {% if is_going_rsvp %}
+
+
Let others know you're going!
+
+
+ {% endif %}
{% endif %}
diff --git a/templates/en-us/share_rsvp_bluesky.partial.html b/templates/en-us/share_rsvp_bluesky.partial.html
new file mode 100644
index 0000000..ddf529d
--- /dev/null
+++ b/templates/en-us/share_rsvp_bluesky.partial.html
@@ -0,0 +1,21 @@
+{% if success %}
+
+
+ Posted to Bluesky!
+
+
+
+
+ View Post
+
+
+
+{% else %}
+
+
+ Failed to post to Bluesky
+
+{% if error_message %}
+{{ error_message }}
+{% endif %}
+{% endif %}