add: hide_social_follows setting

This commit is contained in:
trisua 2025-07-25 13:39:34 -04:00
parent e78c43ab62
commit a337e0c7c1
11 changed files with 179 additions and 96 deletions

4
Cargo.lock generated
View file

@ -3320,7 +3320,7 @@ dependencies = [
[[package]] [[package]]
name = "tetratto-core" name = "tetratto-core"
version = "12.0.1" version = "12.0.2"
dependencies = [ dependencies = [
"async-recursion", "async-recursion",
"base16ct", "base16ct",
@ -3353,7 +3353,7 @@ dependencies = [
[[package]] [[package]]
name = "tetratto-shared" name = "tetratto-shared"
version = "12.0.5" version = "12.0.6"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"chrono", "chrono",

View file

@ -4,15 +4,11 @@ use nanoneo::{
}; };
use pathbufd::PathBufD; use pathbufd::PathBufD;
use regex::Regex; use regex::Regex;
use std::{ use std::{collections::HashMap, fs::read_to_string, sync::LazyLock, time::SystemTime};
collections::HashMap,
fs::{exists, read_to_string, write},
sync::LazyLock,
time::SystemTime,
};
use tera::Context; use tera::Context;
use tetratto_core::{ use tetratto_core::{
config::Config, config::Config,
html::{pull_icons, ICONS},
model::{ model::{
auth::{DefaultTimelineChoice, User}, auth::{DefaultTimelineChoice, User},
permissions::{FinePermission, SecondaryPermission}, permissions::{FinePermission, SecondaryPermission},
@ -157,38 +153,6 @@ pub const TETRATTO_BUNNY: &[u8] = include_bytes!("./public/images/tetratto_bunny
pub(crate) static HTML_FOOTER: LazyLock<RwLock<String>> = pub(crate) static HTML_FOOTER: LazyLock<RwLock<String>> =
LazyLock::new(|| RwLock::new(String::new())); LazyLock::new(|| RwLock::new(String::new()));
/// A container for all loaded icons.
pub(crate) static ICONS: LazyLock<RwLock<HashMap<String, String>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
/// Pull an icon given its name and insert it into [`ICONS`].
pub(crate) async fn pull_icon(icon: &str, icons_dir: &str) {
let writer = &mut ICONS.write().await;
let icon_url = format!(
"https://raw.githubusercontent.com/lucide-icons/lucide/refs/heads/main/icons/{icon}.svg"
);
let file_path = PathBufD::current().extend(&[icons_dir, &format!("{icon}.svg")]);
if exists(&file_path).unwrap() {
writer.insert(icon.to_string(), read_to_string(&file_path).unwrap());
return;
}
println!("download icon: {icon}");
let svg = reqwest::get(icon_url)
.await
.unwrap()
.text()
.await
.unwrap()
.replace("\n", "");
write(&file_path, &svg).unwrap();
writer.insert(icon.to_string(), svg);
}
macro_rules! vendor_icon { macro_rules! vendor_icon {
($name:literal, $icon:ident, $icons_dir:expr) => {{ ($name:literal, $icon:ident, $icons_dir:expr) => {{
let writer = &mut ICONS.write().await; let writer = &mut ICONS.write().await;
@ -261,56 +225,8 @@ pub(crate) async fn replace_in_html(
input = input.replace(cap.get(0).unwrap().as_str(), &replace_with); input = input.replace(cap.get(0).unwrap().as_str(), &replace_with);
} }
// icon (with class) // icons
let icon_with_class = input = pull_icons(input, &config.dirs.icons).await;
Regex::new("(\\{\\{)\\s*(icon)\\s*(.*?)\\s*c\\((.*?)\\)\\s*(\\}\\})").unwrap();
for cap in icon_with_class.captures_iter(&input.clone()) {
let cap_str = &cap.get(3).unwrap().as_str().replace("\"", "");
let icon = &(if cap_str.contains(" }}") {
cap_str.split(" }}").next().unwrap().to_string()
} else {
cap_str.to_string()
});
pull_icon(icon, &config.dirs.icons).await;
let reader = ICONS.read().await;
let icon_text = reader.get(icon).unwrap().replace(
"<svg",
&format!("<svg class=\"icon {}\"", cap.get(4).unwrap().as_str()),
);
input = input.replace(
&format!(
"{{{{ icon \"{cap_str}\" c({}) }}}}",
cap.get(4).unwrap().as_str()
),
&icon_text,
);
}
// icon (without class)
let icon_without_class = Regex::new("(\\{\\{)\\s*(icon)\\s*(.*?)\\s*(\\}\\})").unwrap();
for cap in icon_without_class.captures_iter(&input.clone()) {
let cap_str = &cap.get(3).unwrap().as_str().replace("\"", "");
let icon = &(if cap_str.contains(" }}") {
cap_str.split(" }}").next().unwrap().to_string()
} else {
cap_str.to_string()
});
pull_icon(icon, &config.dirs.icons).await;
let reader = ICONS.read().await;
let icon_text = reader
.get(icon)
.unwrap()
.replace("<svg", "<svg class=\"icon\"");
input = input.replace(&format!("{{{{ icon \"{cap_str}\" }}}}",), &icon_text);
}
// return // return
input = input.replacen("<!-- html_footer_goes_here -->", &format!("{reader}"), 1); input = input.replacen("<!-- html_footer_goes_here -->", &format!("{reader}"), 1);

View file

@ -107,6 +107,7 @@
(p (p
(text "{{ profile.settings.status }}")) (text "{{ profile.settings.status }}"))
(text "{%- endif %}") (text "{%- endif %}")
(text "{% if not profile.settings.hide_social_follows or (user and user.id == profile.id) -%}")
(div (div
("class" "w-full flex") ("class" "w-full flex")
(a (a
@ -123,6 +124,7 @@
(text "{{ profile.following_count }}")) (text "{{ profile.following_count }}"))
(span (span
(text "{{ text \"auth:label.following\" }}")))) (text "{{ text \"auth:label.following\" }}"))))
(text "{%- endif %}")
(text "{% if is_following_you -%}") (text "{% if is_following_you -%}")
(b (b
("class" "notification chip w-content flex items-center gap-2") ("class" "notification chip w-content flex items-center gap-2")

View file

@ -1889,6 +1889,14 @@
\"{{ profile.settings.hide_from_social_lists }}\", \"{{ profile.settings.hide_from_social_lists }}\",
\"checkbox\", \"checkbox\",
], ],
[
[
\"hide_social_follows\",
\"Hide followers/following links on my profile\",
],
\"{{ profile.settings.hide_social_follows }}\",
\"checkbox\",
],
[[], \"Questions\", \"title\"], [[], \"Questions\", \"title\"],
[ [
[ [

View file

@ -731,6 +731,21 @@ pub async fn following_request(
check_user_blocked_or_private!(user, other_user, data, jar); check_user_blocked_or_private!(user, other_user, data, jar);
// check hide_social_follows
if other_user.settings.hide_social_follows {
if let Some(ref ua) = user {
if ua.id != other_user.id {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
}
} else {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
}
}
// fetch data // fetch data
let list = match data let list = match data
.0 .0
@ -826,6 +841,21 @@ pub async fn followers_request(
check_user_blocked_or_private!(user, other_user, data, jar); check_user_blocked_or_private!(user, other_user, data, jar);
// check hide_social_follows
if other_user.settings.hide_social_follows {
if let Some(ref ua) = user {
if ua.id != other_user.id {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
}
} else {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
}
}
// fetch data // fetch data
let list = match data let list = match data
.0 .0

View file

@ -1,7 +1,7 @@
[package] [package]
name = "tetratto-core" name = "tetratto-core"
description = "The core behind Tetratto" description = "The core behind Tetratto"
version = "12.0.1" version = "12.0.2"
edition = "2024" edition = "2024"
authors.workspace = true authors.workspace = true
repository.workspace = true repository.workspace = true
@ -18,7 +18,7 @@ default = ["database", "types", "sdk"]
pathbufd = "0.1.4" pathbufd = "0.1.4"
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
toml = "0.9.2" toml = "0.9.2"
tetratto-shared = { version = "12.0.0", path = "../shared" } tetratto-shared = { version = "12.0.6", path = "../shared" }
tetratto-l10n = { version = "12.0.0", path = "../l10n" } tetratto-l10n = { version = "12.0.0", path = "../l10n" }
serde_json = "1.0.141" serde_json = "1.0.141"
totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"], optional = true } totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"], optional = true }
@ -35,6 +35,4 @@ oiseau = { version = "0.1.2", default-features = false, features = [
"redis", "redis",
], optional = true } ], optional = true }
paste = { version = "1.0.15", optional = true } paste = { version = "1.0.15", optional = true }
[dev-dependencies]
tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread"] }

97
crates/core/src/html.rs Normal file
View file

@ -0,0 +1,97 @@
use std::{
collections::HashMap,
fs::{exists, read_to_string, write},
sync::LazyLock,
};
use tokio::sync::RwLock;
use pathbufd::PathBufD;
/// A container for all loaded icons.
pub static ICONS: LazyLock<RwLock<HashMap<String, String>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
/// Pull an icon given its name and insert it into [`ICONS`].
pub async fn pull_icon(icon: &str, icons_dir: &str) {
let writer = &mut ICONS.write().await;
let icon_url = format!(
"https://raw.githubusercontent.com/lucide-icons/lucide/refs/heads/main/icons/{icon}.svg"
);
let file_path = PathBufD::current().extend(&[icons_dir, &format!("{icon}.svg")]);
if exists(&file_path).unwrap_or(false) {
writer.insert(icon.to_string(), read_to_string(&file_path).unwrap());
return;
}
println!("download icon: {icon}");
let svg = reqwest::get(icon_url)
.await
.unwrap()
.text()
.await
.unwrap()
.replace("\n", "");
write(&file_path, &svg).unwrap();
writer.insert(icon.to_string(), svg);
}
/// Read a string and pull all icons found within it.
pub async fn pull_icons(mut input: String, icon_dir: &str) -> String {
// icon (with class)
let icon_with_class =
regex::Regex::new("(\\{\\{)\\s*(icon)\\s*(.*?)\\s*c\\((.*?)\\)\\s*(\\}\\})").unwrap();
for cap in icon_with_class.captures_iter(&input.clone()) {
let cap_str = &cap.get(3).unwrap().as_str().replace("\"", "");
let icon = &(if cap_str.contains(" }}") {
cap_str.split(" }}").next().unwrap().to_string()
} else {
cap_str.to_string()
});
pull_icon(icon, icon_dir).await;
let reader = ICONS.read().await;
let icon_text = reader.get(icon).unwrap().replace(
"<svg",
&format!("<svg class=\"icon {}\"", cap.get(4).unwrap().as_str()),
);
input = input.replace(
&format!(
"{{{{ icon \"{cap_str}\" c({}) }}}}",
cap.get(4).unwrap().as_str()
),
&icon_text,
);
}
// icon (without class)
let icon_without_class = regex::Regex::new("(\\{\\{)\\s*(icon)\\s*(.*?)\\s*(\\}\\})").unwrap();
for cap in icon_without_class.captures_iter(&input.clone()) {
let cap_str = &cap.get(3).unwrap().as_str().replace("\"", "");
let icon = &(if cap_str.contains(" }}") {
cap_str.split(" }}").next().unwrap().to_string()
} else {
cap_str.to_string()
});
pull_icon(icon, icon_dir).await;
let reader = ICONS.read().await;
let icon_text = reader
.get(icon)
.unwrap()
.replace("<svg", "<svg class=\"icon\"");
input = input.replace(&format!("{{{{ icon \"{cap_str}\" }}}}",), &icon_text);
}
// ...
input
}

View file

@ -3,6 +3,8 @@ pub mod config;
#[cfg(feature = "database")] #[cfg(feature = "database")]
pub mod database; pub mod database;
#[cfg(feature = "types")] #[cfg(feature = "types")]
pub mod html;
#[cfg(feature = "types")]
pub mod model; pub mod model;
#[cfg(feature = "sdk")] #[cfg(feature = "sdk")]
pub mod sdk; pub mod sdk;

View file

@ -337,6 +337,10 @@ pub struct UserSettings {
/// Biography shown on `profile/private.lisp` page. /// Biography shown on `profile/private.lisp` page.
#[serde(default)] #[serde(default)]
pub private_biography: String, pub private_biography: String,
/// If the followers/following links are hidden from the user's profile.
/// Will also revoke access to their respective pages.
#[serde(default)]
pub hide_social_follows: bool,
} }
#[derive(Clone, Debug, Serialize, Deserialize, Default)] #[derive(Clone, Debug, Serialize, Deserialize, Default)]

View file

@ -1,7 +1,7 @@
[package] [package]
name = "tetratto-shared" name = "tetratto-shared"
description = "Shared stuff for Tetratto" description = "Shared stuff for Tetratto"
version = "12.0.5" version = "12.0.6"
edition = "2024" edition = "2024"
authors.workspace = true authors.workspace = true
repository.workspace = true repository.workspace = true

View file

@ -3,7 +3,7 @@ use pulldown_cmark::{Parser, Options, html::push_html};
use std::collections::HashSet; use std::collections::HashSet;
pub fn render_markdown_dirty(input: &str) -> String { pub fn render_markdown_dirty(input: &str) -> String {
let input = &autolinks(&parse_alignment(input)); let input = &autolinks(&parse_alignment(&parse_backslash_breaks(input)));
let mut options = Options::empty(); let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH); options.insert(Options::ENABLE_STRIKETHROUGH);
@ -180,6 +180,32 @@ pub fn parse_alignment(input: &str) -> String {
output output
} }
pub fn parse_backslash_breaks(input: &str) -> String {
let mut in_pre_block = false;
let mut output = String::new();
for line in input.split("\n") {
if line.starts_with("```") {
in_pre_block = !in_pre_block;
output.push_str(&format!("{line}\n"));
continue;
}
if in_pre_block {
output.push_str(&format!("{line}\n"));
continue;
}
if line.trim_end().ends_with("\\") {
output.push_str(&format!("{line}<br />\n"));
} else {
output.push_str(&format!("{line}\n"));
}
}
output
}
/// Adapted from <https://git.cypr.io/oz/autolink-rust>. /// Adapted from <https://git.cypr.io/oz/autolink-rust>.
/// ///
/// The only real change here is that autolinks require a whitespace OR end the /// The only real change here is that autolinks require a whitespace OR end the