diff --git a/src/api/mod.rs b/src/api/mod.rs index e6475f6..973096c 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -22,6 +22,7 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) { // Status page routes (read) .service(status_read::home) .service(status_read::user_status_page) + .service(status_read::status_share_page) .service(status_read::feed) // Status JSON API routes (read) .service(status_read::owner_status_json) diff --git a/src/api/status_read.rs b/src/api/status_read.rs index b2c9daf..26e1b19 100644 --- a/src/api/status_read.rs +++ b/src/api/status_read.rs @@ -4,10 +4,10 @@ use crate::resolver::HickoryDnsTxtResolver; use crate::{ api::auth::OAuthClientType, db::StatusFromDb, - templates::{ErrorTemplate, FeedTemplate, StatusTemplate}, + templates::{ErrorTemplate, FeedTemplate, StatusShareTemplate, StatusTemplate}, }; use actix_session::Session; -use actix_web::{Responder, Result, get, web}; +use actix_web::{HttpRequest, HttpResponse, Responder, Result, get, web}; use askama::Template; use async_sqlite::Pool; use atrium_api::types::string::Did; @@ -40,7 +40,7 @@ pub async fn home( .unwrap_or_else(|| did_string.clone()), Err(_) => did_string.clone(), }; - let current_status = StatusFromDb::my_status(&db_pool, &did) + let mut current_status = StatusFromDb::my_status(&db_pool, &did) .await .unwrap_or(None) .and_then(|s| { @@ -51,12 +51,18 @@ pub async fn home( } Some(s) }); - let history = StatusFromDb::load_user_statuses(&db_pool, &did, 10) + let mut history = StatusFromDb::load_user_statuses(&db_pool, &did, 10) .await .unwrap_or_else(|err| { log::error!("Error loading status history: {err}"); vec![] }); + if let Some(ref mut status) = current_status { + status.handle = Some(handle.clone()); + } + for status in &mut history { + status.handle = Some(handle.clone()); + } let is_admin_flag = is_admin(did.as_str()); let html = StatusTemplate { title: "your status", @@ -82,7 +88,7 @@ pub async fn home( } else { None }; - let current_status = if let Some(ref did) = owner_did { + let mut current_status = if let Some(ref did) = owner_did { StatusFromDb::my_status(&db_pool, did) .await .unwrap_or(None) @@ -97,7 +103,7 @@ pub async fn home( } else { None }; - let history = if let Some(ref did) = owner_did { + let mut history = if let Some(ref did) = owner_did { StatusFromDb::load_user_statuses(&db_pool, did, 10) .await .unwrap_or_else(|err| { @@ -107,6 +113,12 @@ pub async fn home( } else { vec![] }; + if let Some(ref mut status) = current_status { + status.handle = Some(OWNER_HANDLE.to_string()); + } + for status in &mut history { + status.handle = Some(OWNER_HANDLE.to_string()); + } let html = StatusTemplate { title: "nate's status", handle: OWNER_HANDLE.to_string(), @@ -163,7 +175,7 @@ pub async fn user_status_page( Some(session_did) => session_did == did.to_string(), None => false, }; - let current_status = StatusFromDb::my_status(&db_pool, &did) + let mut current_status = StatusFromDb::my_status(&db_pool, &did) .await .unwrap_or(None) .and_then(|s| { @@ -174,12 +186,18 @@ pub async fn user_status_page( } Some(s) }); - let history = StatusFromDb::load_user_statuses(&db_pool, &did, 10) + let mut history = StatusFromDb::load_user_statuses(&db_pool, &did, 10) .await .unwrap_or_else(|err| { log::error!("Error loading status history: {err}"); vec![] }); + if let Some(ref mut status) = current_status { + status.handle = Some(handle.clone()); + } + for status in &mut history { + status.handle = Some(handle.clone()); + } let html = StatusTemplate { title: &format!("@{} status", handle), handle, @@ -193,6 +211,98 @@ pub async fn user_status_page( Ok(web::Html::new(html)) } +/// Public share page for a specific status +#[get("/s/{did}/{rkey}")] +pub async fn status_share_page( + req: HttpRequest, + params: web::Path<(String, String)>, + db_pool: web::Data>, + handle_resolver: web::Data, +) -> Result { + let (did, rkey) = params.into_inner(); + let uri = format!("at://{}/io.zzstoatzz.status.record/{}", did, rkey); + + let mut status = match StatusFromDb::load_by_uri(&db_pool, &uri).await { + Ok(Some(status)) => status, + Ok(None) => { + let html = ErrorTemplate { + title: "Status not found", + error: "We couldn't find that status any more.", + } + .render() + .expect("template should be valid"); + return Ok(HttpResponse::NotFound() + .content_type("text/html; charset=utf-8") + .body(html)); + } + Err(err) => { + log::error!("Database error loading status {}: {}", uri, err); + let html = ErrorTemplate { + title: "Something went wrong", + error: "We couldn't load that status right now.", + } + .render() + .expect("template should be valid"); + return Ok(HttpResponse::InternalServerError() + .content_type("text/html; charset=utf-8") + .body(html)); + } + }; + + let handle = match Did::new(status.author_did.clone()) { + Ok(did) => match handle_resolver.resolve(&did).await { + Ok(doc) => doc + .also_known_as + .and_then(|aka| aka.first().cloned()) + .map(|h| h.replace("at://", "")), + Err(err) => { + log::debug!( + "Failed to resolve handle for {}: {}", + status.author_did, + err + ); + None + } + }, + Err(err) => { + log::warn!("Invalid DID on status {}: {}", status.uri, err); + None + } + }; + status.handle = handle.clone(); + + let display_handle = status.author_display_name(); + let meta_title = status.share_title(); + let meta_description = status.share_description(); + let share_text = status.share_text(); + let profile_href = handle + .clone() + .map(|h| format!("/@{}", h)) + .unwrap_or_else(|| format!("https://bsky.app/profile/{}", status.author_did)); + + let info = req.connection_info(); + let canonical_url = format!("{}://{}/s/{}/{}", info.scheme(), info.host(), did, rkey); + let share_path = format!("/s/{}/{}", did, rkey); + + let html = StatusShareTemplate { + title: "status", + status, + canonical_url, + display_handle, + meta_title, + meta_description, + share_text, + profile_href, + share_path, + } + .render() + .expect("template should be valid"); + + Ok(HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(html)) +} + #[get("/json")] pub async fn owner_status_json( _session: Session, diff --git a/src/db/models.rs b/src/db/models.rs index a100943..f58346d 100644 --- a/src/db/models.rs +++ b/src/db/models.rs @@ -39,6 +39,27 @@ impl StatusFromDb { } } + /// Loads a status by its ATProto URI + pub async fn load_by_uri( + pool: &Data>, + uri: &str, + ) -> Result, async_sqlite::Error> { + let target_uri = uri.to_string(); + pool.conn(move |conn| { + let mut stmt = conn.prepare("SELECT * FROM status WHERE uri = ?1 LIMIT 1")?; + stmt.query_row([target_uri.as_str()], Self::map_from_row) + .map(Some) + .or_else(|err| { + if err == async_sqlite::rusqlite::Error::QueryReturnedNoRows { + Ok(None) + } else { + Err(err) + } + }) + }) + .await + } + /// Helper to map from [Row] to [StatusDb] fn map_from_row(row: &Row) -> Result { Ok(Self { @@ -251,6 +272,56 @@ impl StatusFromDb { None => self.author_did.to_string(), } } + + /// Friendly emoji label suitable for text-only contexts + pub fn share_emoji_label(&self) -> String { + if let Some(name) = self.status.strip_prefix("custom:") { + format!(":{}:", name) + } else { + self.status.clone() + } + } + + fn share_caption(&self) -> Option { + self.text + .as_ref() + .map(|t| t.trim()) + .filter(|t| !t.is_empty()) + .map(|t| t.to_owned()) + } + + /// Short title combining emoji and handle for link previews + pub fn share_title(&self) -> String { + format!( + "{} @{}", + self.share_emoji_label(), + self.author_display_name() + ) + } + + /// Description prioritizing the freeform text when present + pub fn share_description(&self) -> String { + self.share_caption() + .unwrap_or_else(|| format!("{} shared a status", self.author_display_name())) + } + + /// Combined share text used for copy/share flows + pub fn share_text(&self) -> String { + self.share_caption() + .map(|caption| format!("{} — {}", self.share_title(), caption)) + .unwrap_or_else(|| self.share_title()) + } + + /// Returns the record key component of the ATProto URI (rkey) + pub fn record_key(&self) -> Option { + self.uri.rsplit_once('/').map(|(_, rkey)| rkey.to_string()) + } + + /// Generates the relative share path used by the UI (e.g. `/s/did:plc:abc/rkey`) + pub fn share_path(&self) -> String { + let rkey = self.record_key().unwrap_or_default(); + format!("/s/{}/{}", self.author_did, rkey) + } } /// AuthSession table data type diff --git a/src/templates.rs b/src/templates.rs index b1350fe..e5e9b1c 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -39,6 +39,21 @@ pub struct StatusTemplate<'a> { pub is_admin: bool, } +#[derive(Template)] +#[template(path = "status_share.html")] +pub struct StatusShareTemplate<'a> { + #[allow(dead_code)] + pub title: &'a str, + pub status: StatusFromDb, + pub canonical_url: String, + pub display_handle: String, + pub meta_title: String, + pub meta_description: String, + pub share_text: String, + pub profile_href: String, + pub share_path: String, +} + #[derive(Template)] #[template(path = "feed.html")] pub struct FeedTemplate<'a> { diff --git a/static/share.js b/static/share.js new file mode 100644 index 0000000..6f6b8bb --- /dev/null +++ b/static/share.js @@ -0,0 +1,129 @@ +(function () { + const FEEDBACK_DURATION = 2200; + + function cacheDefaults(button) { + if (!button.dataset.defaultLabel) { + const labelEl = button.querySelector('.share-label'); + if (labelEl) { + const text = labelEl.textContent ? labelEl.textContent.trim() : ''; + labelEl.dataset.defaultLabel = text; + button.dataset.defaultLabel = text; + } else { + button.dataset.defaultLabel = button.textContent.trim(); + } + } + } + + function setLabel(button, message) { + const labelEl = button.querySelector('.share-label'); + if (labelEl) { + labelEl.textContent = message; + } else { + button.textContent = message; + } + } + + function restoreLabel(button) { + const labelEl = button.querySelector('.share-label'); + if (labelEl && labelEl.dataset.defaultLabel) { + labelEl.textContent = labelEl.dataset.defaultLabel; + } else if (button.dataset.defaultLabel) { + button.textContent = button.dataset.defaultLabel; + } + button.classList.remove('share-trigger--feedback'); + } + + function scheduleRestore(button) { + if (button.dataset.shareFeedbackTimer) { + clearTimeout(Number(button.dataset.shareFeedbackTimer)); + } + const timer = window.setTimeout(() => { + restoreLabel(button); + delete button.dataset.shareFeedbackTimer; + }, FEEDBACK_DURATION); + button.dataset.shareFeedbackTimer = String(timer); + } + + async function copyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'absolute'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + textarea.select(); + try { + const success = document.execCommand('copy'); + document.body.removeChild(textarea); + return success; + } catch (err) { + document.body.removeChild(textarea); + return false; + } + } + + async function handleShare(button) { + cacheDefaults(button); + + const sharePath = button.dataset.sharePath; + if (!sharePath) { + return; + } + const shareUrl = new URL(sharePath, window.location.origin).toString(); + const shareTitle = button.dataset.shareTitle || ''; + const shareText = button.dataset.shareText || ''; + + if (navigator.share) { + try { + await navigator.share({ + title: shareTitle || undefined, + text: shareText || undefined, + url: shareUrl, + }); + button.classList.add('share-trigger--feedback'); + setLabel(button, 'shared!'); + scheduleRestore(button); + return; + } catch (err) { + if (err && err.name === 'AbortError') { + return; + } + // fall through to clipboard copy + } + } + + try { + const copied = await copyToClipboard(shareUrl); + if (copied) { + button.classList.add('share-trigger--feedback'); + setLabel(button, 'copied!'); + scheduleRestore(button); + return; + } + } catch (err) { + // ignore and fall back to opening link + } + + window.open(shareUrl, '_blank', 'noopener'); + button.classList.add('share-trigger--feedback'); + setLabel(button, 'opened'); + scheduleRestore(button); + } + + document.addEventListener('DOMContentLoaded', function () { + document.querySelectorAll('.share-trigger').forEach(cacheDefaults); + }); + + document.addEventListener('click', function (event) { + const button = event.target.closest('.share-trigger'); + if (!button) { + return; + } + event.preventDefault(); + handleShare(button); + }); +})(); diff --git a/templates/base.html b/templates/base.html index 20f19f6..81701fa 100644 --- a/templates/base.html +++ b/templates/base.html @@ -30,6 +30,8 @@ + +