From 4d9dd6d67606415e989e29bc4c098a5c879d9969 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Mon, 22 Sep 2025 01:37:34 -0500 Subject: [PATCH 1/2] Add per-status share pages and sharing UX --- Cargo.lock | 1 + Cargo.toml | 1 + src/api/mod.rs | 1 + src/api/status_read.rs | 156 ++++++++++++++++++++++++++++++-- src/db/models.rs | 66 ++++++++++++++ src/templates.rs | 14 +++ static/share.js | 129 +++++++++++++++++++++++++++ templates/base.html | 2 + templates/feed.html | 173 ++++++++++++++++++++++++++++-------- templates/status.html | 77 ++++++++++++++++ templates/status_share.html | 173 ++++++++++++++++++++++++++++++++++++ 11 files changed, 747 insertions(+), 46 deletions(-) create mode 100644 static/share.js create mode 100644 templates/status_share.html diff --git a/Cargo.lock b/Cargo.lock index d72e5c5..07a2349 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2360,6 +2360,7 @@ dependencies = [ "atrium-common", "atrium-identity", "atrium-oauth", + "base64 0.22.1", "chrono", "dotenv", "env_logger", diff --git a/Cargo.toml b/Cargo.toml index 987a066..ca1e8fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ hmac = "0.12" sha2 = "0.10" hex = "0.4" url = "2.5" +base64 = "0.22" [build-dependencies] askama = "0.13" 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..7fa78e7 100644 --- a/src/api/status_read.rs +++ b/src/api/status_read.rs @@ -4,16 +4,17 @@ 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; use atrium_common::resolver::Resolver; use atrium_identity::handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig}; use atrium_oauth::DefaultHttpClient; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use serde_json::json; use std::sync::Arc; @@ -40,7 +41,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 +52,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 +89,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 +104,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 +114,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 +176,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 +187,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 +212,127 @@ pub async fn user_status_page( Ok(web::Html::new(html)) } +/// Public share page for a specific status +#[get("/s/{token}")] +pub async fn status_share_page( + req: HttpRequest, + token: web::Path, + db_pool: web::Data>, + handle_resolver: web::Data, +) -> Result { + let share_token = token.into_inner(); + + let uri_bytes = match URL_SAFE_NO_PAD.decode(share_token.as_bytes()) { + Ok(bytes) => bytes, + Err(err) => { + log::warn!("Failed to decode share token {}: {}", share_token, err); + let html = ErrorTemplate { + title: "Invalid share link", + error: "That share link is not valid.", + } + .render() + .expect("template should be valid"); + return Ok(HttpResponse::NotFound() + .content_type("text/html; charset=utf-8") + .body(html)); + } + }; + + let uri = match String::from_utf8(uri_bytes) { + Ok(uri) => uri, + Err(err) => { + log::warn!("Share token did not decode to UTF-8: {}", err); + let html = ErrorTemplate { + title: "Invalid share link", + error: "That share link is not valid.", + } + .render() + .expect("template should be valid"); + return Ok(HttpResponse::NotFound() + .content_type("text/html; charset=utf-8") + .body(html)); + } + }; + + 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(), share_token); + + let html = StatusShareTemplate { + title: "status", + status, + canonical_url, + display_handle, + meta_title, + meta_description, + share_text, + profile_href, + } + .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..7754614 100644 --- a/src/db/models.rs +++ b/src/db/models.rs @@ -4,6 +4,7 @@ use async_sqlite::{ rusqlite::{Error, Row, types::Type}, }; use atrium_api::types::string::Did; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::{ @@ -39,6 +40,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 +273,50 @@ 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()) + } + + /// Generates a share-safe token for embedding in URLs + pub fn share_token(&self) -> String { + URL_SAFE_NO_PAD.encode(self.uri.as_bytes()) + } } /// AuthSession table data type diff --git a/src/templates.rs b/src/templates.rs index b1350fe..71ec748 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -39,6 +39,20 @@ 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, +} + #[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 @@ + +