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);
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 {
margin-left: 1em;
}
@ -377,6 +387,9 @@
color: unset;
text-shadow: unset;
}
span.big-emoji {
font-size: 1.5em;
}
div.context {
border-left: 1px solid var(--background-dim);
padding-left: 1px;

View file

@ -1,12 +1,15 @@
use leptos::*;
use crate::prelude::*;
use apb::{target::Addressed, Activity, ActivityMut, Base, Object};
use apb::{Activity, ActivityMut, Base, Object};
#[component]
pub fn ActivityLine(activity: crate::Object, children: Children) -> impl IntoView {
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! {
<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()}
</a>
{activity_url}
<PrivacyMarker addressed=activity.addressed() />
<PrivacyMarker privacy=privacy to=&to cc=&cc />
</code>
</td>
</tr>

View file

@ -74,6 +74,8 @@ pub fn App() -> impl IntoView {
);
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 username = auth.userid.get_untracked()
@ -85,6 +87,7 @@ pub fn App() -> impl IntoView {
provide_context(auth);
provide_context(config);
provide_context(feeds);
provide_context(privacy);
let reply_controls = ReplyControls::default();
provide_context(reply_controls);
@ -136,6 +139,8 @@ pub fn App() -> impl IntoView {
/>
<hr class="mt-1 mb-1" />
<div class:hidden=move || !auth.present() >
<PrivacySelector setter=set_privacy />
<hr class="mt-1 mb-1" />
{move || if advanced.get() { view! {
<AdvancedPostBox advanced=set_advanced/>
}} 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]
pub fn PrivacyMarker(addressed: Vec<String>) -> impl IntoView {
let privacy = if addressed.iter().any(|x| x == apb::target::PUBLIC) {
PRIVACY_PUBLIC
} else if addressed.iter().any(|x| x.ends_with("/followers")) {
PRIVACY_FOLLOWERS
} else {
PRIVACY_PRIVATE
};
let audience = format!("[ {} ]", addressed.join(", "));
pub fn PrivacyMarker<'a>(
privacy: Privacy,
#[prop(optional)] to: &'a [String],
#[prop(optional)] cc: &'a [String],
#[prop(optional)] big: bool,
) -> impl IntoView {
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!("{to_txt}\n{cc_txt}");
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,
}
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]
pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
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 (posting, set_posting) = create_signal(false);
let (error, set_error) = create_signal(None);
let (content, set_content) = create_signal("".to_string());
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...
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))
></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();
<table class="align rev w-100">
<tr>
<td><input id="priv-public" type="radio" name="privacy" value="public" title="public" node_ref=public_ref /></td>
<td><span class="emoji" title="public" >{PRIVACY_PUBLIC}</span></td>
<td class="w-100" rowspan="3">
<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 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| {
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!("@{}@{}", 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_href(Some(x.href))
})
.collect();
.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>
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> })}
</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 = cache::OBJECTS.get_or(&author_id, serde_json::Value::String(author_id.clone()).into());
let sensitive = object.sensitive().unwrap_or_default();
let addressed = object.addressed();
let public = addressed.iter().any(|x| x.as_str() == apb::target::PUBLIC);
let to = object.to().all_ids();
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 attachments = object.attachment()
.flat()
@ -180,7 +181,7 @@ pub fn Object(object: crate::Object) -> impl IntoView {
{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>
})}
<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())}>
<DateTime t=object.published().ok() />
</a>
@ -195,8 +196,8 @@ pub fn Object(object: crate::Object) -> impl IntoView {
{audience_badge}
<span style="white-space:nowrap">
<ReplyButton n=comments target=oid.clone() />
<LikeButton n=likes liked=already_liked target=oid.clone() author=author_id private=!public />
<RepostButton n=shares target=oid />
<LikeButton n=likes liked=already_liked target=oid.clone() author=author_id.clone() private=!privacy.is_public() />
{if privacy.is_public() { Some(view! { <RepostButton n=shares target=oid author=author_id /> }) } else { None }}
</span>
</div>
}
@ -230,6 +231,7 @@ pub fn LikeButton(
let (count, set_count) = create_signal(n);
let (clicked, set_clicked) = create_signal(!liked);
let auth = use_context::<Auth>().expect("missing auth context");
let privacy = use_context::<PrivacyControl>().expect("missing privacy context");
view! {
<span
class:emoji=clicked
@ -239,18 +241,17 @@ pub fn LikeButton(
on:click=move |_ev| {
if !auth.present() { return; }
if !clicked.get() { return; }
let to = apb::Node::links(vec![author.to_string()]);
let cc = if private { apb::Node::Empty } else {
apb::Node::links(vec![
apb::target::PUBLIC.to_string(),
format!("{URL_BASE}/actors/{}/followers", auth.username())
])
let (mut to, cc) = if private {
(vec![], vec![])
} else {
privacy.get().address(&auth.username())
};
to.push(author.clone());
let payload = serde_json::Value::Object(serde_json::Map::default())
.set_activity_type(Some(apb::ActivityType::Like))
.set_object(apb::Node::link(target.clone()))
.set_to(to)
.set_cc(cc);
.set_to(apb::Node::links(to))
.set_cc(apb::Node::links(cc));
let target = target.clone();
spawn_local(async move {
match Http::post(&auth.outbox(), &payload, auth).await {
@ -304,10 +305,11 @@ pub fn ReplyButton(n: i32, target: String) -> impl IntoView {
}
#[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 (clicked, set_clicked) = create_signal(true);
let auth = use_context::<Auth>().expect("missing auth context");
let privacy = use_context::<PrivacyControl>().expect("missing privacy context");
view! {
<span
class:emoji=clicked
@ -318,13 +320,13 @@ pub fn RepostButton(n: i32, target: String) -> impl IntoView {
if !auth.present() { return; }
if !clicked.get() { return; }
set_clicked.set(false);
let to = apb::Node::links(vec![apb::target::PUBLIC.to_string()]);
let cc = apb::Node::links(vec![format!("{URL_BASE}/actors/{}/followers", auth.username())]);
let (mut to, cc) = privacy.get().address(&auth.username());
to.push(author.clone());
let payload = serde_json::Value::Object(serde_json::Map::default())
.set_activity_type(Some(apb::ActivityType::Announce))
.set_object(apb::Node::link(target.clone()))
.set_to(to)
.set_cc(cc);
.set_to(apb::Node::links(to))
.set_cc(apb::Node::links(cc));
spawn_local(async move {
match Http::post(&auth.outbox(), &payload, auth).await {
Ok(()) => set_count.set(count.get() + 1),