feat(web): global privacy control, 4 privacies
This commit is contained in:
parent
f57f304419
commit
870c781cf7
6 changed files with 214 additions and 115 deletions
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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! {
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
Loading…
Reference in a new issue