Skip to main content

upub_web/components/
post.rs

1use apb::{ActivityMut, Base, BaseMut, DocumentMut, Object, ObjectMut};
2
3use leptos::prelude::*;
4use crate::prelude::*;
5
6#[derive(Debug, Clone, Copy, Default)]
7pub struct ReplyControls {
8	pub context: RwSignal<Option<String>>,
9	pub reply_to: RwSignal<Option<String>>,
10}
11
12impl ReplyControls {
13	pub fn is_set(&self) -> bool {
14		self.context.get_untracked().is_some() && self.reply_to.get_untracked().is_some()
15	}
16
17	pub fn reply(&self, oid: &str) {
18		if let Some(obj) = cache::OBJECTS.get(oid) {
19			self.context.set(obj.context().id().ok());
20			self.reply_to.set(obj.id().ok().map(|x| x.to_string()));
21		}
22	}
23
24	pub fn clear(&self) {
25		self.context.set(None);
26		self.reply_to.set(None);
27	}
28}
29
30fn post_author(post_id: &str) -> Option<crate::Doc> {
31	let usr = cache::OBJECTS.get(post_id)?.attributed_to().id().ok()?;
32	cache::OBJECTS.get(&usr)
33}
34
35#[derive(Clone)]
36enum TextMatch {
37	Mention {
38		href: String,
39		name: String,
40		domain: String,
41	},
42	Hashtag {
43		name: String,
44	}
45}
46
47pub type PrivacyControl = ReadSignal<Privacy>;
48
49#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, serde::Serialize, serde::Deserialize)]
50pub enum Privacy {
51	Broadcast = 4,
52	Public = 3,
53	Private = 2,
54	Direct = 1,
55}
56
57impl Privacy {
58	pub fn is_public(&self) -> bool {
59		matches!(self, Self::Broadcast | Self::Public)
60	}
61
62	pub fn from_value(v: &str) -> Self {
63		match v {
64			"1" => Self::Direct,
65			"2" => Self::Private,
66			"3" => Self::Public,
67			"4" => Self::Broadcast,
68			_ => panic!("invalid value for privacy"),
69		}
70	}
71
72	pub fn from_addressed(to: &[String], cc: &[String]) -> Self {
73		if to.iter().any(|x| apb::target::is_public(x)) {
74			return Self::Broadcast;
75		}
76		if cc.iter().any(|x| apb::target::is_public(x)) {
77			return Self::Public;
78		}
79		if to.iter().any(|x| x.ends_with("/followers"))
80		|| cc.iter().any(|x| x.ends_with("/followers")) {
81			return Self::Private;
82		}
83
84		Self::Direct
85	}
86
87	pub fn icon(&self) -> &'static str {
88		match self {
89			Self::Broadcast => "đŸ“ĸ",
90			Self::Public => "đŸĒŠ",
91			Self::Private => "🔒",
92			Self::Direct => "📨",
93		}
94	}
95
96	// TODO this is weird... should probably come from core or apb
97	pub fn address(&self, user_id: &str) -> (Vec<String>, Vec<String>) {
98		match self {
99			Self::Broadcast => (
100				vec![apb::target::PUBLIC.to_string()],
101				vec![format!("{user_id}/followers")],
102			),
103			Self::Public => (
104				vec![],
105				vec![apb::target::PUBLIC.to_string(), format!("{user_id}/followers")],
106			),
107			Self::Private => (
108				vec![],
109				vec![format!("{user_id}/followers")],
110			),
111			Self::Direct => (
112				vec![],
113				vec![],
114			),
115		}
116	}
117}
118
119#[component]
120pub fn PrivacySelector(getter: ReadSignal<Privacy>, setter: WriteSignal<Privacy>, #[prop(default = true)] full_width: bool) -> impl IntoView {
121	let auth = use_context::<Auth>().expect("missing auth context");
122	view! {
123		<table class:w-100=full_width class="align">
124			<tr>
125				<td class:w-100=full_width >
126					<input
127						type="range"
128						min="1"
129						max="4"
130						class:w-100=full_width
131						prop:value=move || getter.get() as u8
132						on:input=move |ev| {
133							ev.prevent_default();
134							setter.set(Privacy::from_value(&event_target_value(&ev)));
135					} />
136				</td>
137				<td>
138					{move || {
139						let p = getter.get();
140						let (to, cc) = p.address(&auth.user_id());
141						view! {
142							<PrivacyMarker privacy=p to=to cc=cc big=true />
143						}
144					}}
145				</td>
146			</tr>
147		</table>
148	}
149}
150
151fn attachment_id() -> u64 {
152	static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
153	COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
154}
155
156#[derive(Default, Clone)]
157struct AttachmentInput {
158	id: u64,
159	url_ref: NodeRef<leptos::html::Input>,
160	summary_ref: NodeRef<leptos::html::Input>,
161	media_type_ref: NodeRef<leptos::html::Input>,
162}
163
164#[component]
165pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
166	let auth = use_context::<Auth>().expect("missing auth context");
167	let privacy = use_context::<PrivacyControl>().expect("missing privacy context");
168	let reply = use_context::<ReplyControls>().expect("missing reply controls");
169	let (reply_is_quote, set_reply_is_quote) = signal(false);
170	let (posting, set_posting) = signal(false);
171	let (error, set_error) = signal(None);
172	let (content, set_content) = signal("".to_string());
173	let summary_ref: NodeRef<leptos::html::Input> = NodeRef::new();
174	let (attachments, set_attachments) = signal(vec![]);
175
176	// TODO is this too abusive with resources? im even checking if TLD exists...
177	// TODO debounce this!
178	let mentions = LocalResource::new(
179		move || async move {
180			let c  = content.get();
181			let mut out = Vec::new();
182			for word in c.split(' ') {
183				if word.starts_with('@') {
184					let stripped = word.replacen('@', "", 1);
185					if let Some((name, domain)) = stripped.split_once('@') {
186						if let Some(tld) = domain.split('.').next_back() {
187							if tld::exist(tld) {
188								if let Some(uid) = cache::WEBFINGER.blocking_resolve(name, domain, auth).await {
189									out.push(TextMatch::Mention { name: name.to_string(), domain: domain.to_string(), href: uid });
190								}
191							}
192						}
193					}
194				} else if word.starts_with('#') {
195					out.push(TextMatch::Hashtag { name: word.replacen('#', "", 1) });
196				}
197			}
198			out
199		},
200	);
201
202	view! {
203		<div>
204			{move ||
205				reply.reply_to.get().map(|r| {
206					let actor_strip = post_author(&r).map(|x| view! { <ActorStrip object=x /> });
207					view! {
208						<span class="nowrap">
209							<span 
210								class="cursor emoji emoji-btn mr-s ml-s"
211								on:click=move|_| reply.clear()
212								title={format!("> {r} | ctx: {}", reply.context.get().unwrap_or_default())}
213							>
214								"âœ’ī¸"
215							</span>
216							{actor_strip}
217							<small class="tiny ml-1">"["
218								<a class="clean cursor" title="reply/quote control" on:click=move |_| set_reply_is_quote.set(!reply_is_quote.get()) >
219									{move || if reply_is_quote.get() { "quote" } else { "reply" }}
220								</a>
221							"]"</small>
222						</span>
223					}
224				})
225			}
226			{move ||
227				mentions.get()
228					.map(|x| x
229						.into_iter()
230						.map(|u| match u {
231							TextMatch::Mention { href: ref h, .. } => match cache::OBJECTS.get(h) {
232								Some(u) => view! { <span class="nowrap"><span class="emoji mr-s ml-s">"📨"</span><ActorStrip object=u /></span> }.into_any(),
233								None => view! { <span class="nowrap"><span class="emoji mr-s ml-s">"📨"</span><a href={Uri::web(U::Actor, h)}>{h.to_string()}</a></span> }.into_any(),
234							},
235							TextMatch::Hashtag { name } => view! { <code class="color">#{name}</code> }.into_any(),
236						})
237						.collect_view()
238					)
239			}
240			<table class="align w-100">
241				<tr>
242					<td>
243						<input type="button" value="+" on:click=move |_| {
244							let mut a = attachments.get();
245							a.push(AttachmentInput {
246								id: attachment_id(),
247								..Default::default()
248							});
249							set_attachments.set(a); 
250						} />
251					</td>
252					<td><input type="checkbox" on:input=move |ev| advanced.set(event_target_checked(&ev)) title="toggle advanced controls" /></td>
253					<td class="w-100"><input class="w-100" type="text" node_ref=summary_ref title="summary" /></td>
254				</tr>
255			</table>
256
257			<textarea rows="6" class="w-100" title="content" placeholder="\n look at nothing\n  what do you see?"
258				prop:value=content
259				on:input=move |ev| set_content.set(event_target_value(&ev))
260			></textarea>
261
262			<For
263				each=move || attachments.get()
264				key=|x: &AttachmentInput| x.id
265				children=move |x: AttachmentInput| view! {
266					<table class="align w-100 mb-1">
267						<tr>
268							<td colspan="3"><input type="text" class="w-100" node_ref=x.url_ref title="url" placeholder="attachment url" /></td>
269						</tr>
270						<tr>
271							<td><input type="button" title="remove attachment" on:click=move |_| set_attachments.set(attachments.get().into_iter().filter(|a| a.id != x.id).collect()) value="x" /></td>
272							<td><input type="text" class="w-100" node_ref=x.media_type_ref title="media type" placeholder="media type" /></td>
273							<td><input type="text" class="w-100" node_ref=x.summary_ref title="name (media description)" placeholder="name" /></td>
274						</tr>
275					</table>
276				}
277			/>
278
279			<button class="w-100" prop:disabled=posting type="button" style="height: 3em" on:click=move |_| {
280				let content = content.get_untracked();
281				let attachments_vec = attachments.get_untracked();
282				if content.is_empty() && attachments_vec.is_empty() {
283					set_error.set(Some("missing post body or attachments".to_string()));
284					return;
285				}
286				set_posting.set(true);
287				leptos::task::spawn_local(async move {
288					let summary = get_if_some(summary_ref);
289					let (mut to_vec, cc_vec) = privacy.get_untracked().address(&auth.user_id());
290					let mut mention_tags : Vec<serde_json::Value> = mentions.get_untracked()
291						.unwrap_or_default()
292						.into_iter()
293						.map(|x| match x {
294							TextMatch::Mention { name, domain, href } => {
295								use apb::LinkMut;
296								LinkMut::set_name(apb::new(), Some(format!("@{}@{}", name, domain))) // TODO ewww but name clashes
297									.set_link_type(Some(apb::LinkType::Mention))
298									.set_href(Some(href))
299							},
300							TextMatch::Hashtag { name } => {
301								use apb::LinkMut;
302								let href = format!("{URL_BASE}/tags/{name}");
303								LinkMut::set_name(apb::new(), Some(name)) // TODO ewww but name clashes
304									.set_link_type(Some(apb::LinkType::Hashtag))
305									.set_href(Some(href))
306							}
307						})
308						.collect();
309
310					if let Some(r) = reply.reply_to.get_untracked() {
311						if let Some(au) = post_author(&r) {
312							if let Ok(uid) = au.id() {
313								to_vec.push(uid.to_string());
314								if let Ok(name) = au.name() {
315									let domain = Uri::domain(&uid);
316									mention_tags.push({
317										use apb::LinkMut;
318										LinkMut::set_name(apb::new(), Some(format!("@{}@{}", name, domain))) // TODO ewww but name clashes
319											.set_link_type(Some(apb::LinkType::Mention))
320											.set_href(Some(uid))
321									});
322								}
323							}
324						}
325					}
326					for mention in mentions.get_untracked().as_deref().unwrap_or(&[]) {
327						if let TextMatch::Mention { href, .. } = mention {
328							to_vec.push(href.clone());
329						}
330					}
331					let attachments_node = if attachments_vec.is_empty() {
332						apb::Node::Empty
333					} else {
334						apb::Node::array(
335							attachments_vec
336								.into_iter()
337								.map(|x| (get_if_some(x.url_ref), get_if_some(x.media_type_ref), get_if_some(x.summary_ref)))
338								.filter_map(|(url, ty, sum)| Some((url?, ty?, sum)))
339								.map(|(url, ty, summary)| {
340									let document_type = if let Some((t, _mime)) = ty.split_once('/') {
341										match t {
342											"audio" => apb::DocumentType::Audio,
343											"image" => apb::DocumentType::Image,
344											"video" => apb::DocumentType::Video,
345											_ => apb::DocumentType::Document,
346										}
347									} else {
348										apb::DocumentType::Page
349									};
350
351									apb::new()
352										.set_url(apb::Node::link(url))
353										.set_media_type(Some(ty))
354										.set_name(summary)
355										.set_document_type(Some(document_type))
356								})
357								.collect()
358						)
359					};
360					let payload = apb::new()
361						.set_object_type(Some(apb::ObjectType::Note))
362						.set_attachment(attachments_node)
363						.set_summary(summary)
364						.set_content(Some(content))
365						.set_context(apb::Node::maybe_link(if reply_is_quote.get() { None } else { reply.context.get() }))
366						.set_in_reply_to(apb::Node::maybe_link(if reply_is_quote.get() { None } else { reply.reply_to.get()}))
367						.set_quote_url(apb::Node::maybe_link(if reply_is_quote.get() { reply.reply_to.get() } else { None }))
368						.set_to(apb::Node::links(to_vec))
369						.set_cc(apb::Node::links(cc_vec))
370						.set_tag(apb::Node::array(mention_tags));
371					match Http::post(&auth.outbox(), &payload, auth).await {
372						Err(e) => set_error.set(Some(e.to_string())),
373						Ok(()) => {
374							set_error.set(None);
375							if let Some(x) = summary_ref.get() { x.set_value("") }
376							set_content.set("".to_string());
377							set_attachments.set(vec![]);
378						},
379					}
380					set_posting.set(false);
381				})
382			} >post</button>
383
384			{move|| error.get().map(|x| view! { <blockquote class="mt-s">{x}</blockquote> })}
385		</div>
386	}
387}
388
389#[component]
390pub fn AdvancedPostBox(advanced: WriteSignal<bool>) -> impl IntoView {
391	let auth = use_context::<Auth>().expect("missing auth context");
392	let (posting, set_posting) = signal(false);
393	let (error, set_error) = signal(None);
394	let (value, set_value) = signal("Like".to_string());
395	let (embedded, set_embedded) = signal(false);
396	let sensitive_ref: NodeRef<leptos::html::Input> = NodeRef::new();
397	let summary_ref: NodeRef<leptos::html::Input> = NodeRef::new();
398	let content_ref: NodeRef<leptos::html::Textarea> = NodeRef::new();
399	let context_ref: NodeRef<leptos::html::Input> = NodeRef::new();
400	let target_ref: NodeRef<leptos::html::Input> = NodeRef::new();
401	let name_ref: NodeRef<leptos::html::Input> = NodeRef::new();
402	let reply_ref: NodeRef<leptos::html::Input> = NodeRef::new();
403	let to_ref: NodeRef<leptos::html::Input> = NodeRef::new();
404	let object_id_ref: NodeRef<leptos::html::Input> = NodeRef::new();
405	let bto_ref: NodeRef<leptos::html::Input> = NodeRef::new();
406	let cc_ref: NodeRef<leptos::html::Input> = NodeRef::new();
407	let bcc_ref: NodeRef<leptos::html::Input> = NodeRef::new();
408	view! {
409		<div>
410							 
411				<table class="align w-100">
412					<tr>
413						<td>
414							<input type="checkbox" title="embedded object" on:input=move |ev| {
415								set_embedded.set(event_target_checked(&ev)) 
416							}/>
417						</td>
418						<td>
419							<input type="checkbox" title="advanced" checked on:input=move |ev| {
420								advanced.set(event_target_checked(&ev)) 
421							}/>
422						</td>
423						<td class="w-100">
424							<select class="w-100" on:change=move |ev| set_value.set(event_target_value(&ev))>
425								<SelectOption value is="Create" />
426								<SelectOption value is="Like" />
427								<SelectOption value is="Follow" />
428								<SelectOption value is="Announce" />
429								<SelectOption value is="Accept" />
430								<SelectOption value is="Reject" />
431								<SelectOption value is="Undo" />
432								<SelectOption value is="Delete" />
433								<SelectOption value is="Update" />
434							</select>
435						</td>
436					</tr>
437				</table>
438
439				<input class="w-100" type="text" node_ref=object_id_ref title="objectId" placeholder="objectId" />
440				<input class="w-100" type="text" node_ref=target_ref title="target" placeholder="target" />
441
442				<div class:hidden=move|| !embedded.get()>
443					<input class="w-100" type="text" node_ref=name_ref title="name" placeholder="name" />
444					<input class="w-100" type="text" node_ref=context_ref title="context" placeholder="context" />
445					<input class="w-100" type="text" node_ref=reply_ref title="inReplyTo" placeholder="inReplyTo" />
446
447					<table class="align w-100">
448						<tr>
449							<td><input type="checkbox" title="sensitive" checked node_ref=sensitive_ref/>
450									</td>
451							<td class="w-100">
452								<input class="w-100" type="text" node_ref=summary_ref title="summary" placeholder="summary" />
453							</td>
454						</tr>
455					</table>
456
457					<textarea rows="5" class="w-100" node_ref=content_ref title="content" placeholder="content" ></textarea>
458				</div>
459
460				<table class="w-100 align">
461					<tr>
462						<td class="w-66"><input class="w-100" type="text" node_ref=to_ref title="to" placeholder="to" value=apb::target::PUBLIC /></td>
463						<td class="w-66"><input class="w-100" type="text" node_ref=bto_ref title="bto" placeholder="bto" /></td>
464					</tr>
465					<tr>
466						<td class="w-33"><input class="w-100" type="text" node_ref=cc_ref title="cc" placeholder="cc" value=format!("{}/followers", auth.user_id()) /></td>
467						<td class="w-33"><input class="w-100" type="text" node_ref=bcc_ref title="bcc" placeholder="bcc" /></td>
468					</tr>
469				</table>
470
471				<button class="w-100" type="button" prop:disabled=posting on:click=move |_| {
472					set_posting.set(true);
473					leptos::task::spawn_local(async move {
474						let content = content_ref.get().filter(|x| !x.value().is_empty()).map(|x| x.value());
475						let summary = get_if_some(summary_ref);
476						let name = get_if_some(name_ref);
477						let context = get_if_some(context_ref);
478						let reply = get_if_some(reply_ref);
479						let object_id = get_if_some(object_id_ref);
480						let target = get_if_some(target_ref);
481						let to = get_vec_if_some(to_ref);
482						let bto = get_vec_if_some(bto_ref);
483						let cc = get_vec_if_some(cc_ref);
484						let bcc = get_vec_if_some(bcc_ref);
485						let audience = match reply {
486							Some(ref reply) => crate::cache::OBJECTS.get(reply).and_then(|x| x.audience().id().ok()),
487							None => None,
488						};
489						let payload = apb::new()
490							.set_activity_type(Some(value.get().as_str().try_into().unwrap_or(apb::ActivityType::Create)))
491							.set_to(apb::Node::links(to.clone()))
492							.set_bto(apb::Node::links(bto.clone()))
493							.set_cc(apb::Node::links(cc.clone()))
494							.set_bcc(apb::Node::links(bcc.clone()))
495							.set_target(apb::Node::maybe_link(target))
496							.set_object(
497								if embedded.get() {
498									apb::Node::object(
499										apb::new()
500											.set_id(object_id)
501											.set_object_type(Some(apb::ObjectType::Note))
502											.set_name(name)
503											.set_summary(summary)
504											.set_content(content)
505											.set_in_reply_to(apb::Node::maybe_link(reply))
506											.set_audience(apb::Node::maybe_link(audience))
507											.set_context(apb::Node::maybe_link(context))
508											.set_to(apb::Node::links(to))
509											.set_bto(apb::Node::links(bto))
510											.set_cc(apb::Node::links(cc))
511											.set_bcc(apb::Node::links(bcc))
512									)
513								} else {
514									apb::Node::maybe_link(object_id)
515								}
516							);
517						let target_url = auth.outbox();
518						match Http::post(&target_url, &payload, auth).await {
519							Err(e) => set_error.set(Some(e.to_string())),
520							Ok(()) => set_error.set(None),
521						}
522						set_posting.set(false);
523					})
524				} >post</button>
525			{move|| error.get().map(|x| view! { <blockquote class="mt-s">{x}</blockquote> })}
526		</div>
527	}
528}
529
530fn get_if_some(node: NodeRef<leptos::html::Input>) -> Option<String> {
531	node.get()
532		.map(|x| x.value())
533		.filter(|x| !x.is_empty())
534}
535
536fn get_vec_if_some(node: NodeRef<leptos::html::Input>) -> Vec<String> {
537	node.get()
538		.map(|x| x.value())
539		.filter(|x| !x.is_empty())
540		.map(|x|
541			x.split(',')
542				.map(|x| x.to_string())
543				.collect()
544		).unwrap_or_default()
545}
546
547#[allow(unused)]
548fn get_checked(node: NodeRef<leptos::html::Input>) -> bool {
549	node.get()
550		.map(|x| x.checked())
551		.unwrap_or_default()
552}
553
554#[component]
555fn SelectOption(is: &'static str, value: ReadSignal<String>) -> impl IntoView {
556	view! {
557		<option value=is selected=move || value.get() == is >
558			{is}
559		</option>
560	}
561}