upub-web 0.6.0

web frontend for upub
Documentation
use apb::{ActivityMut, DocumentMut, Object, ObjectMut};
use leptos::prelude::*;
use crate::{prelude::*, DEFAULT_COLOR};

#[component]
pub fn ConfigPage(setter: WriteSignal<crate::Config>) -> impl IntoView {
	let config = use_context::<Signal<crate::Config>>().expect("missing config context");
	let auth = use_context::<Auth>().expect("missing auth context");
	let (color, set_color) = leptos_use::use_css_var("--accent");
	let (_color_rgb, set_color_rgb) = leptos_use::use_css_var("--accent-rgb");

	// TODO should this be responsive? idk
	let previous_color = config.get_untracked().accent_color;
	set_color_rgb.set(parse_hex(&previous_color));
	set_color.set(previous_color);

	let display_name_ref: NodeRef<leptos::html::Input> = NodeRef::new();
	let summary_ref: NodeRef<leptos::html::Textarea> = NodeRef::new();
	let avatar_url_ref: NodeRef<leptos::html::Input> = NodeRef::new();
	let banner_url_ref: NodeRef<leptos::html::Input> = NodeRef::new();

	let myself = cache::OBJECTS.get(&auth.userid.get_untracked().unwrap_or_default());
	let (curr_display_name, curr_summary, curr_icon, curr_banner) = match myself.as_ref() {
		None => (String::default(), String::default(), String::default(), String::default()),
		Some(myself) => (
			myself.name().unwrap_or_default().to_string(),
			myself.summary().unwrap_or_default().to_string(),
			myself.icon().inner().and_then(|x| Ok(x.url().id()?.to_string())).unwrap_or_default(),
			myself.image().inner().and_then(|x| Ok(x.url().id()?.to_string())).unwrap_or_default(),
		),
	};

	macro_rules! get_cfg {
		(filter $field:ident) => {
			move || config.get().filters.$field
		};
		($field:ident) => {
			move || config.get().$field
		};
	}

	macro_rules! set_cfg {
		($field:ident) => {
			move |ev| {
				let mut mock = config.get_untracked();
				mock.$field = event_target_checked(&ev);
				setter.set(mock);
			}
		};
		(filter $field:ident) => {
			move |ev| {
				let mut mock = config.get_untracked();
				mock.filters.$field = event_target_checked(&ev);
				setter.set(mock);
			}
		};
	}

	let (privacy, set_privacy) = signal(config.get().default_privacy);

	Effect::watch(
		move || privacy.get(),
		move |prv, old_prv, _state| {
			if let Some(old_prv_value) = old_prv {
				if prv != old_prv_value {
					let mut mock = config.get_untracked();
					mock.default_privacy = *prv;
					setter.set(mock);
				}
			}
		},
		false,
	);

	view! {
		<div>
			<p class="center mt-0"><small>config is saved in your browser local storage</small></p>
			<p>
				<span title="embedded video attachments will loop like gifs if this option is enabled">
					<input type="checkbox" class="mr-1"
						prop:checked=get_cfg!(loop_videos)
						on:input=set_cfg!(loop_videos)
					/> loop videos
				</span>
			</p>
			<p>
				<span title="any post with a summary is considered to have a content warning, and collapsed by default if this option is enabled">
					<input type="checkbox" class="mr-1"
						prop:checked=get_cfg!(collapse_content_warnings)
						on:input=set_cfg!(collapse_content_warnings)
					/> collapse content warnings
				</span>
			</p>
			<p>
				<span title="new posts will be fetched automatically when scrolling down enough">
					<input type="checkbox" class="mr-1"
						prop:checked=get_cfg!(infinite_scroll)
						on:input=set_cfg!(infinite_scroll)
					/> infinite scroll
				</span>
			</p>
			<p>
				<span title="default privacy setting applied when loading frontend">
					default privacy <PrivacySelector getter=privacy setter=set_privacy full_width=false />
				</span>
			</p>
			<p>
				<span title="customize frontend accent color with">
					accent color
					<input type="text" class="ma-1"
						style="width: 8ch;"
						placeholder=DEFAULT_COLOR
						value=color
						on:input=move|ev| {
							let mut val = event_target_value(&ev);
							if val.is_empty() { val = DEFAULT_COLOR.to_string() };
							let mut mock = config.get();
							set_color_rgb.set(parse_hex(&val));
							set_color.set(val.clone());
							mock.accent_color = val;
							setter.set(mock);
					} />
				</span>
			</p>
			<hr />
			<p><code title="unchecked elements won't show in timelines">filters</code></p>
			<ul>
					<li><span title="replies to other posts"><input type="checkbox" prop:checked=get_cfg!(filter replies) on:input=move |ev| {
						let mut mock = config.get();
						mock.filters.replies = event_target_checked(&ev);
						setter.set(mock);
						cache::TIMELINES.clear();
					}/>" replies"</span></li>
					<li><span title="like activities"><input type="checkbox" prop:checked=get_cfg!(filter likes) on:input=set_cfg!(filter likes) />" likes"</span></li>
					<li><span title="create activities with object"><input type="checkbox" prop:checked=get_cfg!(filter creates) on:input=set_cfg!(filter creates)/>" creates"</span></li>
					<li><span title="update activities, to objects or actors"><input type="checkbox" prop:checked=get_cfg!(filter updates) on:input=set_cfg!(filter updates)/>" updates"</span></li>
					<li><span title="delete activities"><input type="checkbox" prop:checked=get_cfg!(filter deletes) on:input=set_cfg!(filter deletes)/>" deletes"</span></li>
					<li><span title="announce activities with object"><input type="checkbox" prop:checked=get_cfg!(filter announces) on:input=set_cfg!(filter announces) />" announces"</span></li>
					<li><span title="follow, accept and reject activities"><input type="checkbox" prop:checked=get_cfg!(filter follows) on:input=set_cfg!(filter follows) />" follows"</span></li>
					<li><span title="objects without a related activity to display"><input type="checkbox" prop:checked=get_cfg!(filter orphans) on:input=set_cfg!(filter orphans) />" fetched"</span></li>
			</ul>
			<hr />
			<div class="border ma-2 pa-1" class:hidden=auth.anonymous()>
				<code class="center cw color mb-1">update profile</code>
				<div class="col-side mb-0">display name</div>
				<div class="col-main">
					<input class="w-100" type="text" node_ref=display_name_ref placeholder="bmdieGo=" value={curr_display_name}/>
				</div>

				<div class="col-side mb-0">avatar url</div>
				<div class="col-main">
					<input class="w-100" type="text" node_ref=avatar_url_ref placeholder="https://cdn.alemi.dev/social/circle-square.png" value={curr_icon} />
				</div>

				<div class="col-side mb-0">banner url</div>
				<div class="col-main">
					<input class="w-100" type="text" node_ref=banner_url_ref placeholder="https://cdn.alemi.dev/social/gradient.png" value={curr_banner} />
				</div>

				<div class="col-side mb-0">summary</div>
				<div class="col-main">
					<textarea rows="5" class="w-100" node_ref=summary_ref placeholder="when you lose control of yourself, who's controlling you?">{curr_summary}</textarea>
				</div>

				<input class="w-100" type="submit" value="submit"
					on:click=move|e| {
						e.prevent_default();
						let display_name = display_name_ref.get()
							.map(|x| x.value())
							.filter(|x| !x.is_empty());

						let summary = summary_ref.get()
							.map(|x| x.value())
							.filter(|x| !x.is_empty());

						let avatar = avatar_url_ref.get()
							.map(|x| x.value())
							.filter(|x| !x.is_empty())
							.map(|x|
								apb::new()
									.set_document_type(Some(apb::DocumentType::Image))
									.set_url(apb::Node::link(x))
							);

						let banner = banner_url_ref.get()
							.map(|x| x.value())
							.filter(|x| !x.is_empty())
							.map(|x|
								apb::new()
									.set_document_type(Some(apb::DocumentType::Image))
									.set_url(apb::Node::link(x))
							);
						
						let id = auth.userid.get_untracked().unwrap_or_default();
						let Some(me) = cache::OBJECTS.get(&id) else {
							tracing::error!("self user not in cache! can't update");
							return;
						};

						let payload = apb::new()
							.set_activity_type(Some(apb::ActivityType::Update))
							.set_to(apb::Node::links(vec![apb::target::PUBLIC.to_string(), format!("{id}/followers")]))
							.set_object(apb::Node::object(
								(*me).clone()
									.set_name(display_name)
									.set_summary(summary)
									.set_icon(apb::Node::maybe_object(avatar))
									.set_image(apb::Node::maybe_object(banner))
									.set_published(Some(chrono::Utc::now()))
							));

						leptos::task::spawn_local(async move {
							if let Err(e) = Http::post(&auth.outbox(), &payload, auth).await {
								tracing::error!("could not send update activity: {e}");
							}
						});
					}
				/>
			</div>
		</div>
	}
}

fn parse_hex(hex: &str) -> String {
	if hex.len() < 7 { return "0, 0, 0".into(); }
	let r = i64::from_str_radix(&hex[1..3], 16).unwrap_or_default();
	let g = i64::from_str_radix(&hex[3..5], 16).unwrap_or_default();
	let b = i64::from_str_radix(&hex[5..7], 16).unwrap_or_default();
	format!("{r}, {g}, {b}")
}