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 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 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))) .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)) .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))) .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}