diff --git a/CHANGELOG.md b/CHANGELOG.md index 99ca029..d78a36f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## v0.1.6 - Pinned Profiles + +### Profiles and Timelines +- Added profile timeline tabs for posts, replies, media, and liked posts. +- Added pinned profile posts with owner-only pin/unpin controls and automatic cleanup when a pinned post is deleted. +- Added a liked-posts visibility setting so users can make their likes private while still seeing their own likes. +- Preserved relationship and moderation visibility rules across profile tabs, pinned posts, private likes, blocked users, muted users, suspended accounts, and anonymous viewers. + +### Posting and Composer +- Added server-enforced post editing during a configurable short edit window, with no-JS edit forms and edited markers on changed posts. +- Added live mention autocomplete to composer text areas backed by bounded `/mentions` suggestions. +- Greyed out composer submit buttons when text exceeds the configured character limit. + +### Onboarding and Notifications +- Added first-run account onboarding for new users with profile setup, optional avatar upload, and follow suggestions. +- Grouped notifications by activity target, added grouped read/open handling, and improved empty-state rendering across feeds, lists, search, bookmarks, and notifications. + +### Admin, Settings, and Operations +- Added `posts.post_edit_window_seconds` to settings and deep server settings, including validation from `0` to `300` seconds. +- Added an admin backups page for creating backup archives from the web UI. +- Refactored the backup restore workflow around staged extraction and clearer restore handling. + +### Privacy, Security, and UI Fixes +- Hardened mention suggestions so wildcard characters cannot broaden searches and unavailable or hidden users stay excluded. +- Hid private likes from profile likes tabs, notifications, and visible like counts except for the liker. +- Rejected non-image onboarding/profile-picture uploads before creating profile avatar state. +- Fixed the no-JS banner so it appears only when JavaScript is disabled. +- Restored the compact Tor onion address pill with a disclosure and copy control. + +### Migrations and Compatibility +- Added migrations for onboarding completion state and liked-post visibility. +- Existing active users are marked as already onboarded, deleted users remain incomplete, and existing users keep public likes by default. +- Bumped the crate version to `0.1.6` and refreshed `Cargo.lock`. + ## v0.1.5 - Safety, Media, and Link Previews ### Posts and Composer diff --git a/Cargo.lock b/Cargo.lock index 7f7d5df..1ebfeea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3223,7 +3223,7 @@ dependencies = [ [[package]] name = "rustpost" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "argon2", diff --git a/Cargo.toml b/Cargo.toml index b519a6f..e05a5bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpost" -version = "0.1.5" +version = "0.1.6" edition = "2024" license = "MIT" description = "A single-binary self-hosted microblogging app" diff --git a/README.md b/README.md index 962c142..65bed88 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,15 @@ registration_captcha_enabled = false `registration_captcha_enabled` adds a single-use CAPTCHA challenge to registration only. Login is unchanged. +### Post editing + +```toml +[posts] +post_edit_window_seconds = 15 +``` + +Users can edit their own post text only during this short server-enforced window. The default is 15 seconds; set it to `0` to disable post editing. + ### Clearnet only (default) ```toml diff --git a/src/admin.rs b/src/admin.rs index 0a1e239..14e4649 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -7,7 +7,7 @@ use rusqlite::{Row, params, params_from_iter}; use serde::Deserialize; use crate::auth; -use crate::config::Settings; +use crate::config::{MAX_POST_EDIT_WINDOW_SECONDS, Settings}; use crate::db::SqlitePool; const MIB: u64 = 1024 * 1024; @@ -18,6 +18,7 @@ pub struct DeepSettingsForm { pub intent: Option, pub site_name: String, pub max_text_chars: String, + pub post_edit_window_seconds: String, pub max_images_per_post: String, pub max_videos_per_post: String, pub max_media_per_post: String, @@ -45,6 +46,7 @@ pub struct DeepSettingsForm { pub enum DeepSettingsField { SiteName, MaxTextChars, + PostEditWindowSeconds, MaxImagesPerPost, MaxVideosPerPost, MaxMediaPerPost, @@ -79,6 +81,7 @@ pub enum DeepSettingsInputKind { pub struct DeepSettingsValues { pub site_name: String, pub max_text_chars: usize, + pub post_edit_window_seconds: u64, pub max_images_per_post: usize, pub max_videos_per_post: usize, pub max_media_per_post: usize, @@ -110,9 +113,10 @@ pub struct DeepSettingsChange { } impl DeepSettingsField { - pub const ALL: [Self; 23] = [ + pub const ALL: [Self; 24] = [ Self::SiteName, Self::MaxTextChars, + Self::PostEditWindowSeconds, Self::MaxImagesPerPost, Self::MaxVideosPerPost, Self::MaxMediaPerPost, @@ -141,6 +145,7 @@ impl DeepSettingsField { match self { Self::SiteName => "Site", Self::MaxTextChars + | Self::PostEditWindowSeconds | Self::MaxImagesPerPost | Self::MaxVideosPerPost | Self::MaxMediaPerPost @@ -168,6 +173,7 @@ impl DeepSettingsField { match self { Self::SiteName => "site", Self::MaxTextChars + | Self::PostEditWindowSeconds | Self::MaxImagesPerPost | Self::MaxVideosPerPost | Self::MaxMediaPerPost @@ -195,6 +201,7 @@ impl DeepSettingsField { match self { Self::SiteName => "name", Self::MaxTextChars => "max_text_chars", + Self::PostEditWindowSeconds => "post_edit_window_seconds", Self::MaxImagesPerPost => "max_images_per_post", Self::MaxVideosPerPost => "max_videos_per_post", Self::MaxMediaPerPost => "max_media_per_post", @@ -224,6 +231,7 @@ impl DeepSettingsField { match self { Self::SiteName => "site_name", Self::MaxTextChars => "max_text_chars", + Self::PostEditWindowSeconds => "post_edit_window_seconds", Self::MaxImagesPerPost => "max_images_per_post", Self::MaxVideosPerPost => "max_videos_per_post", Self::MaxMediaPerPost => "max_media_per_post", @@ -253,6 +261,7 @@ impl DeepSettingsField { match self { Self::SiteName => "Site name", Self::MaxTextChars => "Maximum post text length", + Self::PostEditWindowSeconds => "Post edit window", Self::MaxImagesPerPost => "Maximum images per post", Self::MaxVideosPerPost => "Maximum videos per post", Self::MaxMediaPerPost => "Maximum total media per post", @@ -285,6 +294,9 @@ impl DeepSettingsField { | Self::MaxUsernameLen | Self::MaxDisplayNameLen | Self::MaxBioLen => Some("Characters."), + Self::PostEditWindowSeconds => { + Some("Seconds. Default is 15; valid range is 0 to 300. Set 0 to disable.") + } Self::MaxImagesPerPost | Self::MaxVideosPerPost | Self::MaxMediaPerPost => { Some("Attachments per post.") } @@ -325,6 +337,7 @@ impl DeepSettingsValues { Self { site_name: settings.site.name.clone(), max_text_chars: settings.posts.max_text_chars, + post_edit_window_seconds: settings.posts.post_edit_window_seconds, max_images_per_post: settings.posts.max_images_per_post, max_videos_per_post: settings.posts.max_videos_per_post, max_media_per_post: settings.posts.max_media_per_post, @@ -354,6 +367,7 @@ impl DeepSettingsValues { match field { DeepSettingsField::SiteName => self.site_name.clone(), DeepSettingsField::MaxTextChars => self.max_text_chars.to_string(), + DeepSettingsField::PostEditWindowSeconds => self.post_edit_window_seconds.to_string(), DeepSettingsField::MaxImagesPerPost => self.max_images_per_post.to_string(), DeepSettingsField::MaxVideosPerPost => self.max_videos_per_post.to_string(), DeepSettingsField::MaxMediaPerPost => self.max_media_per_post.to_string(), @@ -389,6 +403,7 @@ impl DeepSettingsValues { | DeepSettingsField::MaxUsernameLen | DeepSettingsField::MaxDisplayNameLen | DeepSettingsField::MaxBioLen => format!("{value} characters"), + DeepSettingsField::PostEditWindowSeconds => format!("{value} seconds"), DeepSettingsField::MaxImageSizeMb | DeepSettingsField::MaxVideoSizeMb => { format!("{value} MB") } @@ -401,6 +416,7 @@ impl DeepSettingsValues { let mut updated = current.clone(); updated.site.name.clone_from(&self.site_name); updated.posts.max_text_chars = self.max_text_chars; + updated.posts.post_edit_window_seconds = self.post_edit_window_seconds; updated.posts.max_images_per_post = self.max_images_per_post; updated.posts.max_videos_per_post = self.max_videos_per_post; updated.posts.max_media_per_post = self.max_media_per_post; @@ -433,6 +449,7 @@ pub fn parse_deep_settings_form( let values = DeepSettingsValues { site_name: form.site_name.clone(), max_text_chars: parse_usize(&form.max_text_chars, DeepSettingsField::MaxTextChars)?, + post_edit_window_seconds: parse_post_edit_window_seconds(&form.post_edit_window_seconds)?, max_images_per_post: parse_usize( &form.max_images_per_post, DeepSettingsField::MaxImagesPerPost, @@ -539,6 +556,23 @@ fn parse_usize(value: &str, field: DeepSettingsField) -> anyhow::Result { .with_context(|| format!("{} must be a whole number", field.label())) } +fn parse_post_edit_window_seconds(value: &str) -> anyhow::Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + anyhow::bail!("Post edit window is required"); + } + if trimmed.starts_with('-') { + anyhow::bail!("Post edit window must not be negative"); + } + let seconds = trimmed + .parse::() + .with_context(|| "Post edit window must be a whole number of seconds")?; + if seconds > MAX_POST_EDIT_WINDOW_SECONDS { + anyhow::bail!("Post edit window must be {MAX_POST_EDIT_WINDOW_SECONDS} seconds or less"); + } + Ok(seconds) +} + fn parse_mb(value: &str, field: DeepSettingsField) -> anyhow::Result { let trimmed = value.trim(); if trimmed.is_empty() { @@ -667,6 +701,9 @@ fn toml_value(field: DeepSettingsField, settings: &Settings) -> String { match field { DeepSettingsField::SiteName => toml::Value::String(settings.site.name.clone()).to_string(), DeepSettingsField::MaxTextChars => settings.posts.max_text_chars.to_string(), + DeepSettingsField::PostEditWindowSeconds => { + settings.posts.post_edit_window_seconds.to_string() + } DeepSettingsField::MaxImagesPerPost => settings.posts.max_images_per_post.to_string(), DeepSettingsField::MaxVideosPerPost => settings.posts.max_videos_per_post.to_string(), DeepSettingsField::MaxMediaPerPost => settings.posts.max_media_per_post.to_string(), @@ -1301,6 +1338,7 @@ mod tests { intent: Some("preview".to_owned()), site_name: values.site_name, max_text_chars: values.max_text_chars.to_string(), + post_edit_window_seconds: values.post_edit_window_seconds.to_string(), max_images_per_post: values.max_images_per_post.to_string(), max_videos_per_post: values.max_videos_per_post.to_string(), max_media_per_post: values.max_media_per_post.to_string(), @@ -1547,6 +1585,18 @@ mod tests { assert_eq!(updated.accounts.min_password_length, 5); } + #[test] + fn deep_settings_accepts_zero_post_edit_window_as_disabled() { + let settings = Settings::default(); + let mut form = form_from_settings(&settings); + form.post_edit_window_seconds = "0".to_owned(); + + let parsed = parse_deep_settings_form(&form, &settings).expect("valid form"); + let updated = parsed.apply_to(&settings); + + assert_eq!(updated.posts.post_edit_window_seconds, 0); + } + #[test] fn deep_settings_writeback_preserves_unrelated_values_and_comments() { let temp = tempdir().expect("temp dir"); diff --git a/src/config.rs b/src/config.rs index 3c870ae..3bdebaa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,6 +3,9 @@ use std::path::Path; use serde::{Deserialize, Serialize}; +pub const DEFAULT_POST_EDIT_WINDOW_SECONDS: u64 = 15; +pub const MAX_POST_EDIT_WINDOW_SECONDS: u64 = 300; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Settings { #[serde(default)] @@ -56,6 +59,8 @@ pub struct AccountSettings { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PostSettings { pub max_text_chars: usize, + #[serde(default = "default_post_edit_window_seconds")] + pub post_edit_window_seconds: u64, pub max_images_per_post: usize, pub max_videos_per_post: usize, pub max_media_per_post: usize, @@ -143,6 +148,7 @@ impl Default for Settings { }, posts: PostSettings { max_text_chars: 280, + post_edit_window_seconds: DEFAULT_POST_EDIT_WINDOW_SECONDS, max_images_per_post: 4, max_videos_per_post: 1, max_media_per_post: 4, @@ -227,10 +233,24 @@ impl Settings { } validate_onion_service_name(&self.tor.onion_service_name)?; validate_display_onion_address(&self.tor.display_onion_address)?; + validate_post_edit_window(self.posts.post_edit_window_seconds)?; Ok(()) } } +const fn default_post_edit_window_seconds() -> u64 { + DEFAULT_POST_EDIT_WINDOW_SECONDS +} + +fn validate_post_edit_window(seconds: u64) -> anyhow::Result<()> { + if seconds > MAX_POST_EDIT_WINDOW_SECONDS { + anyhow::bail!( + "posts.post_edit_window_seconds must be {MAX_POST_EDIT_WINDOW_SECONDS} seconds or less" + ); + } + Ok(()) +} + pub fn write_default_if_missing(path: &Path) -> anyhow::Result<()> { if path.exists() { return Ok(()); @@ -320,6 +340,9 @@ allow_profile_pictures = {allow_profile_pictures} # Maximum post text length in characters. max_text_chars = {max_text_chars} +# Seconds after creation that a user may edit their own post. Set to 0 to disable editing. +post_edit_window_seconds = {post_edit_window_seconds} + # Maximum image attachments allowed on one post. max_images_per_post = {max_images_per_post} @@ -478,6 +501,7 @@ backup_dir = {backup_dir} allow_profile_banners = settings.accounts.allow_profile_banners, allow_profile_pictures = settings.accounts.allow_profile_pictures, max_text_chars = settings.posts.max_text_chars, + post_edit_window_seconds = settings.posts.post_edit_window_seconds, max_images_per_post = settings.posts.max_images_per_post, max_videos_per_post = settings.posts.max_videos_per_post, max_media_per_post = settings.posts.max_media_per_post, @@ -643,6 +667,39 @@ mod tests { assert!(parsed.tor.display_onion_address.is_empty()); } + #[test] + fn missing_post_edit_window_defaults_to_fifteen_seconds() { + let raw = toml::to_string(&Settings::default()) + .expect("settings toml") + .lines() + .filter(|line| !line.trim_start().starts_with("post_edit_window_seconds")) + .collect::>() + .join("\n"); + let parsed: Settings = toml::from_str(&raw).expect("legacy settings parse"); + + assert_eq!( + parsed.posts.post_edit_window_seconds, + DEFAULT_POST_EDIT_WINDOW_SECONDS + ); + parsed.validate().expect("defaulted setting validates"); + } + + #[test] + fn accepts_zero_post_edit_window_as_disabled() { + let mut settings = Settings::default(); + settings.posts.post_edit_window_seconds = 0; + + settings.validate().expect("zero disables post editing"); + } + + #[test] + fn rejects_unsafe_post_edit_windows() { + let mut settings = Settings::default(); + + settings.posts.post_edit_window_seconds = MAX_POST_EDIT_WINDOW_SECONDS + 1; + assert!(settings.validate().is_err()); + } + #[test] fn tor_only_requires_tor_enabled() { let mut settings = Settings::default(); diff --git a/src/db.rs b/src/db.rs index bceec93..c14e25d 100644 --- a/src/db.rs +++ b/src/db.rs @@ -128,6 +128,14 @@ pub async fn migrate(pool: &Db) -> anyhow::Result<()> { tx.execute_batch(MIGRATION_11)?; tx.execute("INSERT INTO schema_migrations (version) VALUES (11)", [])?; } + if applied.unwrap_or(0) < 12 { + tx.execute_batch(MIGRATION_12)?; + tx.execute("INSERT INTO schema_migrations (version) VALUES (12)", [])?; + } + if applied.unwrap_or(0) < 13 { + tx.execute_batch(MIGRATION_13)?; + tx.execute("INSERT INTO schema_migrations (version) VALUES (13)", [])?; + } tx.commit()?; Ok(()) }) @@ -430,6 +438,17 @@ BEGIN END; "#; +const MIGRATION_12: &str = r#" +ALTER TABLE users ADD COLUMN onboarding_completed_at TEXT; +UPDATE users +SET onboarding_completed_at = CURRENT_TIMESTAMP +WHERE onboarding_completed_at IS NULL AND is_deleted = 0; +"#; + +const MIGRATION_13: &str = r#" +ALTER TABLE users ADD COLUMN liked_posts_public INTEGER NOT NULL DEFAULT 1 CHECK (liked_posts_public IN (0, 1)); +"#; + #[cfg(test)] mod tests { use super::*; @@ -471,7 +490,7 @@ mod tests { .await .expect("settings"); - assert_eq!(versions, 11); + assert_eq!(versions, 13); assert_eq!(foreign_keys, 1); assert_eq!(journal_mode, "wal"); assert!(busy_timeout >= 5_000); @@ -611,6 +630,176 @@ mod tests { assert_eq!(is_nsfw, 0); } + #[tokio::test] + async fn onboarding_completion_defaults_to_incomplete() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + migrate(&pool).await.expect("migrate"); + + let completed_at: Option = pool + .call(|conn| { + conn.execute( + "INSERT INTO users (username, normalized_username, password_hash, display_name) VALUES ('Alice', 'alice', 'hash', 'Alice')", + [], + )?; + Ok(conn.query_row( + "SELECT onboarding_completed_at FROM users WHERE normalized_username = 'alice'", + [], + |row| row.get(0), + )?) + }) + .await + .expect("onboarding state"); + + assert!(completed_at.is_none()); + } + + #[tokio::test] + async fn liked_posts_are_public_by_default() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + migrate(&pool).await.expect("migrate"); + + let liked_posts_public: i64 = pool + .call(|conn| { + conn.execute( + "INSERT INTO users (username, normalized_username, password_hash, display_name) VALUES ('Alice', 'alice', 'hash', 'Alice')", + [], + )?; + Ok(conn.query_row( + "SELECT liked_posts_public FROM users WHERE normalized_username = 'alice'", + [], + |row| row.get(0), + )?) + }) + .await + .expect("liked posts visibility"); + + assert_eq!(liked_posts_public, 1); + } + + #[tokio::test] + async fn liked_posts_public_migration_defaults_existing_users_public() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + pool.call(|conn| { + conn.execute_batch( + r#" + CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY); + INSERT INTO schema_migrations (version) VALUES (12); + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + normalized_username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + is_deleted INTEGER NOT NULL DEFAULT 0 + ); + INSERT INTO users (username, normalized_username, password_hash, display_name) + VALUES ('Alice', 'alice', 'hash', 'Alice'); + INSERT INTO users (username, normalized_username, password_hash, display_name, is_deleted) + VALUES ('Deleted', 'deleted', 'hash', 'Deleted', 1); + "#, + )?; + Ok(()) + }) + .await + .expect("legacy schema"); + + migrate(&pool).await.expect("migrate to liked_posts_public"); + migrate(&pool).await.expect("second migrate"); + + let (alice_public, deleted_public, column_count, version): (i64, i64, i64, i64) = pool + .call(|conn| { + let alice = conn.query_row( + "SELECT liked_posts_public FROM users WHERE normalized_username = 'alice'", + [], + |row| row.get(0), + )?; + let deleted = conn.query_row( + "SELECT liked_posts_public FROM users WHERE normalized_username = 'deleted'", + [], + |row| row.get(0), + )?; + let column_count = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('users') WHERE name = 'liked_posts_public'", + [], + |row| row.get(0), + )?; + let version = + conn.query_row("SELECT MAX(version) FROM schema_migrations", [], |row| { + row.get(0) + })?; + Ok((alice, deleted, column_count, version)) + }) + .await + .expect("liked posts migration state"); + + assert_eq!(alice_public, 1); + assert_eq!(deleted_public, 1); + assert_eq!(column_count, 1); + assert_eq!(version, 13); + } + + #[tokio::test] + async fn onboarding_migration_marks_existing_active_users_complete() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + pool.call(|conn| { + conn.execute_batch( + r#" + CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY); + INSERT INTO schema_migrations (version) VALUES (11); + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + normalized_username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + is_deleted INTEGER NOT NULL DEFAULT 0 + ); + INSERT INTO users (username, normalized_username, password_hash, display_name) + VALUES ('Alice', 'alice', 'hash', 'Alice'); + INSERT INTO users (username, normalized_username, password_hash, display_name, is_deleted) + VALUES ('Deleted', 'deleted', 'hash', 'Deleted', 1); + "#, + )?; + Ok(()) + }) + .await + .expect("legacy schema"); + + migrate(&pool).await.expect("migrate"); + + let (active_completed_at, deleted_completed_at): (Option, Option) = pool + .call(|conn| { + let active = conn.query_row( + "SELECT onboarding_completed_at FROM users WHERE normalized_username = 'alice'", + [], + |row| row.get(0), + )?; + let deleted = conn.query_row( + "SELECT onboarding_completed_at FROM users WHERE normalized_username = 'deleted'", + [], + |row| row.get(0), + )?; + Ok((active, deleted)) + }) + .await + .expect("onboarding state"); + + assert!(active_completed_at.is_some()); + assert!(deleted_completed_at.is_none()); + } + #[tokio::test] async fn posts_support_quote_repost_references() { let temp = tempfile::tempdir().expect("temp dir"); diff --git a/src/media.rs b/src/media.rs index a7e108d..91c53ca 100644 --- a/src/media.rs +++ b/src/media.rs @@ -118,12 +118,24 @@ impl MediaKind { } pub async fn save_upload( + pool: &SqlitePool, + settings: &Settings, + paths: &RuntimePaths, + ffmpeg: &FfmpegStatus, + owner_user_id: Option, + field: Field<'_>, +) -> anyhow::Result { + save_upload_inner(pool, settings, paths, ffmpeg, owner_user_id, field, None).await +} + +async fn save_upload_inner( pool: &SqlitePool, settings: &Settings, paths: &RuntimePaths, ffmpeg: &FfmpegStatus, owner_user_id: Option, mut field: Field<'_>, + required_image_label: Option<&'static str>, ) -> anyhow::Result { let original_filename = field.file_name().unwrap_or("upload").to_owned(); reject_path_tricks(&original_filename)?; @@ -144,6 +156,7 @@ pub async fn save_upload( staging, bytes, }, + required_image_label, ) .await } @@ -151,8 +164,15 @@ pub async fn save_upload( async fn save_staged_upload( context: &UploadContext<'_>, upload: StagedUpload, + required_image_label: Option<&'static str>, ) -> anyhow::Result { let prepared = prepare_staged_upload(context.settings, upload).await?; + if let Some(label) = required_image_label + && prepared.media_kind != MediaKind::Image + { + remove_staged_upload(&prepared.staging).await; + anyhow::bail!("{label} must be an image"); + } if let Some(media_id) = try_insert_duplicate( context, &prepared, @@ -450,7 +470,16 @@ pub async fn save_profile_picture_upload( owner_user_id: i64, field: Field<'_>, ) -> anyhow::Result { - let media_id = save_upload(pool, settings, paths, ffmpeg, Some(owner_user_id), field).await?; + let media_id = save_upload_inner( + pool, + settings, + paths, + ffmpeg, + Some(owner_user_id), + field, + Some("profile picture"), + ) + .await?; generate_profile_picture_thumbnail(pool, settings, paths, ffmpeg, media_id).await?; Ok(media_id) } @@ -1777,6 +1806,7 @@ mod tests { staging, bytes: u64::try_from(bytes.len()).expect("byte len"), }, + None, ) .await .expect("save upload") diff --git a/src/render.rs b/src/render.rs index 5ecd962..6a526d3 100644 --- a/src/render.rs +++ b/src/render.rs @@ -1,6 +1,7 @@ use crate::auth::{CurrentUser, Theme}; use crate::social::{ - AccountView, MediaView, NotificationView, PostView, QuotePreview, TimelineEventKind, + AccountView, MediaView, NotificationGroupView, PostView, ProfileTimelineTab, QuotePreview, + TimelineEventKind, }; use axum::http::{StatusCode, Uri}; use chrono::{DateTime, NaiveDateTime, Utc}; @@ -38,6 +39,53 @@ pub struct PostRenderOptions { pub show_timestamp: bool, pub clickable_card: bool, pub blur_nsfw_media: bool, + pub post_edit_window_seconds: u64, +} + +#[derive(Debug, Clone, Copy)] +pub struct SearchRenderOptions { + pub blur_nsfw_media: bool, + pub post_edit_window_seconds: u64, +} + +impl Default for SearchRenderOptions { + fn default() -> Self { + Self { + blur_nsfw_media: true, + post_edit_window_seconds: crate::config::DEFAULT_POST_EDIT_WINDOW_SECONDS, + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct OnboardingPage<'a> { + pub csrf: &'a str, + pub display_name: &'a str, + pub bio: &'a str, + pub picture_path: Option<&'a str>, + pub suggestions: &'a [AccountView], + pub allow_profile_pictures: bool, + pub max_display_name_len: usize, + pub max_bio_len: usize, +} + +#[derive(Debug, Clone, Copy)] +pub struct EmptyState<'a> { + pub title: &'a str, + pub message: &'a str, +} + +impl<'a> EmptyState<'a> { + pub const fn new(title: &'a str, message: &'a str) -> Self { + Self { title, message } + } + + const fn default_posts() -> Self { + Self { + title: "No posts yet.", + message: "The timeline will fill in once people start posting.", + } + } } impl PostRenderOptions { @@ -46,6 +94,7 @@ impl PostRenderOptions { show_timestamp: false, clickable_card: true, blur_nsfw_media: true, + post_edit_window_seconds: crate::config::DEFAULT_POST_EDIT_WINDOW_SECONDS, } } @@ -54,8 +103,14 @@ impl PostRenderOptions { show_timestamp: true, clickable_card: true, blur_nsfw_media: true, + post_edit_window_seconds: crate::config::DEFAULT_POST_EDIT_WINDOW_SECONDS, } } + + const fn with_edit_window(mut self, seconds: u64) -> Self { + self.post_edit_window_seconds = seconds; + self + } } pub fn layout(user: Option<&CurrentUser>, title: &str, body: &str, site_name: &str) -> String { @@ -135,12 +190,13 @@ pub fn layout_with_context( {} - {} + -
JavaScript is disabled. RustPost will use standard links and forms.
+
{}
{}
{}
{} alpha
@@ -165,14 +221,16 @@ fn tor_header_indicator(onion: Option<&str>) -> String { onion.map_or_else(String::new, |onion| { let attr_onion = html_escape::encode_double_quoted_attribute(onion); let onion_url = format!("http://{onion}"); + let escaped_onion_url = html_escape::encode_text(&onion_url); let attr_onion_url = html_escape::encode_double_quoted_attribute(&onion_url); let short = short_onion_address(onion); format!( - r#"
Tor:{}
"#, + r#"
Tor{}
"#, + html_escape::encode_text(&short), attr_onion_url, attr_onion, attr_onion, - html_escape::encode_text(&short), + escaped_onion_url, attr_onion, ) }) @@ -320,6 +378,8 @@ fn password_requirement_hint(min_password_length: usize) -> String { } } +const CLIENT_BOOT_SCRIPT: &str = r#"document.documentElement.classList.add("js-enabled");"#; + const CLIENT_SCRIPT: &str = r#"document.documentElement.classList.add("js-enabled"); function cardInteractiveTarget(target) { @@ -333,6 +393,19 @@ document.addEventListener("click", (event) => { if (!(event.target instanceof Element)) { return; } + const formCard = event.target.closest("[data-card-form]"); + if (formCard && !cardInteractiveTarget(event.target)) { + const form = document.getElementById(formCard.getAttribute("data-card-form")); + if (form instanceof HTMLFormElement) { + event.preventDefault(); + if (form.requestSubmit) { + form.requestSubmit(); + } else { + form.submit(); + } + return; + } + } const card = event.target.closest("[data-card-href]"); if (!card || cardInteractiveTarget(event.target)) { return; @@ -344,6 +417,18 @@ document.addEventListener("keydown", (event) => { if (!(event.target instanceof Element)) { return; } + if (event.key === "Enter" && event.target === event.target.closest("[data-card-form]")) { + const form = document.getElementById(event.target.getAttribute("data-card-form")); + if (form instanceof HTMLFormElement) { + event.preventDefault(); + if (form.requestSubmit) { + form.requestSubmit(); + } else { + form.submit(); + } + return; + } + } if (event.key !== "Enter" || event.target !== event.target.closest("[data-card-href]")) { return; } @@ -450,21 +535,325 @@ document.addEventListener("keydown", (event) => { } }); -function updateComposerCount(textarea) { +const CHARACTER_COUNTER_CLASSES = [ + "character-counter-normal", + "character-counter-warning", + "character-counter-danger" +]; + +function characterCounterFor(textarea) { const counter = document.querySelector(`[data-character-counter="${textarea.id}"]`); + return counter; +} + +function characterCounterClass(remaining) { + if (remaining <= 10) { + return "character-counter-danger"; + } + if (remaining <= 50) { + return "character-counter-warning"; + } + return "character-counter-normal"; +} + +function updateComposerSubmit(textarea, remaining) { + const form = textarea.closest("form"); + const submit = form ? form.querySelector('button[type="submit"]') : null; + if (!submit) { + return; + } + submit.disabled = remaining < 0 || form.dataset.submitting === "true"; +} + +function updateComposerCount(textarea) { + const counter = characterCounterFor(textarea); if (!counter) { return; } const max = Number.parseInt(textarea.getAttribute("data-character-limit") || "0", 10); + if (!Number.isFinite(max)) { + return; + } const length = Array.from(textarea.value).length; - counter.textContent = `${Math.max(0, max - length)} remaining`; + const remaining = max - length; + counter.textContent = `${remaining} remaining`; + counter.classList.remove(...CHARACTER_COUNTER_CLASSES); + counter.classList.add(characterCounterClass(remaining)); + updateComposerSubmit(textarea, remaining); +} + +function initializeComposerCounters(root) { + if (root.matches && root.matches("textarea[data-character-limit]")) { + updateComposerCount(root); + } + if (root.querySelectorAll) { + root.querySelectorAll("textarea[data-character-limit]").forEach(updateComposerCount); + } +} + +const mentionStates = new WeakMap(); +const MENTION_DEBOUNCE_MS = 120; + +function mentionStateFor(textarea) { + let state = mentionStates.get(textarea); + if (state) { + return state; + } + const menu = document.getElementById(textarea.getAttribute("aria-controls")); + if (!menu) { + return null; + } + state = { + textarea, + menu, + items: [], + activeIndex: -1, + atIndex: -1, + fragment: "", + requestId: 0, + timer: 0, + controller: null + }; + mentionStates.set(textarea, state); + return state; +} + +function mentionContext(textarea) { + if (textarea.selectionStart !== textarea.selectionEnd) { + return null; + } + const cursor = textarea.selectionStart; + const before = textarea.value.slice(0, cursor); + const atIndex = before.lastIndexOf("@"); + if (atIndex < 0) { + return null; + } + const preceding = atIndex > 0 ? before.charAt(atIndex - 1) : ""; + if (preceding && /[A-Za-z0-9_@.-]/.test(preceding)) { + return null; + } + const fragment = before.slice(atIndex + 1); + if (!/^[A-Za-z0-9_-]*$/.test(fragment)) { + return null; + } + return { atIndex, fragment }; +} + +function closeMentionMenu(textarea) { + const state = mentionStateFor(textarea); + if (!state) { + return; + } + if (state.timer) { + window.clearTimeout(state.timer); + state.timer = 0; + } + if (state.controller) { + state.controller.abort(); + state.controller = null; + } + state.items = []; + state.activeIndex = -1; + state.atIndex = -1; + state.fragment = ""; + state.menu.hidden = true; + state.menu.textContent = ""; + textarea.setAttribute("aria-expanded", "false"); + textarea.removeAttribute("aria-activedescendant"); +} + +function setActiveMention(state, index) { + if (state.items.length === 0) { + state.activeIndex = -1; + state.textarea.removeAttribute("aria-activedescendant"); + return; + } + state.activeIndex = (index + state.items.length) % state.items.length; + state.menu.querySelectorAll("[role='option']").forEach((option, optionIndex) => { + const selected = optionIndex === state.activeIndex; + option.setAttribute("aria-selected", selected ? "true" : "false"); + if (selected) { + state.textarea.setAttribute("aria-activedescendant", option.id); + option.scrollIntoView({ block: "nearest" }); + } + }); +} + +function selectMention(textarea, index) { + const state = mentionStateFor(textarea); + if (!state || index < 0 || index >= state.items.length) { + return; + } + const context = mentionContext(textarea); + if (!context || context.atIndex !== state.atIndex) { + closeMentionMenu(textarea); + return; + } + const username = state.items[index].username; + const start = state.atIndex; + const end = textarea.selectionStart; + const suffix = textarea.value.slice(end); + const needsSpace = suffix.length === 0 || /^[A-Za-z0-9_@#-]/.test(suffix); + const replacement = `@${username}${needsSpace ? " " : ""}`; + textarea.value = `${textarea.value.slice(0, start)}${replacement}${suffix}`; + const cursor = start + replacement.length; + textarea.setSelectionRange(cursor, cursor); + closeMentionMenu(textarea); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + textarea.focus(); +} + +function renderMentionSuggestions(state, suggestions) { + state.items = suggestions; + state.menu.textContent = ""; + if (suggestions.length === 0) { + closeMentionMenu(state.textarea); + return; + } + suggestions.forEach((suggestion, index) => { + const option = document.createElement("button"); + option.type = "button"; + option.className = "mention-option"; + option.id = `${state.menu.id}-option-${index}`; + option.setAttribute("role", "option"); + option.setAttribute("aria-selected", "false"); + option.dataset.username = suggestion.username; + const name = document.createElement("span"); + name.className = "mention-name"; + name.textContent = suggestion.display_name || suggestion.username; + const handle = document.createElement("span"); + handle.className = "mention-handle"; + handle.textContent = `@${suggestion.username}`; + option.append(name, handle); + option.addEventListener("pointerdown", (event) => { + event.preventDefault(); + selectMention(state.textarea, index); + }); + option.addEventListener("mouseenter", () => setActiveMention(state, index)); + state.menu.append(option); + }); + state.menu.hidden = false; + state.textarea.setAttribute("aria-expanded", "true"); + setActiveMention(state, 0); } -document.querySelectorAll("textarea[data-character-limit]").forEach((textarea) => { - updateComposerCount(textarea); - textarea.addEventListener("input", () => updateComposerCount(textarea)); +function requestMentionSuggestions(textarea) { + const state = mentionStateFor(textarea); + const context = mentionContext(textarea); + if (!state || !context || !window.fetch) { + closeMentionMenu(textarea); + return; + } + state.atIndex = context.atIndex; + state.fragment = context.fragment; + if (state.timer) { + window.clearTimeout(state.timer); + } + state.timer = window.setTimeout(async () => { + state.timer = 0; + if (state.controller) { + state.controller.abort(); + } + const requestId = state.requestId + 1; + state.requestId = requestId; + state.controller = window.AbortController ? new AbortController() : null; + try { + const response = await fetch(`/mentions?q=${encodeURIComponent(context.fragment)}`, { + headers: { "Accept": "application/json" }, + credentials: "same-origin", + signal: state.controller ? state.controller.signal : undefined + }); + if (!response.ok || requestId !== state.requestId) { + return; + } + const suggestions = await response.json(); + const current = mentionContext(textarea); + if (!current || current.atIndex !== context.atIndex || current.fragment !== context.fragment) { + return; + } + renderMentionSuggestions(state, Array.isArray(suggestions) ? suggestions : []); + } catch (error) { + if (!state.controller || error.name !== "AbortError") { + closeMentionMenu(textarea); + } + } + }, MENTION_DEBOUNCE_MS); +} + +function initializeMentionAutocomplete(root) { + if (root.matches && root.matches("textarea[data-mention-autocomplete]")) { + mentionStateFor(root); + } + if (root.querySelectorAll) { + root.querySelectorAll("textarea[data-mention-autocomplete]").forEach(mentionStateFor); + } +} + +document.addEventListener("input", (event) => { + const textarea = event.target.closest("textarea[data-character-limit]"); + if (textarea) { + updateComposerCount(textarea); + } + const mentionTextarea = event.target.closest("textarea[data-mention-autocomplete]"); + if (mentionTextarea) { + requestMentionSuggestions(mentionTextarea); + } }); +document.addEventListener("keydown", (event) => { + if (!(event.target instanceof Element)) { + return; + } + const textarea = event.target.closest("textarea[data-mention-autocomplete]"); + if (!textarea) { + return; + } + const state = mentionStateFor(textarea); + if (!state || state.menu.hidden) { + return; + } + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveMention(state, state.activeIndex + 1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveMention(state, state.activeIndex - 1); + } else if (event.key === "Enter" || event.key === "Tab") { + event.preventDefault(); + selectMention(textarea, state.activeIndex); + } else if (event.key === "Escape") { + event.preventDefault(); + closeMentionMenu(textarea); + } +}); + +document.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) { + return; + } + document.querySelectorAll("textarea[data-mention-autocomplete]").forEach((textarea) => { + const state = mentionStateFor(textarea); + if (state && event.target !== textarea && !state.menu.contains(event.target)) { + closeMentionMenu(textarea); + } + }); +}); + +initializeComposerCounters(document); +initializeMentionAutocomplete(document); +window.addEventListener("pageshow", () => initializeComposerCounters(document)); +window.requestAnimationFrame(() => initializeComposerCounters(document)); +if (window.MutationObserver) { + new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + initializeComposerCounters(node); + initializeMentionAutocomplete(node); + }); + }); + }).observe(document.documentElement, { childList: true, subtree: true }); +} + function mediaSummary(input) { if (!input.files || input.files.length === 0) { return ""; @@ -515,6 +904,72 @@ document.addEventListener("click", (event) => { input.focus(); }); +function mediaFileName(input) { + if (!input.files || input.files.length === 0) { + return ""; + } + return input.files[0].name || "media file"; +} + +function setProfileMediaStatus(frame, message) { + const status = frame.querySelector("[data-profile-media-status]"); + if (status) { + status.textContent = message; + } +} + +function updateProfileMediaFile(input) { + const frame = input.closest("[data-profile-media-frame]"); + if (!frame) { + return; + } + const fileName = mediaFileName(input); + frame.classList.toggle("settings-media-has-file", fileName !== ""); + if (fileName !== "") { + const remove = frame.querySelector("[data-profile-media-delete]"); + if (remove) { + remove.checked = false; + frame.classList.remove("settings-media-removing"); + } + setProfileMediaStatus(frame, `${fileName} selected`); + } else { + setProfileMediaStatus(frame, ""); + } +} + +function updateProfileMediaDelete(input) { + const frame = input.closest("[data-profile-media-frame]"); + if (!frame) { + return; + } + frame.classList.toggle("settings-media-removing", input.checked); + const file = frame.querySelector("[data-profile-media-file]"); + if (input.checked && file) { + file.value = ""; + updateProfileMediaFile(file); + frame.classList.add("settings-media-removing"); + } + const label = input.getAttribute("aria-label") || "Media"; + setProfileMediaStatus(frame, input.checked ? `${label} selected` : `${label} cancelled`); +} + +document.querySelectorAll("[data-profile-media-file]").forEach(updateProfileMediaFile); + +document.addEventListener("change", (event) => { + if (!(event.target instanceof Element)) { + return; + } + const file = event.target.closest("[data-profile-media-file]"); + if (file) { + updateProfileMediaFile(file); + return; + } + const remove = event.target.closest("[data-profile-media-delete]"); + if (remove) { + updateProfileMediaDelete(remove); + } +}); + function resetSubmittingForms() { document.querySelectorAll('form[data-submitting="true"]').forEach((form) => { delete form.dataset.submitting; @@ -678,8 +1133,13 @@ document.addEventListener("submit", (event) => { async function copyTextToClipboard(text) { if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(text); - return; + try { + await navigator.clipboard.writeText(text); + return; + } catch (_err) { + // Fall back to the selection-based path below for browsers that expose + // the Clipboard API but deny writes in this context. + } } const textarea = document.createElement("textarea"); textarea.value = text; @@ -688,8 +1148,20 @@ async function copyTextToClipboard(text) { textarea.style.top = "-1000px"; document.body.append(textarea); textarea.select(); - document.execCommand("copy"); + const copied = document.execCommand("copy"); textarea.remove(); + if (!copied) { + throw new Error("copy command failed"); + } +} + +function setCopyButtonLabel(button, label) { + const feedback = button.querySelector("[data-copy-feedback]"); + if (feedback) { + feedback.textContent = label; + return; + } + button.textContent = label; } document.addEventListener("click", async (event) => { @@ -697,28 +1169,40 @@ document.addEventListener("click", async (event) => { if (!button) { return; } + event.preventDefault(); + event.stopPropagation(); + const original = button.getAttribute("data-copy-label") || button.textContent || "Copy"; + if (button.dataset.copyTimer) { + window.clearTimeout(Number.parseInt(button.dataset.copyTimer, 10)); + delete button.dataset.copyTimer; + } try { await copyTextToClipboard(button.getAttribute("data-copy-text") || ""); - const original = button.getAttribute("data-copy-label") || button.textContent; - button.textContent = button.getAttribute("data-copied-label") || "Copied"; - window.setTimeout(() => { - button.textContent = original; - }, 1600); + setCopyButtonLabel(button, button.getAttribute("data-copied-label") || "Copied"); } catch (_err) { - button.textContent = "Copy failed"; + setCopyButtonLabel(button, button.getAttribute("data-copy-failed-label") || "Failed"); } + button.dataset.copyTimer = String(window.setTimeout(() => { + setCopyButtonLabel(button, original); + delete button.dataset.copyTimer; + }, 1600)); });"#; pub fn client_script() -> &'static str { CLIENT_SCRIPT } +pub fn client_boot_script() -> &'static str { + CLIENT_BOOT_SCRIPT +} + pub fn composer(csrf: Option<&str>, parent: Option, max_text_chars: usize) -> String { let parent_input = parent.map_or_else(String::new, |id| { format!(r#""#) }); let csrf = csrf.unwrap_or_default(); let input_id = parent.map_or_else(|| "post-text".to_owned(), |id| format!("reply-text-{id}")); + let mention_menu_id = format!("{input_id}-mention-menu"); let media_id = parent.map_or_else(|| "post-media".to_owned(), |id| format!("reply-media-{id}")); let nsfw_id = parent.map_or_else(|| "post-nsfw".to_owned(), |id| format!("reply-nsfw-{id}")); let placeholder = if parent.is_some() { @@ -727,11 +1211,12 @@ pub fn composer(csrf: Option<&str>, parent: Option, max_text_chars: usize) "What's happening?" }; format!( - r#"

{}

{} remaining
+ r#"

{}

{} remaining
{}
- + +
@@ -749,8 +1234,9 @@ pub fn composer(csrf: Option<&str>, parent: Option, max_text_chars: usize) html_escape::encode_double_quoted_attribute(&input_id), html_escape::encode_double_quoted_attribute(&input_id), max_text_chars, - max_text_chars, + html_escape::encode_double_quoted_attribute(&mention_menu_id), html_escape::encode_double_quoted_attribute(placeholder), + html_escape::encode_double_quoted_attribute(&mention_menu_id), html_escape::encode_double_quoted_attribute(&media_id), html_escape::encode_double_quoted_attribute(&media_id), icon_svg("paperclip"), @@ -762,15 +1248,14 @@ pub fn composer(csrf: Option<&str>, parent: Option, max_text_chars: usize) pub fn quote_composer(csrf: &str, quote: &QuotePreview, max_text_chars: usize) -> String { let preview = quote_preview_card(quote); format!( - r#"

Quote post

{max_text_chars} remaining
{preview} + r#"

Quote post

{max_text_chars} remaining
{preview} - +
"#, quote.id, html_escape::encode_double_quoted_attribute(csrf), - max_text_chars, max_text_chars ) } @@ -778,8 +1263,8 @@ pub fn quote_composer(csrf: &str, quote: &QuotePreview, max_text_chars: usize) - pub fn accounts(accounts: &[AccountView], csrf: &str) -> String { if accounts.is_empty() { return empty_state( - "You are not following anyone yet.", - "Follow accounts to build your home feed.", + "Follow people to build your feed.", + "Accounts you follow will appear here.", ); } let rows = accounts @@ -820,9 +1305,86 @@ pub fn accounts(accounts: &[AccountView], csrf: &str) -> String { format!(r#""#) } +pub fn onboarding_page(page: OnboardingPage<'_>) -> String { + let picture = page.picture_path.map_or_else( + || { + r#""# + .to_owned() + }, + |path| { + format!( + r#""#, + html_escape::encode_double_quoted_attribute(path) + ) + }, + ); + let picture_control = if page.allow_profile_pictures { + r#""# + .to_owned() + } else { + r#"

Profile pictures are disabled.

"#.to_owned() + }; + let suggestions = if page.suggestions.is_empty() { + r#"

No local accounts to suggest yet.

"#.to_owned() + } else { + page.suggestions + .iter() + .map(|account| { + let avatar = account.profile_picture_path.as_ref().map_or_else( + || { + let initial = account.display_name.chars().next().unwrap_or('R'); + format!( + r#""#, + html_escape::encode_text(&initial.to_string()) + ) + }, + |path| { + format!( + r#""#, + html_escape::encode_double_quoted_attribute(path) + ) + }, + ); + let checked = if account.viewer_following { + " checked" + } else { + "" + }; + let bio = if account.bio.trim().is_empty() { + String::new() + } else { + format!( + r#"{}"#, + html_escape::encode_text(&account.bio) + ) + }; + format!( + r#""#, + account.id, + html_escape::encode_text(&account.display_name), + html_escape::encode_text(&account.username), + ) + }) + .collect::>() + .join("") + }; + format!( + r#"

First run

Set up your account

{picture}
{picture_control}
Follow accounts{suggestions}
"#, + html_escape::encode_double_quoted_attribute(page.csrf), + page.max_display_name_len, + html_escape::encode_double_quoted_attribute(page.display_name), + page.max_bio_len, + html_escape::encode_text(page.bio), + ) +} + pub fn account_links(accounts: &[AccountView], empty_message: &str) -> String { + account_links_with_empty_state(accounts, EmptyState::new(empty_message, "")) +} + +pub fn account_links_with_empty_state(accounts: &[AccountView], empty: EmptyState<'_>) -> String { if accounts.is_empty() { - return empty_state(empty_message, ""); + return empty_state(empty.title, empty.message); } let rows = accounts .iter() @@ -855,6 +1417,22 @@ pub fn account_links(accounts: &[AccountView], empty_message: &str) -> String { format!(r#""#) } +pub fn edit_post_form( + csrf: &str, + post_id: i64, + text: &str, + max_text_chars: usize, + return_to: &str, +) -> String { + format!( + r#"

Edit post

{max_text_chars} remaining
Cancel
"#, + html_escape::encode_double_quoted_attribute(csrf), + html_escape::encode_double_quoted_attribute(return_to), + html_escape::encode_text(text), + html_escape::encode_double_quoted_attribute(return_to), + ) +} + pub fn search_page( site_name: &str, query: &str, @@ -862,7 +1440,7 @@ pub fn search_page( posts: &[PostView], user: Option<&CurrentUser>, csrf: Option<&str>, - blur_nsfw_media: bool, + options: SearchRenderOptions, ) -> String { let form = search_form(site_name, query); let state = if query.is_empty() { @@ -871,9 +1449,9 @@ pub fn search_page( "Search for posts, usernames, mentions, or hashtags.", ) } else if users.is_empty() && posts.is_empty() { - empty_state("No results found", &format!(r#"No matches for "{query}"."#)) + empty_state("No matching posts or users found.", "Try another search.") } else { - search_results(query, users, posts, user, csrf, blur_nsfw_media) + search_results(query, users, posts, user, csrf, options) }; format!("{form}{state}") } @@ -892,7 +1470,7 @@ fn search_results( posts: &[PostView], user: Option<&CurrentUser>, csrf: Option<&str>, - blur_nsfw_media: bool, + options: SearchRenderOptions, ) -> String { let total = users.len() + posts.len(); let plural = if total == 1 { "result" } else { "results" }; @@ -912,9 +1490,11 @@ fn search_results( user, csrf, PostRenderOptions { - blur_nsfw_media, + blur_nsfw_media: options.blur_nsfw_media, ..PostRenderOptions::timeline() - } + .with_edit_window(options.post_edit_window_seconds) + }, + EmptyState::default_posts(), ) ) }; @@ -999,7 +1579,13 @@ pub fn follow_form(user_id: i64, csrf: &str, following: bool) -> String { } pub fn posts(posts: &[PostView], user: Option<&CurrentUser>, csrf: Option<&str>) -> String { - posts_with_options(posts, user, csrf, PostRenderOptions::timeline()) + posts_with_options( + posts, + user, + csrf, + PostRenderOptions::timeline(), + EmptyState::default_posts(), + ) } pub fn posts_with_nsfw_blur( @@ -1016,6 +1602,104 @@ pub fn posts_with_nsfw_blur( blur_nsfw_media, ..PostRenderOptions::timeline() }, + EmptyState::default_posts(), + ) +} + +pub fn posts_with_controls( + posts: &[PostView], + user: Option<&CurrentUser>, + csrf: Option<&str>, + blur_nsfw_media: bool, + post_edit_window_seconds: u64, +) -> String { + posts_with_controls_empty_state( + posts, + user, + csrf, + blur_nsfw_media, + post_edit_window_seconds, + EmptyState::default_posts(), + ) +} + +pub fn posts_with_controls_empty_state( + posts: &[PostView], + user: Option<&CurrentUser>, + csrf: Option<&str>, + blur_nsfw_media: bool, + post_edit_window_seconds: u64, + empty: EmptyState<'_>, +) -> String { + posts_with_options( + posts, + user, + csrf, + PostRenderOptions { + blur_nsfw_media, + ..PostRenderOptions::timeline().with_edit_window(post_edit_window_seconds) + }, + empty, + ) +} + +pub fn profile_tabs(username: &str, active: ProfileTimelineTab) -> String { + let tabs = [ + (ProfileTimelineTab::Posts, "Posts"), + (ProfileTimelineTab::Replies, "Replies"), + (ProfileTimelineTab::Media, "Media"), + (ProfileTimelineTab::Likes, "Likes"), + ]; + let links = tabs + .iter() + .map(|(tab, label)| { + let href = profile_tab_href(username, *tab); + let active_attrs = if *tab == active { + r#" class="active" aria-current="page""# + } else { + "" + }; + format!( + r#"{}"#, + html_escape::encode_double_quoted_attribute(&href), + active_attrs, + html_escape::encode_text(label) + ) + }) + .collect::>() + .join(""); + format!( + r#""# + ) +} + +fn profile_tab_href(username: &str, tab: ProfileTimelineTab) -> String { + match tab { + ProfileTimelineTab::Posts => format!("/users/{username}"), + ProfileTimelineTab::Replies => format!("/users/{username}?tab=replies"), + ProfileTimelineTab::Media => format!("/users/{username}?tab=media"), + ProfileTimelineTab::Likes => format!("/users/{username}?tab=likes"), + } +} + +pub fn pinned_post_with_controls( + post: &PostView, + user: Option<&CurrentUser>, + csrf: Option<&str>, + blur_nsfw_media: bool, + post_edit_window_seconds: u64, +) -> String { + let card = post_card_with_options( + post, + user, + csrf, + PostRenderOptions { + blur_nsfw_media, + ..PostRenderOptions::timeline().with_edit_window(post_edit_window_seconds) + }, + ); + format!( + r#"

Pinned post

{card}
"# ) } @@ -1040,6 +1724,24 @@ pub fn thread_posts_with_nsfw_blur( ) } +pub fn thread_posts_with_controls( + posts: &[PostView], + user: Option<&CurrentUser>, + csrf: Option<&str>, + blur_nsfw_media: bool, + post_edit_window_seconds: u64, +) -> String { + thread_posts_with_options( + posts, + user, + csrf, + PostRenderOptions { + blur_nsfw_media, + ..PostRenderOptions::thread().with_edit_window(post_edit_window_seconds) + }, + ) +} + fn thread_posts_with_options( posts: &[PostView], user: Option<&CurrentUser>, @@ -1047,10 +1749,8 @@ fn thread_posts_with_options( options: PostRenderOptions, ) -> String { if posts.is_empty() { - return empty_state( - "No posts yet", - "The timeline will fill in once people start posting.", - ); + let empty = EmptyState::default_posts(); + return empty_state(empty.title, empty.message); } format!( r#"
{}
"#, @@ -1074,12 +1774,10 @@ fn posts_with_options( user: Option<&CurrentUser>, csrf: Option<&str>, options: PostRenderOptions, + empty: EmptyState<'_>, ) -> String { if posts.is_empty() { - return empty_state( - "No posts yet", - "The timeline will fill in once people start posting.", - ); + return empty_state(empty.title, empty.message); } format!( r#"
{}
"#, @@ -1112,6 +1810,24 @@ pub fn post_card_with_nsfw_blur( ) } +pub fn post_card_with_controls( + post: &PostView, + user: Option<&CurrentUser>, + csrf: Option<&str>, + blur_nsfw_media: bool, + post_edit_window_seconds: u64, +) -> String { + post_card_with_options( + post, + user, + csrf, + PostRenderOptions { + blur_nsfw_media, + ..PostRenderOptions::timeline().with_edit_window(post_edit_window_seconds) + }, + ) +} + pub fn thread_post_card(post: &PostView, user: Option<&CurrentUser>, csrf: Option<&str>) -> String { post_card_with_options(post, user, csrf, PostRenderOptions::thread()) } @@ -1133,6 +1849,24 @@ pub fn thread_post_card_with_nsfw_blur( ) } +pub fn thread_post_card_with_controls( + post: &PostView, + user: Option<&CurrentUser>, + csrf: Option<&str>, + blur_nsfw_media: bool, + post_edit_window_seconds: u64, +) -> String { + post_card_with_options( + post, + user, + csrf, + PostRenderOptions { + blur_nsfw_media, + ..PostRenderOptions::thread().with_edit_window(post_edit_window_seconds) + }, + ) +} + // Rendering a post card stays centralized because the markup, counts, media, // and action controls must remain consistent between timelines and threads. #[expect( @@ -1212,6 +1946,27 @@ fn post_card_with_options( } else { String::new() }; + let edit = if post.user_id == Some(user.id) + && post_edit_available(&post.created_at, options.post_edit_window_seconds) + { + icon_link(&format!("/posts/{}/edit", post.id), "Edit", "edit") + } else { + String::new() + }; + let pin = if post.user_id == Some(user.id) { + pin_action_form( + &format!("/posts/{}/pin", post.id), + csrf, + if post.pinned_by_author { + "Unpin from profile" + } else { + "Pin to profile" + }, + post.pinned_by_author, + ) + } else { + String::new() + }; let nsfw = if user.is_admin && !post.media.is_empty() { admin_nsfw_form(post, csrf) } else { @@ -1219,7 +1974,7 @@ fn post_card_with_options( }; let reply_link = icon_link(&format!("/posts/{}#reply", post.id), "Reply", "reply"); format!( - r#"
{}{}{}{}{}{}
"#, + r#"
{}{}{}{}{}{}{}{}
"#, icon_action_form( &format!("/posts/{}/like", post.id), csrf, @@ -1256,6 +2011,8 @@ fn post_card_with_options( "bookmark", post.viewer_bookmarked ), + pin, + edit, delete, nsfw, ) @@ -1296,12 +2053,15 @@ fn post_card_with_options( } else { String::new() }; + let edited = post.edited_at.as_ref().map_or_else(String::new, |_| { + r#"edited"#.to_owned() + }); let quote = post .quote .as_ref() .map_or_else(String::new, quote_preview_card); format!( - r#"
{}{}
{}
{}
{}
{}
{}{}{}
{} likes{} reposts{} replies{}
{}
"#, + r#"
{}{}
{}
{}
{}
{}
{}{}{}
{} likes{} reposts{} replies{}{}
{}
"#, post_class, post.id, post.id, @@ -1319,11 +2079,26 @@ fn post_card_with_options( post.like_count, post.repost_count, post.reply_count, + edited, permalink, controls ) } +fn post_edit_available(created_at: &str, window_seconds: u64) -> bool { + if window_seconds == 0 { + return false; + } + let Some(created) = parse_timestamp(created_at) else { + return false; + }; + let Ok(window_seconds) = i64::try_from(window_seconds) else { + return false; + }; + let elapsed = Utc::now().signed_duration_since(created); + elapsed.num_seconds() >= 0 && elapsed.num_seconds() <= window_seconds +} + fn repost_action_with_quote( action: &str, quote_href: &str, @@ -1373,6 +2148,20 @@ fn icon_action_form( ) } +fn pin_action_form(action: &str, csrf: &str, label: &str, active: bool) -> String { + format!( + r#"
"#, + html_escape::encode_double_quoted_attribute(action), + html_escape::encode_double_quoted_attribute(csrf), + if active { " active" } else { "" }, + if active { "true" } else { "false" }, + html_escape::encode_double_quoted_attribute(label), + html_escape::encode_double_quoted_attribute(label), + icon_svg("pin"), + html_escape::encode_text(label) + ) +} + fn icon_link(href: &str, label: &str, icon: &str) -> String { format!( r#"{}{}"#, @@ -1433,7 +2222,7 @@ fn quote_preview_card(quote: &QuotePreview) -> String { ) } -fn icon_svg(icon: &str) -> &'static str { +pub(crate) fn icon_svg(icon: &str) -> &'static str { match icon { "home" => { r#""# @@ -1480,6 +2269,12 @@ fn icon_svg(icon: &str) -> &'static str { "trash" => { r#""# } + "edit" => { + r#""# + } + "pin" => { + r#""# + } "paperclip" => { r#""# } @@ -1500,7 +2295,7 @@ fn render_media(media: &MediaView, post_id: i64, index: usize, blur_nsfw_media: } let toggle_id = format!("nsfw-media-{post_id}-{index}"); format!( - r#"
{item}NSFW
Open media
"# + r#"
{item}NSFW
"# ) } @@ -1686,13 +2481,39 @@ pub fn linkify(text: &str) -> String { } pub fn empty_state(title: &str, message: &str) -> String { + let message = empty_state_message(message); format!( - r#"

{}

{}

"#, + r#"

{}

{message}
"#, + html_escape::encode_text(title), + ) +} + +pub fn compact_empty_state(title: &str, message: &str) -> String { + compact_empty_state_with_class("", title, message) +} + +pub fn compact_empty_state_with_class(extra_class: &str, title: &str, message: &str) -> String { + let class = if extra_class.trim().is_empty() { + "compact-empty".to_owned() + } else { + format!("compact-empty {}", extra_class.trim()) + }; + let message = empty_state_message(message); + format!( + r#"
{}{message}
"#, + html_escape::encode_double_quoted_attribute(&class), html_escape::encode_text(title), - html_escape::encode_text(message) ) } +fn empty_state_message(message: &str) -> String { + if message.trim().is_empty() { + String::new() + } else { + format!(r#"

{}

"#, html_escape::encode_text(message)) + } +} + pub fn page_header(title: &str, subtitle: &str) -> String { format!( r#""#, @@ -1702,7 +2523,7 @@ pub fn page_header(title: &str, subtitle: &str) -> String { } pub fn notifications_page( - notifications: &[NotificationView], + notifications: &[NotificationGroupView], unread_count: i64, csrf: &str, ) -> String { @@ -1730,10 +2551,7 @@ pub fn notifications_page( return format!( "{}{}", header, - empty_state( - "No notifications yet", - "Replies, likes, reposts, follows, and mentions will appear here." - ) + empty_state("No notifications.", "New activity will appear here.") ); } let caught_up = if unread_count == 0 { @@ -1745,11 +2563,11 @@ pub fn notifications_page( "{}{}
{}
", header, caught_up, - grouped_notification_rows(notifications) + grouped_notification_rows(notifications, csrf) ) } -fn grouped_notification_rows(notifications: &[NotificationView]) -> String { +fn grouped_notification_rows(notifications: &[NotificationGroupView], csrf: &str) -> String { let mut current_group = ""; let mut html = String::new(); for notification in notifications { @@ -1761,13 +2579,13 @@ fn grouped_notification_rows(notifications: &[NotificationView]) -> String { html_escape::encode_text(group) )); } - html.push_str(¬ification_row(notification)); + html.push_str(¬ification_row(notification, csrf)); } html } -fn notification_group(notification: &NotificationView) -> &'static str { - if notification.read_at.is_none() { +fn notification_group(notification: &NotificationGroupView) -> &'static str { + if notification.unread_count > 0 { "New" } else if is_today(¬ification.created_at) { "Today" @@ -1776,52 +2594,142 @@ fn notification_group(notification: &NotificationView) -> &'static str { } } -fn notification_row(notification: &NotificationView) -> String { - let unread = notification.read_at.is_none(); +fn notification_row(notification: &NotificationGroupView, csrf: &str) -> String { + let unread = notification.unread_count > 0; let target = notification_target(notification); - let target_attrs = target.as_ref().map_or_else(String::new, |href| { + let form_id = format!("notification-open-{}", notification.id); + let target_attrs = target.as_ref().map_or_else(String::new, |_href| { format!( - r#" data-card-href="{}""#, - html_escape::encode_double_quoted_attribute(href) + r#" data-card-form="{}" tabindex="0""#, + html_escape::encode_double_quoted_attribute(&form_id) ) }); let preview = notification_preview(notification, target.as_deref()); let unread_marker = if unread { - r#""# + format!( + r#""#, + html_escape::encode_double_quoted_attribute(¬ification_unread_label( + notification.unread_count + )) + ) } else { - "" + String::new() }; + let details = notification_actor_details(notification); + let open_control = notification_open_control(notification, target.as_deref(), &form_id, csrf); format!( - r#"

{} {}

{}

{}
"#, + r#"

{}

{}{}

{}

{}{}
"#, if unread { " unread" } else { "" }, target_attrs, html_escape::encode_text(notification_kind_label(¬ification.kind)), - notification_actor(notification), - html_escape::encode_text(notification_action_text(¬ification.kind)), + notification_line(notification), preview, + details, html_escape::encode_double_quoted_attribute(¬ification.created_at), html_escape::encode_text(&relative_time(¬ification.created_at)), + notification_count_meta(notification), + open_control, unread_marker ) } -fn notification_actor(notification: &NotificationView) -> String { - match ( - notification.actor_username.as_deref(), - notification.actor_display_name.as_deref(), - ) { +fn notification_line(notification: &NotificationGroupView) -> String { + if notification.total_count == 1 { + return format!( + "{} {}", + notification_actor( + notification + .actors + .first() + .and_then(|actor| actor.username.as_deref()), + notification + .actors + .first() + .and_then(|actor| actor.display_name.as_deref()), + notification.actors.first().and_then(|actor| actor.user_id) + ), + html_escape::encode_text(notification_action_text(¬ification.kind)) + ); + } + format!( + r#"{} {}"#, + html_escape::encode_text(¬ification_people_label(notification.total_count)), + html_escape::encode_text(notification_group_action_text(¬ification.kind)) + ) +} + +fn notification_actor( + username: Option<&str>, + display_name: Option<&str>, + user_id: Option, +) -> String { + match (username, display_name) { (Some(username), display_name) => format!( r#"{} @{}"#, html_escape::encode_double_quoted_attribute(username), html_escape::encode_text(display_name.unwrap_or(username)), html_escape::encode_text(username) ), - (None, _) if notification.actor_user_id.is_some() => "Deleted account".to_owned(), + (None, _) if user_id.is_some() => "Deleted account".to_owned(), _ => "Someone".to_owned(), } } -fn notification_preview(notification: &NotificationView, target: Option<&str>) -> String { +fn notification_actor_details(notification: &NotificationGroupView) -> String { + if notification.total_count <= 1 { + return String::new(); + } + let actors = notification + .actors + .iter() + .map(|actor| { + format!( + r#"
  • {}
  • "#, + notification_actor( + actor.username.as_deref(), + actor.display_name.as_deref(), + actor.user_id + ) + ) + }) + .collect::>() + .join(""); + format!( + r#"
    View people
      {actors}
    "# + ) +} + +fn notification_open_control( + notification: &NotificationGroupView, + target: Option<&str>, + form_id: &str, + csrf: &str, +) -> String { + let Some(target) = target else { + return String::new(); + }; + let notification_ids = notification + .notification_ids + .iter() + .map(i64::to_string) + .collect::>() + .join(","); + let group_target = notification + .group_target_post_id + .map_or_else(String::new, |id| { + format!(r#""#) + }); + format!( + r#"
    {group_target}
    "#, + html_escape::encode_double_quoted_attribute(form_id), + html_escape::encode_double_quoted_attribute(csrf), + html_escape::encode_double_quoted_attribute(¬ification_ids), + html_escape::encode_double_quoted_attribute(¬ification.kind), + html_escape::encode_double_quoted_attribute(target) + ) +} + +fn notification_preview(notification: &NotificationGroupView, target: Option<&str>) -> String { if notification.kind == "follow" { return String::new(); } @@ -1849,10 +2757,12 @@ fn notification_preview(notification: &NotificationView, target: Option<&str>) - } } -fn notification_target(notification: &NotificationView) -> Option { +fn notification_target(notification: &NotificationGroupView) -> Option { if notification.kind == "follow" { return notification - .actor_username + .actors + .first() + .and_then(|actor| actor.username.as_ref()) .as_ref() .map(|username| format!("/users/{username}")); } @@ -1863,6 +2773,47 @@ fn notification_target(notification: &NotificationView) -> Option { .map(|post_id| format!("/posts/{post_id}")) } +fn notification_count_meta(notification: &NotificationGroupView) -> String { + if notification.total_count <= 1 && notification.unread_count == 0 { + return String::new(); + } + let mut parts = Vec::new(); + if notification.total_count > 1 { + parts.push(notification_items_label(notification.total_count)); + } + if notification.unread_count > 0 { + parts.push(notification_unread_label(notification.unread_count)); + } + format!( + r#" {}"#, + html_escape::encode_text(&parts.join(" / ")) + ) +} + +fn notification_people_label(count: usize) -> String { + if count == 1 { + "1 person".to_owned() + } else { + format!("{count} people") + } +} + +fn notification_items_label(count: usize) -> String { + if count == 1 { + "1 notification".to_owned() + } else { + format!("{count} notifications") + } +} + +fn notification_unread_label(count: i64) -> String { + if count == 1 { + "1 unread".to_owned() + } else { + format!("{count} unread") + } +} + fn notification_action_text(kind: &str) -> &'static str { match kind { "reply" => "replied to your post", @@ -1875,6 +2826,18 @@ fn notification_action_text(kind: &str) -> &'static str { } } +fn notification_group_action_text(kind: &str) -> &'static str { + match kind { + "reply" => "replied to your post", + "like" => "liked your post", + "repost" => "reposted your post", + "quote" => "quoted your post", + "follow" => "followed you", + "mention" => "mentioned you in a post", + _ => "sent you notifications", + } +} + fn notification_kind_label(kind: &str) -> &'static str { match kind { "reply" => "R", @@ -1965,13 +2928,13 @@ pub fn error_page(status: StatusCode, message: &str) -> String { } const CSS: &str = r#" -:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color-scheme:light;line-height:1.5;--bg:#f5f6f1;--surface:#fff;--surface-subtle:#fbfcfa;--surface-muted:#f4f5f2;--header-bg:rgba(255,255,255,.96);--text:#202124;--text-strong:#172017;--muted:#667064;--muted-strong:#59625a;--border:#dfe4dc;--border-strong:#b9c2b8;--link:#1f5f8b;--link-strong:#24445f;--brand:#163b2f;--brand-hover:#235544;--brand-text:#fff;--hover:#eef3f0;--focus:#93c5fd;--shadow:rgba(20,35,30,.04);--reply-border:#c8d8d0;--avatar-bg:#eef3f0;--danger:#8a3d2d;--danger-strong:#6f2f22;--danger-bg:#fff8f5;--danger-border:#e6b8a8;--success-bg:#f4fbf5;--success-border:#add7b4;--media-bg:#f6f7f4;--shell-side:240px;--shell-primary:640px;--shell-gap:1.25rem;--shell-max:1160px;--header-padding-y:.8rem;--header-brand-size:2rem;--hairline:1px;--rail-sticky-top:calc(var(--header-brand-size) + var(--header-padding-y) + var(--header-padding-y) + var(--shell-gap) + var(--hairline))} -:root[data-theme="dark"]{color-scheme:dark;--bg:#111827;--surface:#182231;--surface-subtle:#1d2939;--surface-muted:#233044;--header-bg:rgba(17,24,39,.96);--text:#eef4fb;--text-strong:#f8fafc;--muted:#c3cfdd;--muted-strong:#d4deea;--border:#344256;--border-strong:#596b83;--link:#8fc7ff;--link-strong:#badcff;--brand:#4f8fc7;--brand-hover:#6aa8df;--brand-text:#06111f;--hover:#243349;--focus:#fbbf24;--shadow:rgba(0,0,0,.26);--reply-border:#4f6680;--avatar-bg:#243349;--danger:#ffb4a2;--danger-strong:#ffd2c7;--danger-bg:#3a2020;--danger-border:#8f4d43;--success-bg:#163321;--success-border:#4c8a61;--media-bg:#0f172a} +:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color-scheme:light;line-height:1.5;--bg:#f5f6f1;--surface:#fff;--surface-subtle:#fbfcfa;--surface-muted:#f4f5f2;--header-bg:rgba(255,255,255,.96);--text:#202124;--text-strong:#172017;--muted:#667064;--muted-strong:#59625a;--border:#dfe4dc;--border-strong:#b9c2b8;--link:#1f5f8b;--link-strong:#24445f;--brand:#163b2f;--brand-hover:#235544;--brand-text:#fff;--hover:#eef3f0;--focus:#93c5fd;--shadow:rgba(20,35,30,.04);--reply-border:#c8d8d0;--avatar-bg:#eef3f0;--warning:#9a5a00;--danger:#8a3d2d;--danger-strong:#6f2f22;--danger-bg:#fff8f5;--danger-border:#e6b8a8;--success-bg:#f4fbf5;--success-border:#add7b4;--media-bg:#f6f7f4;--shell-side:240px;--shell-primary:640px;--shell-gap:1.25rem;--shell-max:1160px;--header-padding-y:.8rem;--header-brand-size:2rem;--hairline:1px;--rail-sticky-top:calc(var(--header-brand-size) + var(--header-padding-y) + var(--header-padding-y) + var(--shell-gap) + var(--hairline))} +:root[data-theme="dark"]{color-scheme:dark;--bg:#111827;--surface:#182231;--surface-subtle:#1d2939;--surface-muted:#233044;--header-bg:rgba(17,24,39,.96);--text:#eef4fb;--text-strong:#f8fafc;--muted:#c3cfdd;--muted-strong:#d4deea;--border:#344256;--border-strong:#596b83;--link:#8fc7ff;--link-strong:#badcff;--brand:#4f8fc7;--brand-hover:#6aa8df;--brand-text:#06111f;--hover:#243349;--focus:#fbbf24;--shadow:rgba(0,0,0,.26);--reply-border:#4f6680;--avatar-bg:#243349;--warning:#f6c36b;--danger:#ffb4a2;--danger-strong:#ffd2c7;--danger-bg:#3a2020;--danger-border:#8f4d43;--success-bg:#163321;--success-border:#4c8a61;--media-bg:#0f172a} *{box-sizing:border-box}body{margin:0;min-width:320px;color:var(--text);background:var(--bg)}a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline} .site-header{position:sticky;top:0;z-index:10;background:var(--header-bg);border-bottom:1px solid var(--border);backdrop-filter:blur(8px)} .header-inner{max-width:var(--shell-max);margin:0 auto;padding:var(--header-padding-y) 1rem;display:flex;align-items:center;justify-content:space-between;gap:1rem} .header-brand-row{display:flex;align-items:center;gap:.75rem;min-width:0;max-width:100%}.brand{display:flex;align-items:center;gap:.55rem;font-weight:800;color:var(--text-strong);min-width:0}.brand span:last-child{overflow-wrap:anywhere}.brand-mark{display:inline-grid;place-items:center;width:var(--header-brand-size);height:var(--header-brand-size);border-radius:7px;background:var(--brand);color:var(--brand-text);flex:0 0 auto} -.tor-indicator{display:inline-flex;align-items:center;gap:.28rem;min-width:0;max-width:min(28rem,100%);flex:1 1 14rem;min-height:2rem;border-left:1px solid var(--border);padding:.05rem 0 .05rem .7rem;color:var(--muted-strong);font-size:.86rem;line-height:1;white-space:nowrap;overflow:hidden}.tor-label{flex:0 0 auto;color:var(--muted);font-weight:800}.tor-address-link{display:inline-flex;align-items:center;min-width:0;max-width:100%;flex:1 1 auto;min-height:1.9rem;padding:0 .1rem;color:var(--link-strong);font-weight:750}.tor-address-link:hover{color:var(--text-strong);text-decoration:underline}.tor-summary-text{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-variant-numeric:tabular-nums}.tor-copy-button{display:none;flex:0 0 auto;min-width:2.55rem;min-height:1.9rem;border:1px solid transparent;border-radius:6px;background:transparent;color:var(--muted-strong);padding:.18rem .35rem;font-size:.82rem;font-weight:800;line-height:1;box-shadow:none}.js-enabled .tor-copy-button{display:inline-flex;align-items:center;justify-content:center}.tor-copy-button:hover{background:var(--hover);color:var(--link-strong)} +.tor-indicator{position:relative;display:inline-flex;align-items:center;min-width:0;max-width:min(18rem,100%);flex:0 1 auto;color:var(--muted-strong);font-size:.84rem;line-height:1;white-space:nowrap}.tor-disclosure{position:relative;min-width:0;max-width:100%;flex:0 1 auto}.tor-pill{display:inline-flex;align-items:center;gap:.38rem;max-width:100%;min-height:2.15rem;border:1px solid var(--border);border-radius:999px;padding:.25rem .68rem;background:var(--surface-subtle);color:var(--link-strong);font-weight:800;cursor:pointer;list-style:none;overflow:hidden}.js-enabled .tor-pill{padding-right:3.85rem}.tor-pill::-webkit-details-marker{display:none}.tor-pill::marker{content:""}.tor-pill:hover{background:var(--hover);text-decoration:none}.tor-pill-label{flex:0 0 auto;color:var(--muted);font-weight:900}.tor-summary-text{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-variant-numeric:tabular-nums}.tor-details{position:absolute;z-index:12;left:0;top:calc(100% + .35rem);width:max-content;max-width:min(30rem,calc(100vw - 2rem));padding:.55rem .65rem;border:1px solid var(--border-strong);border-radius:8px;background:var(--surface);box-shadow:0 8px 24px var(--shadow);white-space:normal}.tor-full-link{display:block;max-width:min(28rem,calc(100vw - 3.5rem));overflow-wrap:anywhere;color:var(--text-strong);font-weight:800;line-height:1.3}.tor-full-link:hover{color:var(--link-strong)}.tor-copy-button{display:none;position:absolute;z-index:13;right:.22rem;top:50%;transform:translateY(-50%);align-items:center;justify-content:center;width:3.25rem;min-height:1.65rem;border:1px solid var(--border);border-radius:999px;background:var(--surface);color:var(--link-strong);padding:.12rem .35rem;font-size:.78rem;font-weight:850;line-height:1;box-shadow:none}.js-enabled .tor-copy-button{display:inline-flex}.tor-copy-button:hover{background:var(--hover);color:var(--text-strong)} nav{display:flex;gap:.35rem;align-items:center;flex-wrap:wrap;justify-content:flex-end}nav a,nav button,.button-link{display:inline-flex;align-items:center;gap:.35rem;min-height:2.15rem;border-radius:7px;padding:.42rem .65rem;color:var(--link-strong);border:1px solid transparent;background:transparent} nav a:hover,nav button:hover,.button-link:hover{background:var(--hover);text-decoration:none}nav form,.actions form{display:inline} nav svg{width:1.05rem;height:1.05rem;fill:currentColor;flex:0 0 auto} @@ -1979,21 +2942,23 @@ nav svg{width:1.05rem;height:1.05rem;fill:currentColor;flex:0 0 auto} main{padding:var(--shell-gap)}.app-shell{width:min(100%,var(--shell-max));margin:0 auto;display:grid;grid-template-columns:var(--shell-side) minmax(0,var(--shell-primary)) var(--shell-side);gap:var(--shell-gap);align-items:start;justify-content:center}.primary-column{min-width:0;width:100%}.left-rail,.right-rail{min-width:0;position:sticky;top:var(--rail-sticky-top);display:grid;gap:.75rem;align-items:start}.side-rail-card,.rail-nav{background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--muted-strong);box-shadow:0 1px 2px var(--shadow)}.side-rail-card{padding:.85rem}.side-rail-card h2{margin:.1rem 0 .6rem;font-size:1rem;color:var(--text)}.rail-nav{display:grid;grid-template-columns:minmax(0,1fr);gap:.2rem;width:100%;padding:.35rem;justify-content:stretch}.rail-nav a,.rail-nav button{width:100%;min-height:2.35rem;justify-content:flex-start;padding:.5rem .65rem}.rail-nav form{display:block}.mobile-nav{display:none}.dashboard-list{display:grid;grid-template-columns:auto minmax(0,1fr);gap:.45rem .75rem;margin:.25rem 0 .85rem}.dashboard-list dt{font-weight:800;color:var(--text)}.dashboard-list dd{margin:0;overflow-wrap:anywhere}.dashboard-account{color:var(--text)}.dashboard-account:hover{text-decoration:none}.dashboard-actions{display:flex;flex-wrap:wrap;gap:.4rem}.site-footer{max-width:var(--shell-max);margin:0 auto;padding:1rem;color:var(--muted);font-size:.9rem} .page-header,.post,.composer,.panel,.empty-state,.notice{background:var(--surface);border:1px solid var(--border);border-radius:8px;margin:0 0 .7rem;padding:.85rem;box-shadow:0 1px 2px var(--shadow)} .page-header h1,.section-heading h1,.panel h1{margin:0;font-size:1.45rem;line-height:1.2}.panel h1+table,.panel h1+form,.panel h1+p,.panel h1+dl{margin-top:.85rem}.page-header p,.muted,.empty-state p{color:var(--muted);margin:.35rem 0 0}.section-heading{display:flex;justify-content:space-between;gap:1rem;align-items:baseline;margin-bottom:.8rem} -.notifications-hero{background:var(--surface);border:1px solid var(--border);border-radius:8px;margin:0 0 .7rem;padding:1rem;box-shadow:0 1px 2px var(--shadow);display:flex;align-items:center;justify-content:space-between;gap:1rem}.notifications-hero h1{margin:0;font-size:1.55rem;line-height:1.15}.notifications-hero p:not(.eyebrow){margin:.35rem 0 0;color:var(--muted-strong)}.caught-up-pill{display:inline-flex;align-items:center;min-height:2rem;border:1px solid var(--success-border);border-radius:999px;background:var(--success-bg);color:var(--text-strong);padding:.32rem .75rem;font-weight:800}.caught-up{padding:.75rem .85rem}.caught-up p{margin:0}.notifications-list{display:grid;gap:.5rem}.notification-group{margin:.8rem .15rem .2rem;color:var(--muted);font-size:.82rem;text-transform:uppercase;letter-spacing:.08em}.notification-row{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:start;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.8rem;box-shadow:0 1px 2px var(--shadow)}.notification-row.unread{border-color:var(--border-strong);background:var(--surface-subtle)}.js-enabled .notification-row[data-card-href]{cursor:pointer}.js-enabled .notification-row[data-card-href]:hover{border-color:var(--border-strong);background:var(--hover)}.notification-kind{display:grid;place-items:center;width:2rem;height:2rem;border-radius:7px;background:var(--surface-muted);color:var(--link-strong);font-weight:900;font-size:.8rem}.notification-row.unread .notification-kind{background:var(--brand);color:var(--brand-text)}.notification-body{min-width:0}.notification-line{margin:0;overflow-wrap:anywhere}.notification-meta{margin:.35rem 0 0;color:var(--muted);font-size:.88rem}.notification-preview{display:block;margin:.5rem 0 0;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle);color:var(--muted-strong);overflow-wrap:anywhere}.notification-preview:hover{background:var(--surface);text-decoration:none}.notification-preview.unavailable{border-style:dashed}.unread-dot{width:.65rem;height:.65rem;border-radius:999px;background:var(--brand);margin-top:.7rem} +.character-counter{display:inline-block;flex:0 0 auto;min-width:8.5rem;text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}.character-counter-normal{color:var(--muted)}.character-counter-warning{color:var(--warning)}.character-counter-danger{color:var(--danger)} +.notifications-hero{background:var(--surface);border:1px solid var(--border);border-radius:8px;margin:0 0 .7rem;padding:1rem;box-shadow:0 1px 2px var(--shadow);display:flex;align-items:center;justify-content:space-between;gap:1rem}.notifications-hero h1{margin:0;font-size:1.55rem;line-height:1.15}.notifications-hero p:not(.eyebrow){margin:.35rem 0 0;color:var(--muted-strong)}.caught-up-pill{display:inline-flex;align-items:center;min-height:2rem;border:1px solid var(--success-border);border-radius:999px;background:var(--success-bg);color:var(--text-strong);padding:.32rem .75rem;font-weight:800}.caught-up{padding:.75rem .85rem}.caught-up p{margin:0}.notifications-list{display:grid;gap:.5rem}.notification-group{margin:.8rem .15rem .2rem;color:var(--muted);font-size:.82rem;text-transform:uppercase;letter-spacing:.08em}.notification-row{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto auto;gap:.75rem;align-items:start;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.8rem;box-shadow:0 1px 2px var(--shadow)}.notification-row.unread{border-color:var(--border-strong);background:var(--surface-subtle)}.js-enabled .notification-row[data-card-href],.js-enabled .notification-row[data-card-form]{cursor:pointer}.js-enabled .notification-row[data-card-href]:hover,.js-enabled .notification-row[data-card-form]:hover{border-color:var(--border-strong);background:var(--hover)}.notification-kind{display:grid;place-items:center;width:2rem;height:2rem;border-radius:7px;background:var(--surface-muted);color:var(--link-strong);font-weight:900;font-size:.8rem}.notification-row.unread .notification-kind{background:var(--brand);color:var(--brand-text)}.notification-body{min-width:0}.notification-line{margin:0;overflow-wrap:anywhere}.notification-meta{margin:.35rem 0 0;color:var(--muted);font-size:.88rem}.notification-counts{color:var(--muted-strong);font-weight:800}.notification-preview{display:block;margin:.5rem 0 0;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle);color:var(--muted-strong);overflow-wrap:anywhere}.notification-preview:hover{background:var(--surface);text-decoration:none}.notification-preview.unavailable{border-style:dashed}.notification-actors{margin:.45rem 0 0}.notification-actors summary{display:inline-flex;align-items:center;min-height:1.7rem;color:var(--link-strong);font-weight:800;cursor:pointer}.notification-actors ul{list-style:none;margin:.25rem 0 0;padding:0;display:flex;flex-wrap:wrap;gap:.35rem}.notification-actors li{border:1px solid var(--border);border-radius:999px;background:var(--surface-subtle);padding:.16rem .5rem;font-size:.88rem}.notification-open-form{display:flex;align-items:flex-start}.notification-open{min-height:2rem;padding:.3rem .55rem;background:var(--surface);border-color:var(--border)}.unread-dot{width:.65rem;height:.65rem;border-radius:999px;background:var(--brand);margin-top:.7rem} label{display:block;font-weight:700;margin:.85rem 0 .35rem}input,textarea,button,select{font:inherit}input[type=text],input[type=search],input[type=password],input[type=url],input:not([type]),textarea,select{width:100%;padding:.72rem .8rem;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);color:var(--text)}textarea{resize:vertical;min-height:7rem}::placeholder{color:var(--muted)} input[type=checkbox]{accent-color:var(--brand)}.check-row,.theme-toggle{display:flex;align-items:center;gap:.55rem;font-weight:700;color:var(--text)}.theme-toggle{padding:.65rem .75rem;border:1px solid var(--border);border-radius:8px;background:var(--surface-subtle)}.theme-toggle input{width:auto} input[type=text].password-visible{padding-right:.8rem}.password-control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.45rem;align-items:center}.password-control input{min-width:0}.password-toggle{display:none;background:var(--surface);color:var(--link-strong);border-color:var(--border);min-width:4.5rem}.js-enabled .password-toggle{display:inline-block}.auth-submit{margin-top:1.15rem}.auth-form{margin-top:.35rem}.auth-form .field-help{margin:.15rem 0 .4rem;color:var(--muted-strong)} .search-panel h1{margin-bottom:.75rem}.search-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center}.search-form input{min-width:0}.search-results{display:grid;gap:.65rem}.section-title{margin:.2rem 0 .65rem;font-size:1.05rem;color:var(--text)}.search-users{margin-bottom:0}.search-users .section-title{margin-top:0}.search-account{grid-template-columns:auto minmax(0,1fr)} -input:focus,textarea:focus,select:focus,button:focus-visible,a:focus-visible{outline:3px solid var(--focus);outline-offset:2px}button,.primary{border:1px solid var(--brand);background:var(--brand);color:var(--brand-text);border-radius:7px;padding:.5rem .8rem;cursor:pointer;font-weight:700}button:hover,.primary:hover{background:var(--brand-hover);text-decoration:none} +input:focus,textarea:focus,select:focus,button:focus-visible,a:focus-visible{outline:3px solid var(--focus);outline-offset:2px}button,.primary{border:1px solid var(--brand);background:var(--brand);color:var(--brand-text);border-radius:7px;padding:.5rem .8rem;cursor:pointer;font-weight:700}button:hover,.primary:hover{background:var(--brand-hover);text-decoration:none}button:disabled,.primary:disabled,button:disabled:hover,.primary:disabled:hover{border-color:var(--border);background:var(--surface-muted);color:var(--muted);cursor:not-allowed;opacity:1} nav button{border-color:transparent;background:transparent;color:var(--link-strong);padding:.42rem .65rem}.rail-nav button{border-color:transparent;background:transparent;color:var(--link-strong);padding:.5rem .65rem}.rail-nav button:hover,.mobile-nav button:hover{background:var(--hover);color:var(--link-strong)} -.composer-surface{border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);overflow:hidden}.composer-surface textarea{border:0;border-radius:0;background:transparent;min-height:7rem;resize:vertical}.composer-surface textarea:focus{outline:0;box-shadow:inset 0 0 0 3px var(--focus)}.composer-footer{display:flex;align-items:center;justify-content:space-between;gap:.55rem;border-top:1px solid var(--border);padding:.42rem .5rem;background:var(--surface-subtle)}.composer-file-control{position:relative;display:inline-flex;align-items:center;gap:.45rem;max-width:100%;margin:0;color:var(--link-strong);font-weight:800}.composer-file-input{max-width:100%;color:var(--muted-strong)}.composer-file-input::file-selector-button{border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:.34rem .58rem;font-weight:800;cursor:pointer}.composer-file-input::file-selector-button:hover{background:var(--hover);color:var(--text-strong)}.composer-file-button{display:none}.js-enabled .composer-file-input{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.js-enabled .composer-file-button{display:inline-flex;align-items:center;gap:.38rem;min-height:2rem;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:.3rem .55rem;cursor:pointer}.composer-file-button svg{width:1rem;height:1rem;fill:currentColor;flex:0 0 auto}.js-enabled .composer-file-control:hover .composer-file-button{background:var(--hover);color:var(--text-strong)}.js-enabled .composer-file-input:focus-visible+.composer-file-button{outline:3px solid var(--focus);outline-offset:2px}.composer-media-selection[hidden]{display:none}.composer-media-selection{display:flex;align-items:center;gap:.7rem;flex-wrap:wrap;border-top:1px solid var(--border);padding:.5rem;background:var(--surface)}.composer-media-summary{font-weight:800;color:var(--muted-strong);overflow-wrap:anywhere}.composer-nsfw{margin:0}.composer-clear-media{background:var(--surface);color:var(--link-strong);border-color:var(--border);padding:.32rem .55rem}.composer-clear-media:hover{background:var(--hover);color:var(--text-strong)}.composer-tools{display:flex;align-items:center;justify-content:space-between;gap:.75rem;margin-top:.85rem} +.composer-surface{position:relative;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);overflow:visible}.composer-surface textarea{border:0;border-radius:0;background:transparent;min-height:7rem;resize:vertical}.composer-surface textarea:focus{outline:0;box-shadow:inset 0 0 0 3px var(--focus)}.mention-menu[hidden]{display:none}.mention-menu{position:absolute;z-index:9;left:.55rem;right:.55rem;top:3.1rem;max-height:12rem;overflow:auto;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);box-shadow:0 8px 24px var(--shadow);padding:.25rem}.mention-option{display:grid;width:100%;grid-template-columns:minmax(0,1fr) auto;gap:.65rem;align-items:center;border:0;border-radius:6px;background:transparent;color:var(--text);padding:.42rem .5rem;text-align:left}.mention-option:hover,.mention-option[aria-selected="true"]{background:var(--hover);color:var(--text-strong)}.mention-name{font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mention-handle{color:var(--muted);font-size:.9rem}.composer-footer{display:flex;align-items:center;justify-content:space-between;gap:.55rem;border-top:1px solid var(--border);padding:.42rem .5rem;background:var(--surface-subtle)}.composer-file-control{position:relative;display:inline-flex;align-items:center;gap:.45rem;max-width:100%;margin:0;color:var(--link-strong);font-weight:800}.composer-file-input{max-width:100%;color:var(--muted-strong)}.composer-file-input::file-selector-button{border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:.34rem .58rem;font-weight:800;cursor:pointer}.composer-file-input::file-selector-button:hover{background:var(--hover);color:var(--text-strong)}.composer-file-button{display:none}.js-enabled .composer-file-input{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.js-enabled .composer-file-button{display:inline-flex;align-items:center;gap:.38rem;min-height:2rem;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:.3rem .55rem;cursor:pointer}.composer-file-button svg{width:1rem;height:1rem;fill:currentColor;flex:0 0 auto}.js-enabled .composer-file-control:hover .composer-file-button{background:var(--hover);color:var(--text-strong)}.js-enabled .composer-file-input:focus-visible+.composer-file-button{outline:3px solid var(--focus);outline-offset:2px}.composer-media-selection[hidden]{display:none}.composer-media-selection{display:flex;align-items:center;gap:.7rem;flex-wrap:wrap;border-top:1px solid var(--border);padding:.5rem;background:var(--surface)}.composer-media-summary{font-weight:800;color:var(--muted-strong);overflow-wrap:anywhere}.composer-nsfw{margin:0}.composer-clear-media{background:var(--surface);color:var(--link-strong);border-color:var(--border);padding:.32rem .55rem}.composer-clear-media:hover{background:var(--hover);color:var(--text-strong)}.composer-tools{display:flex;align-items:center;justify-content:space-between;gap:.75rem;margin-top:.85rem} .thread-nav{display:flex;margin:0 0 .45rem .85rem}.thread-back{width:2rem;height:2rem;display:inline-flex;align-items:center;justify-content:center;border-radius:999px;color:var(--link-strong)}.thread-back svg{width:1.2rem;height:1.2rem;fill:currentColor}.thread-back:hover{background:var(--hover);text-decoration:none} -.timeline{display:grid;gap:.65rem}.post{overflow:hidden;position:relative}.js-enabled .post[data-card-href]{cursor:pointer}.js-enabled .post[data-card-href]:hover{border-color:var(--border-strong)}.reply-post{margin-left:1.1rem;border-left:4px solid var(--reply-border);background:var(--surface-subtle)}.reply-post::before{content:"";position:absolute;left:-1.1rem;top:1.25rem;width:1.1rem;border-top:2px solid var(--reply-border)}.anchor-target{position:absolute;top:-5rem}.post-header{display:flex;justify-content:space-between;gap:.65rem;align-items:flex-start}.author-block{display:flex;gap:.55rem;align-items:center;min-width:0}.post-avatar{width:2rem;height:2rem;object-fit:cover;border-radius:999px;border:1px solid var(--border);background:var(--avatar-bg);flex:0 0 auto;margin:0}.post-avatar.placeholder{display:inline-grid;place-items:center;color:var(--muted-strong);font-weight:800}.author-name{font-weight:800;color:var(--text-strong)}.username,.post-time,.counts{color:var(--muted);font-size:.92rem}.text{white-space:pre-wrap;margin:.55rem 0;line-height:1.5;overflow-wrap:anywhere}.post img,.post video{display:block;max-width:100%;border-radius:8px;border:1px solid var(--border);margin-top:.5rem;background:var(--media-bg)}.post img.post-avatar{display:block;margin:0;border-radius:999px}.youtube-previews{display:grid;gap:.45rem;margin:.35rem 0 .55rem}.youtube-preview-card{display:grid;grid-template-columns:minmax(5.5rem,7.5rem) minmax(0,1fr);gap:.65rem;align-items:center;min-height:4.5rem;border:1px solid var(--border);border-radius:7px;background:var(--surface-subtle);color:var(--text);overflow:hidden}.youtube-preview-card:hover{border-color:var(--border-strong);background:var(--hover);text-decoration:none}.youtube-thumbnail-frame{position:relative;display:block;width:100%;aspect-ratio:16/9;overflow:hidden;background:var(--media-bg)}.post img.youtube-thumbnail{width:100%;height:100%;object-fit:cover;margin:0;border:0;border-radius:0}.youtube-play{position:absolute;left:50%;top:50%;width:2rem;height:2rem;border-radius:999px;background:rgba(0,0,0,.68);transform:translate(-50%,-50%)}.youtube-play::before{content:"";position:absolute;left:.78rem;top:.55rem;border-top:.45rem solid transparent;border-bottom:.45rem solid transparent;border-left:.65rem solid #fff}.youtube-preview-body{display:grid;gap:.08rem;min-width:0;padding:.45rem .55rem .45rem 0}.youtube-preview-source{color:var(--muted);font-size:.78rem;font-weight:900;text-transform:uppercase}.youtube-preview-title{color:var(--text-strong);font-weight:850;overflow-wrap:anywhere}.youtube-preview-url{color:var(--muted-strong);font-size:.86rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nsfw-media{position:relative;margin-top:.5rem}.nsfw-media .post img,.nsfw-media .post video{margin-top:0}.nsfw-media-frame{position:relative;display:block;overflow:hidden;border-radius:8px}.nsfw-media-frame img,.nsfw-media-frame video{margin-top:0;filter:blur(24px);transform:scale(1.02)}.nsfw-toggle:checked+.nsfw-media-frame img,.nsfw-toggle:checked+.nsfw-media-frame video{filter:none;transform:none}.nsfw-badge{position:absolute;left:.55rem;bottom:.55rem;border-radius:999px;padding:.18rem .5rem;background:rgba(0,0,0,.72);color:#fff;font-size:.78rem;font-weight:900;letter-spacing:.03em}.nsfw-show{position:absolute;right:.55rem;bottom:.55rem;margin:0;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);color:var(--text-strong);padding:.32rem .65rem;font-weight:900;box-shadow:0 1px 2px var(--shadow);cursor:pointer}.nsfw-show:hover{background:var(--hover)}.nsfw-toggle:focus-visible~.nsfw-show{outline:3px solid var(--focus);outline-offset:2px}.nsfw-toggle:checked~.nsfw-show,.nsfw-toggle:checked+.nsfw-media-frame .nsfw-badge{display:none}.nsfw-open{display:block;margin:.35rem 0 0;font-size:.9rem;font-weight:700} -.counts{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.3rem;min-height:1.4rem}.post-permalink{font-weight:700;color:var(--link-strong)}.js-enabled .post-permalink{display:none}.actions{display:inline-flex;gap:.25rem;flex-wrap:wrap;align-items:center;margin-top:.5rem;max-width:100%}.icon-button{width:2.2rem;height:2.2rem;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:0}.icon-button svg{width:1.05rem;height:1.05rem;fill:currentColor}.icon-button:hover,.icon-button.active{background:var(--hover);color:var(--text-strong);text-decoration:none}.icon-button.disabled,.icon-button:disabled{color:var(--muted);background:var(--surface-muted);border-color:var(--border);cursor:not-allowed;opacity:.75}.icon-button.disabled:hover,.icon-button:disabled:hover{background:var(--surface-muted);color:var(--muted)}.admin-nsfw-button{min-height:2.2rem;padding:.3rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border);font-size:.86rem}.admin-nsfw-button:hover{background:var(--hover);color:var(--text-strong)}.repost-control{position:relative;display:inline-flex;align-items:center;gap:.25rem}.repost-menu{position:absolute;z-index:8;left:0;top:calc(100% + .25rem);min-width:8.5rem;padding:.3rem;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);box-shadow:0 6px 18px var(--shadow)}.repost-menu a{display:inline-flex;align-items:center;gap:.35rem;width:100%;min-height:2rem;border-radius:6px;padding:.32rem .55rem;color:var(--link-strong);font-weight:700}.repost-menu a svg{width:1rem;height:1rem;fill:currentColor;flex:0 0 auto}.repost-menu a:hover,.quote-fallback:hover{background:var(--hover);text-decoration:none}.quote-preview{display:block;margin:.6rem 0 .25rem;border:1px solid var(--border);border-radius:7px;background:var(--surface-subtle);overflow:hidden}.quote-preview p{margin:.65rem;color:var(--muted-strong)}.quote-link{display:grid;gap:.2rem;padding:.6rem;color:var(--text)}.quote-link:hover{background:var(--hover);text-decoration:none}.quote-author{font-weight:800}.quote-text{white-space:pre-wrap;overflow-wrap:anywhere}.quote-time{color:var(--muted);font-size:.86rem}.follow-button{min-width:6.6rem}.follow-button.active{background:var(--hover);color:var(--text-strong);border-color:var(--border-strong)}.profile-actions{margin-top:0}.profile-secondary button{background:var(--surface);color:var(--danger);border-color:var(--danger-border);padding:.32rem .5rem;min-height:1.85rem;font-size:.86rem}.profile-secondary button:hover{background:var(--danger-bg);color:var(--danger-strong)}.profile-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:.75rem}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.repost-banner{color:var(--muted-strong);font-size:.9rem;font-weight:800;margin-bottom:.35rem}.unavailable{color:var(--muted)}.empty-state{text-align:center;padding:2rem 1rem}.empty-state h2{margin:0;font-size:1.2rem}.notice.error,.error-panel{border-color:var(--danger-border);background:var(--danger-bg)}.notice.success{border-color:var(--success-border);background:var(--success-bg)}.eyebrow{text-transform:uppercase;letter-spacing:.08em;font-weight:800;color:var(--muted);font-size:.78rem}.noscript-banner{max-width:1100px;margin:.7rem auto 0;padding:.65rem .85rem;border:1px solid var(--border);border-radius:8px;background:var(--surface-subtle);color:var(--muted-strong)}.js-enabled .noscript-banner{display:none} -.profile-banner{width:100%;max-height:220px;object-fit:cover;border-radius:8px;border:1px solid var(--border);background:var(--surface-muted)}.profile-heading{display:flex;gap:1rem;align-items:flex-start;margin-top:.85rem}.profile-main{min-width:0;flex:1}.profile-picture{width:88px;height:88px;object-fit:cover;border-radius:999px;border:3px solid var(--surface);background:var(--avatar-bg);flex:0 0 auto}.profile-meta{color:var(--muted-strong);margin:.45rem 0 0}.settings-profile-editor{padding:0;overflow:hidden}.settings-editor-bar{display:flex;justify-content:space-between;align-items:center;gap:1rem;padding:.85rem;border-bottom:1px solid var(--border)}.settings-editor-bar h1{margin:0}.settings-editor-bar .primary{flex:0 0 auto}.settings-profile-form{padding:0 .85rem .85rem}.settings-profile-media{margin:0 -.85rem .85rem}.settings-banner-wrap{background:var(--media-bg)}.settings-banner-preview{display:block;width:100%;height:220px;object-fit:cover;background:linear-gradient(135deg,var(--surface-muted),var(--hover));border:0;border-radius:0}.settings-banner-preview.placeholder::before{content:"";display:block;width:100%;height:100%}.settings-picture-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:end;padding:0 .85rem .85rem;margin-top:-48px}.settings-picture-preview{width:112px;height:112px;object-fit:cover;border-radius:999px;border:5px solid var(--surface);background:var(--avatar-bg);box-shadow:0 1px 4px var(--shadow)}.settings-picture-preview.placeholder{display:block}.settings-media-controls{display:grid;gap:.5rem;align-content:end;padding-top:3.25rem}.media-control-row{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.settings-fields{display:grid;gap:.25rem}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-panel{padding:0;overflow:hidden}.deep-settings-form{padding:.85rem;display:grid;gap:.85rem}.deep-settings-group{border:1px solid var(--border);border-radius:8px;padding:.8rem;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-group legend{font-weight:800;padding:0 .35rem}.deep-settings-field{display:grid;gap:.25rem;align-content:start}.deep-settings-field label{font-weight:800}.deep-settings-field input,.deep-settings-field select{min-width:0}.field-help{font-size:.88rem}.deep-settings-confirm .settings-item-list li{display:block}.compact-panel h2,.danger-panel h2{margin:0 0 .65rem;font-size:1.1rem}.inline-settings-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center;margin:.2rem 0 .75rem}.inline-settings-form input{min-width:0}.settings-password-form button[type=submit]{margin-top:.9rem}.settings-item-list{list-style:none;margin:.25rem 0 0;padding:0;display:grid;gap:.45rem}.settings-item-list li{display:flex;justify-content:space-between;align-items:center;gap:.75rem;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle)}.settings-item-list form{flex:0 0 auto}.settings-item-list button{padding:.32rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border)}.compact-empty{border:1px dashed var(--border);border-radius:7px;padding:.75rem;background:var(--surface-subtle);color:var(--muted-strong)}.compact-empty p{margin:.25rem 0 0}.danger-panel{border-color:var(--danger-border);background:var(--danger-bg)}.danger,.danger-link{border-color:var(--danger-border);background:var(--danger);color:var(--brand-text)}.danger:hover,.danger-link:hover{background:var(--danger-strong);color:var(--brand-text);text-decoration:none}.delete-account-panel p{max-width:62ch}.favicon-preview{width:32px;height:32px;object-fit:contain;border:1px solid var(--border);border-radius:6px;background:var(--surface)}.admin-user-search{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;gap:.65rem;align-items:end;margin-top:.75rem}.admin-user-search label{margin-top:0}.admin-user-search-actions{display:flex;gap:.4rem;align-items:center;margin-bottom:.05rem}.admin-user-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.75rem;border:1px solid var(--border);border-radius:8px;padding:.75rem;margin-top:.65rem;background:var(--surface-subtle)}.admin-user-heading{overflow-wrap:anywhere}.admin-user-statuses,.admin-user-matches{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.45rem}.admin-user-pill,.admin-user-match{display:inline-flex;align-items:center;min-height:1.55rem;border:1px solid var(--border);border-radius:999px;padding:.15rem .5rem;background:var(--surface);font-size:.82rem;font-weight:800;color:var(--muted-strong)}.admin-user-match{border-color:var(--success-border);background:var(--success-bg);color:var(--text)}.admin-user-meta{margin:.6rem 0 0}.admin-post-preview{margin:.55rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.admin-user-actions{display:flex;align-items:flex-start}.admin-users-empty{margin-top:.75rem}.account-list{display:grid;gap:.65rem}.account-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.85rem}.account-row p{margin:.3rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.85rem}.item-list{margin:.75rem 0 0;padding-left:1.2rem}.item-list li{margin:.45rem 0}.panel dl:not(.dashboard-list){display:grid;grid-template-columns:max-content minmax(0,1fr);gap:.45rem .85rem}.panel dl:not(.dashboard-list) dt{font-weight:800}.panel dl:not(.dashboard-list) dd{margin:0;overflow-wrap:anywhere}table{width:100%;border-collapse:collapse}td,th{border-bottom:1px solid var(--border);text-align:left;padding:.55rem;vertical-align:top}pre{white-space:pre-wrap;overflow:auto;max-width:100%} +.timeline{display:grid;gap:.65rem}.post{overflow:hidden;position:relative}.js-enabled .post[data-card-href]{cursor:pointer}.js-enabled .post[data-card-href]:hover{border-color:var(--border-strong)}.reply-post{margin-left:1.1rem;border-left:4px solid var(--reply-border);background:var(--surface-subtle)}.reply-post::before{content:"";position:absolute;left:-1.1rem;top:1.25rem;width:1.1rem;border-top:2px solid var(--reply-border)}.anchor-target{position:absolute;top:-5rem}.post-header{display:flex;justify-content:space-between;gap:.65rem;align-items:flex-start}.author-block{display:flex;gap:.55rem;align-items:center;min-width:0}.post-avatar{width:2rem;height:2rem;object-fit:cover;border-radius:999px;border:1px solid var(--border);background:var(--avatar-bg);flex:0 0 auto;margin:0}.post-avatar.placeholder{display:inline-grid;place-items:center;color:var(--muted-strong);font-weight:800}.author-name{font-weight:800;color:var(--text-strong)}.username,.post-time,.counts{color:var(--muted);font-size:.92rem}.text{white-space:pre-wrap;margin:.55rem 0;line-height:1.5;overflow-wrap:anywhere}.post img,.post video{display:block;max-width:100%;border-radius:8px;border:1px solid var(--border);margin-top:.5rem;background:var(--media-bg)}.post img.post-avatar{display:block;margin:0;border-radius:999px}.youtube-previews{display:grid;gap:.45rem;margin:.35rem 0 .55rem}.youtube-preview-card{display:grid;grid-template-columns:minmax(5.5rem,7.5rem) minmax(0,1fr);gap:.65rem;align-items:center;min-height:4.5rem;border:1px solid var(--border);border-radius:7px;background:var(--surface-subtle);color:var(--text);overflow:hidden}.youtube-preview-card:hover{border-color:var(--border-strong);background:var(--hover);text-decoration:none}.youtube-thumbnail-frame{position:relative;display:block;width:100%;aspect-ratio:16/9;overflow:hidden;background:var(--media-bg)}.post img.youtube-thumbnail{width:100%;height:100%;object-fit:cover;margin:0;border:0;border-radius:0}.youtube-play{position:absolute;left:50%;top:50%;width:2rem;height:2rem;border-radius:999px;background:rgba(0,0,0,.68);transform:translate(-50%,-50%)}.youtube-play::before{content:"";position:absolute;left:.78rem;top:.55rem;border-top:.45rem solid transparent;border-bottom:.45rem solid transparent;border-left:.65rem solid #fff}.youtube-preview-body{display:grid;gap:.08rem;min-width:0;padding:.45rem .55rem .45rem 0}.youtube-preview-source{color:var(--muted);font-size:.78rem;font-weight:900;text-transform:uppercase}.youtube-preview-title{color:var(--text-strong);font-weight:850;overflow-wrap:anywhere}.youtube-preview-url{color:var(--muted-strong);font-size:.86rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nsfw-media{position:relative;margin-top:.5rem}.nsfw-media .post img,.nsfw-media .post video{margin-top:0}.nsfw-media-frame{position:relative;display:block;overflow:hidden;border-radius:8px}.nsfw-media-frame img,.nsfw-media-frame video{margin-top:0;filter:blur(24px);transform:scale(1.02)}.nsfw-toggle:checked+.nsfw-media-frame img,.nsfw-toggle:checked+.nsfw-media-frame video{filter:none;transform:none}.nsfw-badge{position:absolute;left:.55rem;bottom:.55rem;border-radius:999px;padding:.18rem .5rem;background:rgba(0,0,0,.72);color:#fff;font-size:.78rem;font-weight:900;letter-spacing:.03em}.nsfw-show{position:absolute;right:.55rem;bottom:.55rem;margin:0;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);color:var(--text-strong);padding:.32rem .65rem;font-weight:900;box-shadow:0 1px 2px var(--shadow);cursor:pointer}.nsfw-show:hover{background:var(--hover)}.nsfw-toggle:focus-visible~.nsfw-show{outline:3px solid var(--focus);outline-offset:2px}.nsfw-toggle:checked~.nsfw-show,.nsfw-toggle:checked+.nsfw-media-frame .nsfw-badge{display:none} +.counts{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.3rem;min-height:1.4rem}.edited-marker{font-weight:800;color:var(--muted-strong)}.post-permalink{font-weight:700;color:var(--link-strong)}.js-enabled .post-permalink{display:none}.actions{display:inline-flex;gap:.25rem;flex-wrap:wrap;align-items:center;margin-top:.5rem;max-width:100%}.icon-button{width:2.2rem;height:2.2rem;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:0}.icon-button svg{width:1.05rem;height:1.05rem;fill:currentColor}.icon-button:hover,.icon-button.active{background:var(--hover);color:var(--text-strong);text-decoration:none}.icon-button.disabled,.icon-button:disabled{color:var(--muted);background:var(--surface-muted);border-color:var(--border);cursor:not-allowed;opacity:.75}.icon-button.disabled:hover,.icon-button:disabled:hover{background:var(--surface-muted);color:var(--muted)}.admin-nsfw-button{min-height:2.2rem;padding:.3rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border);font-size:.86rem}.admin-nsfw-button:hover{background:var(--hover);color:var(--text-strong)}.repost-control{position:relative;display:inline-flex;align-items:center;gap:.25rem}.repost-menu{position:absolute;z-index:8;left:0;top:calc(100% + .25rem);min-width:8.5rem;padding:.3rem;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);box-shadow:0 6px 18px var(--shadow)}.repost-menu a{display:inline-flex;align-items:center;gap:.35rem;width:100%;min-height:2rem;border-radius:6px;padding:.32rem .55rem;color:var(--link-strong);font-weight:700}.repost-menu a svg{width:1rem;height:1rem;fill:currentColor;flex:0 0 auto}.repost-menu a:hover,.quote-fallback:hover{background:var(--hover);text-decoration:none}.quote-preview{display:block;margin:.6rem 0 .25rem;border:1px solid var(--border);border-radius:7px;background:var(--surface-subtle);overflow:hidden}.quote-preview p{margin:.65rem;color:var(--muted-strong)}.quote-link{display:grid;gap:.2rem;padding:.6rem;color:var(--text)}.quote-link:hover{background:var(--hover);text-decoration:none}.quote-author{font-weight:800}.quote-text{white-space:pre-wrap;overflow-wrap:anywhere}.quote-time{color:var(--muted);font-size:.86rem}.follow-button{min-width:6.6rem}.follow-button.active{background:var(--hover);color:var(--text-strong);border-color:var(--border-strong)}.profile-actions{margin-top:0}.profile-secondary button{background:var(--surface);color:var(--danger);border-color:var(--danger-border);padding:.32rem .5rem;min-height:1.85rem;font-size:.86rem}.profile-secondary button:hover{background:var(--danger-bg);color:var(--danger-strong)}.profile-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:.75rem}.profile-tabs{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.35rem;margin:.65rem 0;border-bottom:1px solid var(--border)}.profile-tabs a{display:flex;align-items:center;justify-content:center;min-height:2.6rem;border-radius:7px 7px 0 0;color:var(--muted-strong);font-weight:850}.profile-tabs a:hover{background:var(--hover);color:var(--text-strong);text-decoration:none}.profile-tabs a.active{color:var(--text-strong);background:var(--surface);box-shadow:inset 0 -3px 0 var(--brand)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.repost-banner{color:var(--muted-strong);font-size:.9rem;font-weight:800;margin-bottom:.35rem}.unavailable{color:var(--muted)}.empty-state{text-align:center;padding:2rem 1rem}.empty-state h2{margin:0;font-size:1.2rem}.notice.error,.error-panel{border-color:var(--danger-border);background:var(--danger-bg)}.notice.success{border-color:var(--success-border);background:var(--success-bg)}.eyebrow{text-transform:uppercase;letter-spacing:.08em;font-weight:800;color:var(--muted);font-size:.78rem}.noscript-banner{max-width:1100px;margin:.7rem auto 0;padding:.65rem .85rem;border:1px solid var(--border);border-radius:8px;background:var(--surface-subtle);color:var(--muted-strong)} +.profile-banner{width:100%;max-height:220px;object-fit:cover;border-radius:8px;border:1px solid var(--border);background:var(--surface-muted)}.profile-heading{display:flex;gap:1rem;align-items:flex-start;margin-top:.85rem}.profile-main{min-width:0;flex:1}.profile-picture{width:88px;height:88px;object-fit:cover;border-radius:999px;border:3px solid var(--surface);background:var(--avatar-bg);flex:0 0 auto}.profile-meta{color:var(--muted-strong);margin:.45rem 0 0}.settings-profile-editor{padding:0;overflow:hidden}.settings-editor-bar{display:flex;justify-content:space-between;align-items:center;gap:1rem;padding:.85rem;border-bottom:1px solid var(--border)}.settings-editor-bar h1{margin:0}.settings-editor-bar .primary{flex:0 0 auto}.settings-profile-form{padding:.85rem;display:grid;gap:1rem}.settings-section{display:grid;gap:.75rem;min-width:0}.settings-section+.settings-section{border-top:1px solid var(--border);padding-top:1rem}.settings-section-heading{display:grid;gap:.18rem}.settings-section-heading h2,.settings-list-panel h2,.settings-security-panel h2,.danger-panel h2{margin:0;font-size:1.05rem;line-height:1.25;color:var(--text-strong)}.settings-section-help,.settings-switch-help{margin:0;color:var(--muted-strong);font-size:.9rem;line-height:1.35;overflow-wrap:anywhere}.settings-profile-fields{gap:.28rem}.settings-profile-fields label,.settings-password-form label{margin:.55rem 0 .28rem}.settings-profile-media{display:grid;gap:.65rem;min-width:0}.settings-banner-wrap{background:var(--media-bg);border-radius:8px;overflow:hidden}.settings-banner-preview{display:block;width:100%;height:170px;object-fit:cover;object-position:left center;background:linear-gradient(135deg,var(--surface-muted),var(--hover));border:1px solid var(--border);border-radius:8px}.settings-banner-preview.placeholder::before{content:"";display:block;width:100%;height:100%}.settings-picture-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:end;margin-top:-42px;padding:0 .75rem .2rem}.settings-picture-preview{width:104px;height:104px;object-fit:cover;border-radius:999px;border:5px solid var(--surface);background:var(--avatar-bg);box-shadow:0 1px 4px var(--shadow)}.settings-picture-preview.placeholder{display:block}.settings-media-controls{display:grid;gap:.45rem;align-content:end;padding-top:2.85rem;min-width:0}.media-control-row{display:flex;align-items:center;gap:.65rem;flex-wrap:wrap;min-width:0}.media-control-row .file-control{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap;margin:0;max-width:100%;overflow-wrap:anywhere}.media-control-row .file-control input[type=file]{max-width:100%}.media-control-row .check-row{margin:0}.settings-switch-list{display:grid;gap:0;border:1px solid var(--border);border-radius:8px;background:var(--surface-subtle);overflow:hidden}.settings-switch-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.85rem;align-items:center;margin:0;padding:.75rem .8rem;color:var(--text);cursor:pointer}.settings-switch-row+.settings-switch-row{border-top:1px solid var(--border)}.settings-switch-copy{display:grid;gap:.12rem;min-width:0}.settings-switch-label{font-weight:800;color:var(--text-strong);overflow-wrap:anywhere}.settings-switch-toggle{position:relative;display:inline-grid;width:2.75rem;height:1.55rem;justify-self:end;flex:0 0 auto}.settings-switch-input{position:absolute;inset:0;z-index:1;width:100%;height:100%;padding:0;margin:0;opacity:0;cursor:pointer}.settings-switch-control{position:relative;display:block;width:2.75rem;height:1.55rem;border:1px solid var(--border-strong);border-radius:999px;background:var(--surface-muted);box-shadow:inset 0 0 0 1px var(--shadow);transition:background-color .15s ease,border-color .15s ease}.settings-switch-control::before{content:"";position:absolute;left:.16rem;top:50%;width:1.15rem;height:1.15rem;border-radius:999px;background:var(--surface);box-shadow:0 1px 3px var(--shadow);transform:translateY(-50%);transition:transform .15s ease}.settings-switch-input:checked+.settings-switch-control{border-color:var(--brand);background:var(--brand)}.settings-switch-input:checked+.settings-switch-control::before{transform:translate(1.2rem,-50%)}.settings-switch-input:focus-visible+.settings-switch-control{outline:3px solid var(--focus);outline-offset:2px}.settings-form-actions{display:flex;justify-content:flex-end;gap:.5rem;border-top:1px solid var(--border);padding-top:.85rem}.onboarding-panel{overflow:hidden}.onboarding-form{display:grid;gap:.9rem}.onboarding-media-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:center}.onboarding-suggestions{border:1px solid var(--border);border-radius:8px;padding:.75rem;display:grid;gap:.55rem}.onboarding-suggestions legend{font-weight:800;padding:0 .35rem}.onboarding-suggestion{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:.6rem;align-items:center;margin:0;border:1px solid var(--border);border-radius:7px;padding:.55rem;background:var(--surface-subtle);cursor:pointer}.onboarding-suggestion input{margin:0}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-panel{padding:0;overflow:hidden}.deep-settings-form{padding:.85rem;display:grid;gap:.85rem}.deep-settings-group{border:1px solid var(--border);border-radius:8px;padding:.8rem;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-group legend{font-weight:800;padding:0 .35rem}.deep-settings-field{display:grid;gap:.25rem;align-content:start}.deep-settings-field label{font-weight:800}.deep-settings-field input,.deep-settings-field select{min-width:0}.field-help{font-size:.88rem;margin:.05rem 0 .35rem;color:var(--muted-strong)}.deep-settings-confirm .settings-item-list li{display:block}.compact-panel h2,.danger-panel h2{margin:0;font-size:1.05rem}.inline-settings-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center;margin:.65rem 0 .75rem}.inline-settings-form input{min-width:0}.settings-password-form{display:grid;gap:0;margin-top:.65rem}.settings-password-form button[type=submit]{margin-top:0}.settings-list-panel,.settings-security-panel{display:grid;gap:.55rem}.settings-item-list{list-style:none;margin:.25rem 0 0;padding:0;display:grid;gap:.45rem}.settings-item-list li{display:flex;justify-content:space-between;align-items:center;gap:.75rem;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle)}.settings-item-list li>span{min-width:0;overflow-wrap:anywhere}.settings-item-list form{flex:0 0 auto}.settings-item-list button{padding:.32rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border)}.compact-empty{border:1px dashed var(--border);border-radius:7px;padding:.75rem;background:var(--surface-subtle);color:var(--muted-strong)}.compact-empty p{margin:.25rem 0 0}.danger-panel{border-color:var(--danger-border);background:var(--danger-bg)}.danger,.danger-link{border-color:var(--danger-border);background:var(--danger);color:var(--brand-text)}.danger:hover,.danger-link:hover{background:var(--danger-strong);color:var(--brand-text);text-decoration:none}.delete-account-panel p,.danger-panel p{max-width:62ch}.settings-danger-action{margin:.7rem 0 0}.favicon-preview{width:32px;height:32px;object-fit:contain;border:1px solid var(--border);border-radius:6px;background:var(--surface)}.admin-user-search{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;gap:.65rem;align-items:end;margin-top:.75rem}.admin-user-search label{margin-top:0}.admin-user-search-actions{display:flex;gap:.4rem;align-items:center;margin-bottom:.05rem}.admin-user-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.75rem;border:1px solid var(--border);border-radius:8px;padding:.75rem;margin-top:.65rem;background:var(--surface-subtle)}.admin-user-heading{overflow-wrap:anywhere}.admin-user-statuses,.admin-user-matches{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.45rem}.admin-user-pill,.admin-user-match{display:inline-flex;align-items:center;min-height:1.55rem;border:1px solid var(--border);border-radius:999px;padding:.15rem .5rem;background:var(--surface);font-size:.82rem;font-weight:800;color:var(--muted-strong)}.admin-user-match{border-color:var(--success-border);background:var(--success-bg);color:var(--text)}.admin-user-meta{margin:.6rem 0 0}.admin-post-preview{margin:.55rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.admin-user-actions{display:flex;align-items:flex-start}.admin-users-empty{margin-top:.75rem}.account-list{display:grid;gap:.65rem}.account-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.85rem}.account-row p{margin:.3rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.85rem}.item-list{margin:.75rem 0 0;padding-left:1.2rem}.item-list li{margin:.45rem 0}.panel dl:not(.dashboard-list){display:grid;grid-template-columns:max-content minmax(0,1fr);gap:.45rem .85rem}.panel dl:not(.dashboard-list) dt{font-weight:800}.panel dl:not(.dashboard-list) dd{margin:0;overflow-wrap:anywhere}table{width:100%;border-collapse:collapse}td,th{border-bottom:1px solid var(--border);text-align:left;padding:.55rem;vertical-align:top}pre{white-space:pre-wrap;overflow:auto;max-width:100%} +.settings-media-frame{position:relative;min-width:0}.settings-picture-row{display:flex;align-items:flex-end;gap:0}.settings-picture-wrap{display:inline-block;max-width:100%;line-height:0}.settings-picture-preview{display:block}.settings-media-actions{position:absolute;z-index:2;display:flex;gap:.35rem;align-items:center}.settings-banner-actions{top:.55rem;right:.55rem}.settings-picture-actions{left:50%;bottom:.45rem;transform:translateX(-50%)}.settings-media-control{position:relative;display:inline-flex}.settings-media-input,.settings-media-delete-input{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.settings-media-icon-button{display:inline-flex;align-items:center;justify-content:center;width:2rem;height:2rem;margin:0;border:1px solid rgba(255,255,255,.62);border-radius:999px;background:rgba(23,32,23,.56);color:#fff;padding:0;box-shadow:0 1px 4px rgba(0,0,0,.22);cursor:pointer;opacity:.72;transition:opacity .15s ease,background-color .15s ease,border-color .15s ease,transform .15s ease}.settings-media-icon-button svg{width:1rem;height:1rem;fill:currentColor}.settings-media-frame:hover .settings-media-icon-button,.settings-media-frame:focus-within .settings-media-icon-button,.settings-media-icon-button:hover{opacity:1}.settings-media-icon-button:hover{background:rgba(23,32,23,.82);text-decoration:none}.settings-media-input:focus-visible+.settings-media-icon-button,.settings-media-delete-input:focus-visible+.settings-media-icon-button{outline:3px solid var(--focus);outline-offset:2px;opacity:1}.settings-media-delete-input:checked+.settings-media-icon-button,.settings-media-removing .settings-media-remove{background:var(--danger);border-color:var(--danger-border);color:var(--brand-text);opacity:1}.settings-media-has-file .settings-media-change{background:var(--brand);border-color:rgba(255,255,255,.72);color:var(--brand-text);opacity:1}.settings-media-disabled{position:absolute;right:.55rem;bottom:.55rem;max-width:calc(100% - 1.1rem);margin:0;border:1px solid rgba(255,255,255,.5);border-radius:999px;background:rgba(23,32,23,.62);color:#fff;padding:.22rem .55rem;font-size:.82rem;font-weight:800;line-height:1.2;overflow-wrap:anywhere} @media (max-width:1100px){.app-shell{--shell-side:220px;--shell-max:880px;grid-template-columns:var(--shell-side) minmax(0,var(--shell-primary))}.right-rail{display:none}} @media (max-width:820px){.app-shell{grid-template-columns:minmax(0,680px)}.left-rail,.right-rail{display:none}.mobile-nav{display:flex}} -@media (max-width:600px){main{padding:.75rem}.header-inner{align-items:flex-start;flex-direction:column}.header-brand-row{align-items:center;width:100%;gap:.55rem}.tor-indicator{max-width:calc(100% - 7rem);margin-left:auto}.tor-details{left:auto;right:0;max-width:calc(100vw - 1.5rem)}.site-header{position:static}nav{justify-content:flex-start}.mobile-nav{width:100%}.search-form,.inline-settings-form,.settings-grid,.deep-settings-group,.admin-user-search,.admin-user-row{grid-template-columns:1fr}.search-form button,.inline-settings-form button{width:100%}.composer-tools,.post-header,.profile-heading,.profile-title-row,.account-row,.settings-editor-bar,.notifications-hero{align-items:stretch;grid-template-columns:1fr;flex-direction:column}.composer-footer,.composer-media-selection{align-items:flex-start;flex-direction:column}.composer-file-input{max-width:100%}.settings-banner-preview{height:150px}.settings-picture-row{grid-template-columns:1fr;margin-top:-38px;gap:.5rem}.settings-picture-preview{width:92px;height:92px}.settings-media-controls{padding-top:0}.media-control-row{align-items:flex-start}.settings-item-list li{align-items:stretch;flex-direction:column}.admin-user-search-actions,.admin-user-actions{align-items:stretch;flex-direction:column}.admin-user-search-actions button,.admin-user-search-actions .button-link,.admin-user-actions button{width:100%;justify-content:center}.panel dl:not(.dashboard-list){grid-template-columns:1fr}table{display:block;max-width:100%;overflow-x:auto}.author-block{align-items:flex-start}.reply-post{margin-left:.65rem;padding-left:.8rem}.reply-post::before{left:-.65rem;width:.65rem}.button-link{padding:.42rem .55rem}.counts{gap:.45rem}.page-header h1,.section-heading h1,.panel h1,.notifications-hero h1{font-size:1.25rem}.notification-row{grid-template-columns:auto minmax(0,1fr);gap:.6rem}.unread-dot{position:absolute;right:.75rem;top:.75rem;margin:0}.notification-preview{padding:.5rem}} +@media (max-width:600px){main{padding:.75rem}.header-inner{align-items:flex-start;flex-direction:column}.header-brand-row{align-items:center;width:100%;gap:.55rem}.tor-indicator{max-width:calc(100% - 7rem);margin-left:auto}.tor-details{left:auto;right:0;max-width:calc(100vw - 1.5rem)}.site-header{position:static}nav{justify-content:flex-start}.mobile-nav{width:100%}.search-form,.inline-settings-form,.settings-grid,.deep-settings-group,.admin-user-search,.admin-user-row,.onboarding-media-row{grid-template-columns:1fr}.search-form button,.inline-settings-form button{width:100%}.composer-tools,.post-header,.profile-heading,.profile-title-row,.account-row,.settings-editor-bar,.notifications-hero{align-items:stretch;grid-template-columns:1fr;flex-direction:column}.composer-footer,.composer-media-selection{align-items:flex-start;flex-direction:column}.composer-file-input{max-width:100%}.settings-banner-preview{height:150px}.settings-picture-row{grid-template-columns:1fr;margin-top:-38px;gap:.5rem}.settings-picture-preview{width:92px;height:92px}.settings-media-controls{padding-top:0}.media-control-row{align-items:flex-start}.settings-switch-row{grid-template-columns:1fr;gap:.55rem}.settings-switch-toggle,.settings-switch-control{justify-self:start}.settings-form-actions{justify-content:stretch}.settings-form-actions button,.settings-danger-action .button-link{width:100%;justify-content:center}.settings-item-list li{align-items:stretch;flex-direction:column}.admin-user-search-actions,.admin-user-actions{align-items:stretch;flex-direction:column}.admin-user-search-actions button,.admin-user-search-actions .button-link,.admin-user-actions button{width:100%;justify-content:center}.panel dl:not(.dashboard-list){grid-template-columns:1fr}table{display:block;max-width:100%;overflow-x:auto}.author-block{align-items:flex-start}.reply-post{margin-left:.65rem;padding-left:.8rem}.reply-post::before{left:-.65rem;width:.65rem}.button-link{padding:.42rem .55rem}.counts{gap:.45rem}.page-header h1,.section-heading h1,.panel h1,.notifications-hero h1{font-size:1.25rem}.notification-row{grid-template-columns:auto minmax(0,1fr);gap:.6rem}.unread-dot{position:absolute;right:.75rem;top:.75rem;margin:0}.notification-preview{padding:.5rem}} "#; #[cfg(test)] @@ -2035,16 +3000,27 @@ mod tests { } #[test] - fn layout_includes_no_javascript_status_and_styles() { + fn layout_includes_no_javascript_status_as_noscript_only() { let body = layout(None, "Home Feed", "

    body

    ", "My Microblog"); - assert!(body.contains(r#"class="noscript-banner" role="status""#)); - assert!(body.contains(".js-enabled .noscript-banner")); + assert!(body.contains(r#"