feat(web): global privacy control, 4 privacies

This commit is contained in:
əlemi 2024-11-23 04:39:31 +01:00
parent f57f304419
commit 870c781cf7
Signed by: alemi
GPG key ID: A4895B84D311642C
6 changed files with 214 additions and 115 deletions

View file

@ -317,6 +317,16 @@
color: var(--background); color: var(--background);
cursor: pointer; cursor: pointer;
} }
input[type="range"] {
accent-color: var(--accent);
}
input[type="range"]:hover {
outline: none;
}
input[type="range"]:focus {
outline: none;
accent-color: var(--accent-dim);
}
.ml-1-r { .ml-1-r {
margin-left: 1em; margin-left: 1em;
} }
@ -377,6 +387,9 @@
color: unset; color: unset;
text-shadow: unset; text-shadow: unset;
} }
span.big-emoji {
font-size: 1.5em;
}
div.context { div.context {
border-left: 1px solid var(--background-dim); border-left: 1px solid var(--background-dim);
padding-left: 1px; padding-left: 1px;

View file

@ -1,12 +1,15 @@
use leptos::*; use leptos::*;
use crate::prelude::*; use crate::prelude::*;
use apb::{target::Addressed, Activity, ActivityMut, Base, Object}; use apb::{Activity, ActivityMut, Base, Object};
#[component] #[component]
pub fn ActivityLine(activity: crate::Object, children: Children) -> impl IntoView { pub fn ActivityLine(activity: crate::Object, children: Children) -> impl IntoView {
let object_id = activity.object().id().unwrap_or_default(); let object_id = activity.object().id().unwrap_or_default();
let to = activity.to().all_ids();
let cc = activity.cc().all_ids();
let privacy = Privacy::from_addressed(&to, &cc);
let activity_url = activity.id().map(|x| view! { let activity_url = activity.id().map(|x| view! {
<sup><small><a class="clean ml-s" href={x.to_string()} target="_blank">""</a></small></sup> <sup><small><a class="clean ml-s" href={x.to_string()} target="_blank">""</a></small></sup>
}); });
@ -31,7 +34,7 @@ pub fn ActivityLine(activity: crate::Object, children: Children) -> impl IntoVie
{kind.as_ref().to_string()} {kind.as_ref().to_string()}
</a> </a>
{activity_url} {activity_url}
<PrivacyMarker addressed=activity.addressed() /> <PrivacyMarker privacy=privacy to=&to cc=&cc />
</code> </code>
</td> </td>
</tr> </tr>

View file

@ -74,6 +74,8 @@ pub fn App() -> impl IntoView {
); );
let (config, set_config, _) = use_local_storage::<crate::Config, JsonSerdeCodec>("config"); let (config, set_config, _) = use_local_storage::<crate::Config, JsonSerdeCodec>("config");
let (privacy, set_privacy) = create_signal(Privacy::Private);
let auth = Auth { token, userid }; let auth = Auth { token, userid };
let username = auth.userid.get_untracked() let username = auth.userid.get_untracked()
@ -85,6 +87,7 @@ pub fn App() -> impl IntoView {
provide_context(auth); provide_context(auth);
provide_context(config); provide_context(config);
provide_context(feeds); provide_context(feeds);
provide_context(privacy);
let reply_controls = ReplyControls::default(); let reply_controls = ReplyControls::default();
provide_context(reply_controls); provide_context(reply_controls);
@ -136,6 +139,8 @@ pub fn App() -> impl IntoView {
/> />
<hr class="mt-1 mb-1" /> <hr class="mt-1 mb-1" />
<div class:hidden=move || !auth.present() > <div class:hidden=move || !auth.present() >
<PrivacySelector setter=set_privacy />
<hr class="mt-1 mb-1" />
{move || if advanced.get() { view! { {move || if advanced.get() { view! {
<AdvancedPostBox advanced=set_advanced/> <AdvancedPostBox advanced=set_advanced/>
}} else { view! { }} else { view! {

View file

@ -37,21 +37,17 @@ pub fn DateTime(t: Option<chrono::DateTime<chrono::Utc>>) -> impl IntoView {
} }
} }
pub const PRIVACY_PUBLIC : &str = "🪩";
pub const PRIVACY_FOLLOWERS : &str = "🔒";
pub const PRIVACY_PRIVATE : &str = "📨";
#[component] #[component]
pub fn PrivacyMarker(addressed: Vec<String>) -> impl IntoView { pub fn PrivacyMarker<'a>(
let privacy = if addressed.iter().any(|x| x == apb::target::PUBLIC) { privacy: Privacy,
PRIVACY_PUBLIC #[prop(optional)] to: &'a [String],
} else if addressed.iter().any(|x| x.ends_with("/followers")) { #[prop(optional)] cc: &'a [String],
PRIVACY_FOLLOWERS #[prop(optional)] big: bool,
} else { ) -> impl IntoView {
PRIVACY_PRIVATE let to_txt = if to.is_empty() { String::new() } else { format!("to: {}", to.join(", ")) };
}; let cc_txt = if cc.is_empty() { String::new() } else { format!("cc: {}", cc.join(", ")) };
let audience = format!("[ {} ]", addressed.join(", ")); let audience = format!("{to_txt}\n{cc_txt}");
view! { view! {
<span class="emoji ml-1 mr-s moreinfo" title={audience} >{privacy}</span> <span class:big-emoji=big class="emoji ml-1 mr-s moreinfo" title={audience} >{privacy.icon()}</span>
} }
} }

View file

@ -35,17 +35,122 @@ struct MentionMatch {
domain: String, domain: String,
} }
pub type PrivacyControl = ReadSignal<Privacy>;
#[derive(Debug, Clone, Copy)]
pub enum Privacy {
Broadcast = 4,
Public = 3,
Private = 2,
Direct = 1,
}
impl Privacy {
pub fn is_public(&self) -> bool {
match self {
Self::Broadcast | Self::Public => true,
_ => false,
}
}
pub fn from_value(v: &str) -> Self {
match v {
"1" => Self::Direct,
"2" => Self::Private,
"3" => Self::Public,
"4" => Self::Broadcast,
_ => panic!("invalid value for privacy"),
}
}
pub fn from_addressed(to: &[String], cc: &[String]) -> Self {
if to.iter().any(|x| x == apb::target::PUBLIC) {
return Self::Broadcast;
}
if cc.iter().any(|x| x == apb::target::PUBLIC) {
return Self::Public;
}
if to.iter().any(|x| x.ends_with("/followers"))
|| cc.iter().any(|x| x.ends_with("/followers")) {
return Self::Private;
}
Self::Direct
}
pub fn icon(&self) -> &'static str {
match self {
Self::Broadcast => "📢",
Self::Public => "🪩",
Self::Private => "🔒",
Self::Direct => "📨",
}
}
pub fn address(&self, user: &str) -> (Vec<String>, Vec<String>) {
match self {
Self::Broadcast => (
vec![apb::target::PUBLIC.to_string()],
vec![format!("{URL_BASE}/actors/{user}/followers")],
),
Self::Public => (
vec![],
vec![apb::target::PUBLIC.to_string(), format!("{URL_BASE}/actors/{user}/followers")],
),
Self::Private => (
vec![],
vec![format!("{URL_BASE}/actors/{user}/followers")],
),
Self::Direct => (
vec![],
vec![],
),
}
}
}
#[component]
pub fn PrivacySelector(setter: WriteSignal<Privacy>) -> impl IntoView {
let privacy = use_context::<PrivacyControl>().expect("missing privacy context");
let auth = use_context::<Auth>().expect("missing auth context");
view! {
<table class="align w-100">
<tr>
<td class="w-100">
<input
type="range"
min="1"
max="4"
class="w-100"
prop:value=move || privacy.get() as u8
on:input=move |ev| {
ev.prevent_default();
setter.set(Privacy::from_value(&event_target_value(&ev)));
} />
</td>
<td>
{move || {
let p = privacy.get();
let (to, cc) = p.address(&auth.username());
view! {
<PrivacyMarker privacy=p to=&to cc=&cc big=true />
}
}}
</td>
</tr>
</table>
}
}
#[component] #[component]
pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView { pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
let auth = use_context::<Auth>().expect("missing auth context"); let auth = use_context::<Auth>().expect("missing auth context");
let privacy = use_context::<PrivacyControl>().expect("missing privacy context");
let reply = use_context::<ReplyControls>().expect("missing reply controls"); let reply = use_context::<ReplyControls>().expect("missing reply controls");
let (posting, set_posting) = create_signal(false); let (posting, set_posting) = create_signal(false);
let (error, set_error) = create_signal(None); let (error, set_error) = create_signal(None);
let (content, set_content) = create_signal("".to_string()); let (content, set_content) = create_signal("".to_string());
let summary_ref: NodeRef<html::Input> = create_node_ref(); let summary_ref: NodeRef<html::Input> = create_node_ref();
let public_ref: NodeRef<html::Input> = create_node_ref();
let followers_ref: NodeRef<html::Input> = create_node_ref();
let private_ref: NodeRef<html::Input> = create_node_ref();
// TODO is this too abusive with resources? im even checking if TLD exists... // TODO is this too abusive with resources? im even checking if TLD exists...
let mentions = create_local_resource( let mentions = create_local_resource(
@ -109,88 +214,63 @@ pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
on:input=move |ev| set_content.set(event_target_value(&ev)) on:input=move |ev| set_content.set(event_target_value(&ev))
></textarea> ></textarea>
<button class="w-100" prop:disabled=posting type="button" style="height: 3em" on:click=move |_| {
set_posting.set(true);
spawn_local(async move {
let summary = get_if_some(summary_ref);
let content = content.get();
let (mut to_vec, cc_vec) = privacy.get().address(&auth.username());
let mut mention_tags : Vec<serde_json::Value> = mentions.get()
.unwrap_or_default()
.into_iter()
.map(|x| {
use apb::LinkMut;
LinkMut::set_name(apb::new(), Some(format!("@{}@{}", x.name, x.domain))) // TODO ewww but name clashes
.set_link_type(Some(apb::LinkType::Mention))
.set_href(Some(x.href))
})
.collect();
if let Some(r) = reply.reply_to.get() {
<table class="align rev w-100"> if let Some(au) = post_author(&r) {
<tr> if let Ok(uid) = au.id() {
<td><input id="priv-public" type="radio" name="privacy" value="public" title="public" node_ref=public_ref /></td> to_vec.push(uid.to_string());
<td><span class="emoji" title="public" >{PRIVACY_PUBLIC}</span></td> if let Ok(name) = au.name() {
<td class="w-100" rowspan="3"> let domain = Uri::domain(&uid);
<button class="w-100" prop:disabled=posting type="button" style="height: 3em" on:click=move |_| { mention_tags.push({
set_posting.set(true);
spawn_local(async move {
let summary = get_if_some(summary_ref);
let content = content.get();
let mut cc_vec = Vec::new();
let mut to_vec = Vec::new();
if get_checked(followers_ref) {
cc_vec.push(format!("{URL_BASE}/actors/{}/followers", auth.username()));
}
if get_checked(public_ref) {
cc_vec.push(apb::target::PUBLIC.to_string());
cc_vec.push(format!("{URL_BASE}/actors/{}/followers", auth.username()));
}
let mut mention_tags : Vec<serde_json::Value> = mentions.get()
.unwrap_or_default()
.into_iter()
.map(|x| {
use apb::LinkMut; use apb::LinkMut;
LinkMut::set_name(apb::new(), Some(format!("@{}@{}", x.name, x.domain))) // TODO ewww but name clashes LinkMut::set_name(apb::new(), Some(format!("@{}@{}", name, domain))) // TODO ewww but name clashes
.set_link_type(Some(apb::LinkType::Mention)) .set_link_type(Some(apb::LinkType::Mention))
.set_href(Some(x.href)) .set_href(Some(uid))
}) });
.collect(); }
}
}
}
for mention in mentions.get().as_deref().unwrap_or(&[]) {
to_vec.push(mention.href.clone());
}
let payload = apb::new()
.set_object_type(Some(apb::ObjectType::Note))
.set_summary(summary)
.set_content(Some(content))
.set_context(apb::Node::maybe_link(reply.context.get()))
.set_in_reply_to(apb::Node::maybe_link(reply.reply_to.get()))
.set_to(apb::Node::links(to_vec))
.set_cc(apb::Node::links(cc_vec))
.set_tag(apb::Node::array(mention_tags));
match Http::post(&auth.outbox(), &payload, auth).await {
Err(e) => set_error.set(Some(e.to_string())),
Ok(()) => {
set_error.set(None);
if let Some(x) = summary_ref.get() { x.set_value("") }
set_content.set("".to_string());
},
}
set_posting.set(false);
})
} >post</button>
if let Some(r) = reply.reply_to.get() {
if let Some(au) = post_author(&r) {
if let Ok(uid) = au.id() {
to_vec.push(uid.to_string());
if let Ok(name) = au.name() {
let domain = Uri::domain(&uid);
mention_tags.push({
use apb::LinkMut;
LinkMut::set_name(apb::new(), Some(format!("@{}@{}", name, domain))) // TODO ewww but name clashes
.set_link_type(Some(apb::LinkType::Mention))
.set_href(Some(uid))
});
}
}
}
}
for mention in mentions.get().as_deref().unwrap_or(&[]) {
to_vec.push(mention.href.clone());
}
let payload = apb::new()
.set_object_type(Some(apb::ObjectType::Note))
.set_summary(summary)
.set_content(Some(content))
.set_context(apb::Node::maybe_link(reply.context.get()))
.set_in_reply_to(apb::Node::maybe_link(reply.reply_to.get()))
.set_to(apb::Node::links(to_vec))
.set_cc(apb::Node::links(cc_vec))
.set_tag(apb::Node::array(mention_tags));
match Http::post(&auth.outbox(), &payload, auth).await {
Err(e) => set_error.set(Some(e.to_string())),
Ok(()) => {
set_error.set(None);
if let Some(x) = summary_ref.get() { x.set_value("") }
set_content.set("".to_string());
},
}
set_posting.set(false);
})
} >post</button>
</td>
</tr>
<tr>
<td><input id="priv-followers" type="radio" name="privacy" value="followers" title="followers" node_ref=followers_ref checked /></td>
<td><span class="emoji" title="followers" >{PRIVACY_FOLLOWERS}</span></td>
</tr>
<tr>
<td><input id="priv-private" type="radio" name="privacy" value="private" title="private" node_ref=private_ref /></td>
<td><span class="emoji" title="private" >{PRIVACY_PRIVATE}</span></td>
</tr>
</table>
{move|| error.get().map(|x| view! { <blockquote class="mt-s">{x}</blockquote> })} {move|| error.get().map(|x| view! { <blockquote class="mt-s">{x}</blockquote> })}
</div> </div>
} }

View file

@ -11,8 +11,9 @@ pub fn Object(object: crate::Object) -> impl IntoView {
let author_id = object.attributed_to().id().ok().unwrap_or_default(); let author_id = object.attributed_to().id().ok().unwrap_or_default();
let author = cache::OBJECTS.get_or(&author_id, serde_json::Value::String(author_id.clone()).into()); let author = cache::OBJECTS.get_or(&author_id, serde_json::Value::String(author_id.clone()).into());
let sensitive = object.sensitive().unwrap_or_default(); let sensitive = object.sensitive().unwrap_or_default();
let addressed = object.addressed(); let to = object.to().all_ids();
let public = addressed.iter().any(|x| x.as_str() == apb::target::PUBLIC); let cc = object.cc().all_ids();
let privacy = Privacy::from_addressed(&to, &cc);
let external_url = object.url().id().ok().unwrap_or_else(|| oid.clone()); let external_url = object.url().id().ok().unwrap_or_else(|| oid.clone());
let attachments = object.attachment() let attachments = object.attachment()
.flat() .flat()
@ -180,7 +181,7 @@ pub fn Object(object: crate::Object) -> impl IntoView {
{object.in_reply_to().id().ok().map(|reply| view! { {object.in_reply_to().id().ok().map(|reply| view! {
<small><i><a class="clean" href={Uri::web(U::Object, &reply)} title={reply}>reply</a></i></small> <small><i><a class="clean" href={Uri::web(U::Object, &reply)} title={reply}>reply</a></i></small>
})} })}
<PrivacyMarker addressed=addressed /> <PrivacyMarker privacy=privacy to=&to cc=&cc />
<a class="clean hover ml-s" href={Uri::web(U::Object, &object.id().unwrap_or_default())}> <a class="clean hover ml-s" href={Uri::web(U::Object, &object.id().unwrap_or_default())}>
<DateTime t=object.published().ok() /> <DateTime t=object.published().ok() />
</a> </a>
@ -195,8 +196,8 @@ pub fn Object(object: crate::Object) -> impl IntoView {
{audience_badge} {audience_badge}
<span style="white-space:nowrap"> <span style="white-space:nowrap">
<ReplyButton n=comments target=oid.clone() /> <ReplyButton n=comments target=oid.clone() />
<LikeButton n=likes liked=already_liked target=oid.clone() author=author_id private=!public /> <LikeButton n=likes liked=already_liked target=oid.clone() author=author_id.clone() private=!privacy.is_public() />
<RepostButton n=shares target=oid /> {if privacy.is_public() { Some(view! { <RepostButton n=shares target=oid author=author_id /> }) } else { None }}
</span> </span>
</div> </div>
} }
@ -230,6 +231,7 @@ pub fn LikeButton(
let (count, set_count) = create_signal(n); let (count, set_count) = create_signal(n);
let (clicked, set_clicked) = create_signal(!liked); let (clicked, set_clicked) = create_signal(!liked);
let auth = use_context::<Auth>().expect("missing auth context"); let auth = use_context::<Auth>().expect("missing auth context");
let privacy = use_context::<PrivacyControl>().expect("missing privacy context");
view! { view! {
<span <span
class:emoji=clicked class:emoji=clicked
@ -239,18 +241,17 @@ pub fn LikeButton(
on:click=move |_ev| { on:click=move |_ev| {
if !auth.present() { return; } if !auth.present() { return; }
if !clicked.get() { return; } if !clicked.get() { return; }
let to = apb::Node::links(vec![author.to_string()]); let (mut to, cc) = if private {
let cc = if private { apb::Node::Empty } else { (vec![], vec![])
apb::Node::links(vec![ } else {
apb::target::PUBLIC.to_string(), privacy.get().address(&auth.username())
format!("{URL_BASE}/actors/{}/followers", auth.username())
])
}; };
to.push(author.clone());
let payload = serde_json::Value::Object(serde_json::Map::default()) let payload = serde_json::Value::Object(serde_json::Map::default())
.set_activity_type(Some(apb::ActivityType::Like)) .set_activity_type(Some(apb::ActivityType::Like))
.set_object(apb::Node::link(target.clone())) .set_object(apb::Node::link(target.clone()))
.set_to(to) .set_to(apb::Node::links(to))
.set_cc(cc); .set_cc(apb::Node::links(cc));
let target = target.clone(); let target = target.clone();
spawn_local(async move { spawn_local(async move {
match Http::post(&auth.outbox(), &payload, auth).await { match Http::post(&auth.outbox(), &payload, auth).await {
@ -304,10 +305,11 @@ pub fn ReplyButton(n: i32, target: String) -> impl IntoView {
} }
#[component] #[component]
pub fn RepostButton(n: i32, target: String) -> impl IntoView { pub fn RepostButton(n: i32, target: String, author: String) -> impl IntoView {
let (count, set_count) = create_signal(n); let (count, set_count) = create_signal(n);
let (clicked, set_clicked) = create_signal(true); let (clicked, set_clicked) = create_signal(true);
let auth = use_context::<Auth>().expect("missing auth context"); let auth = use_context::<Auth>().expect("missing auth context");
let privacy = use_context::<PrivacyControl>().expect("missing privacy context");
view! { view! {
<span <span
class:emoji=clicked class:emoji=clicked
@ -318,13 +320,13 @@ pub fn RepostButton(n: i32, target: String) -> impl IntoView {
if !auth.present() { return; } if !auth.present() { return; }
if !clicked.get() { return; } if !clicked.get() { return; }
set_clicked.set(false); set_clicked.set(false);
let to = apb::Node::links(vec![apb::target::PUBLIC.to_string()]); let (mut to, cc) = privacy.get().address(&auth.username());
let cc = apb::Node::links(vec![format!("{URL_BASE}/actors/{}/followers", auth.username())]); to.push(author.clone());
let payload = serde_json::Value::Object(serde_json::Map::default()) let payload = serde_json::Value::Object(serde_json::Map::default())
.set_activity_type(Some(apb::ActivityType::Announce)) .set_activity_type(Some(apb::ActivityType::Announce))
.set_object(apb::Node::link(target.clone())) .set_object(apb::Node::link(target.clone()))
.set_to(to) .set_to(apb::Node::links(to))
.set_cc(cc); .set_cc(apb::Node::links(cc));
spawn_local(async move { spawn_local(async move {
match Http::post(&auth.outbox(), &payload, auth).await { match Http::post(&auth.outbox(), &payload, auth).await {
Ok(()) => set_count.set(count.get() + 1), Ok(()) => set_count.set(count.get() + 1),