upub-web 0.6.0

web frontend for upub
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
use apb::{Collection, Object};
use leptos::{either::Either, prelude::*};
use leptos_router::{components::*, hooks::{use_location, use_params}, path};
use crate::prelude::*;

use leptos_use::{
	signal_debounced, storage::use_local_storage, use_cookie_with_options, use_element_size, use_window_scroll,
	UseCookieOptions, UseElementSizeReturn
};

#[component]
pub fn App() -> impl IntoView {
	let (token, set_token) = use_cookie_with_options::<String, codee::string::FromToStringCodec>(
		"token",
		UseCookieOptions::default()
			.same_site(cookie::SameSite::Strict)
			// .secure(true)
			.path("/")
	);
	let (userid, set_userid) = use_cookie_with_options::<String, codee::string::FromToStringCodec>(
		"user_id",
		UseCookieOptions::default()
			.same_site(cookie::SameSite::Strict)
			// .secure(true)
			.path("/")
	);
	let (config, set_config, _) = use_local_storage::<crate::Config, codee::string::JsonSerdeCodec>("config");

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

	let auth = Auth { token, userid };

	let (be_version, set_be_version) = signal("?.?.?".to_string());
	leptos::task::spawn_local(async move {
		match Http::fetch::<serde_json::Value>(&format!("{URL_BASE}/nodeinfo/2.0.json"), auth).await {
			Err(e) => tracing::error!("failed fetching backend version: {e} - {e:?}"),
			Ok(nodeinfo) => {
				if let Some(version) = nodeinfo
					.get("software")
					.and_then(|x| x.get("version"))
					.and_then(|x| x.as_str())
				{
					set_be_version.set(version.to_string());
				}
			},
		}
	});

	provide_context(auth);
	provide_context(config);
	provide_context(privacy);

	let reply_controls = ReplyControls::default();
	provide_context(reply_controls);

	let list_controls = ListControls::default();
	provide_context(list_controls);
	Effect::watch(
		move || auth.present(),
		move |present, _present_before, _state| {
			if *present {
				list_controls.fetch(auth);
			}
		},
		true,
	);

	let screen_width = document().body().map(|x| x.client_width()).unwrap_or_default();
	tracing::info!("detected width of {screen_width}");

	let (menu, set_menu) = signal(screen_width < 768);
	let (advanced, set_advanced) = signal(false);

	let title_target = move || if auth.present() { "/web/home" } else { "/web/global" };

	// refresh token immediately and  every hour
	let refresh_token = move || leptos::task::spawn_local(async move { Auth::refresh(auth, set_token, set_userid).await; });
	refresh_token();
	set_interval(refresh_token, std::time::Duration::from_secs(3600));

	// refresh notifications
	let (notifications, set_notifications) = signal(0);
	provide_context((notifications, set_notifications));
	let fetch_notifications = move || leptos::task::spawn_local(async move {
		if let Some(actor_id) = userid.get_untracked() {
			let notif_url = format!("{actor_id}/notifications");
			match Http::fetch::<serde_json::Value>(&notif_url, auth).await {
				Err(e) => tracing::error!("failed fetching notifications: {e}"),
				Ok(doc) => if let Ok(count) = doc.total_items() {
					set_notifications.set(count);
				},
			} 
		}
	});
	set_interval(fetch_notifications, std::time::Duration::from_secs(60));
	Effect::watch(
		move || auth.present(),
		move |present, _present_before, _state| {
			if *present {
				fetch_notifications();
			}
		},
		true,
	);

	view! {
		<nav class="w-100 mt-1 mb-1 pb-s">
			<code class="color ml-3" ><a class="upub-title" href=title_target >μpub</a></code>
			<small class="ml-1 mr-1 hidden-on-tiny" ><a class="clean" href="/web/global" >micro social network, federated</a></small>
			/* TODO kinda jank with the float but whatever, will do for now */
			<input type="submit" class="mr-2 rev" on:click=move |_| set_menu.set(!menu.get()) value="menu" style="float: right" />
		</nav>
		<hr class="sep sticky" />
		<div class="container mt-2 pt-2" >
			<div class="two-col" >
				<div class="col-side sticky pb-s" class:hidden=menu >
					<Navigator notifications=notifications />
					<hr class="mt-1 mb-1" />
					<LoginBox
						token_tx=set_token
						userid_tx=set_userid
					/>
					<hr class="mt-1 mb-1" />
					<div class:hidden=move || !auth.present() >
						<PrivacySelector getter=privacy setter=set_privacy />
						<hr class="mt-1 mb-1" />
						{move || if advanced.get() { Either::Left(view! {
							<AdvancedPostBox advanced=set_advanced/>
						})} else { Either::Right(view! {
							<PostBox advanced=set_advanced/>
						})}}
						<hr class="only-on-mobile sep mb-0 pb-0" />
					</div>
				</div>
				<div class="col-main" class:w-100=menu >
					<Router>
						<main>
								<Routes fallback=NotFound>
									<Route path=path!("/") view=move || view! { <Redirect path="/web" /> } />
									<ParentRoute path=path!("/web") view=Scrollable >
										<Route path=path!("") view=move ||
											if auth.present() {
												view! { <Redirect path="home" /> }
											} else {
												view! { <Redirect path="local" /> }
											}
										/>

										// main timelines
										<Route path=path!("home") view=move || if auth.present() {
											Either::Left(view! {
												<Loadable
													base=format!("{}/inbox/page", auth.user_id())
													element=move |obj| view! { <Item item=obj sep=true /> }
												/>
											}) 
										} else {
											Either::Right(view! { <Unauthorized /> })
										} />

										<Route path=path!("global") view=move || view! {
											<Loadable
												base=format!("{URL_BASE}/inbox/page")
												element=move |obj| view! { <Item item=obj sep=true /> }
											/>
										} />

										<Route path=path!("local") view=move || view! {
											<Loadable
												base=format!("{URL_BASE}/outbox/page")
												element=move |obj| view! { <Item item=obj sep=true /> }
											/>
										} />

										<Route path=path!("threads") view=ThreadsPage />

										<Route path=path!("lists") view=ListsPage />
										<ParentRoute path=path!("lists/:id") view=ListView >
											<Route path=path!("") view=ListMembers />
											<Route path=path!("feed") view=ListFeed />
										</ParentRoute>

										<Route path=path!("notifications") view=move || if auth.present() {
											Either::Left(view! {
												<Loadable
													base=format!("{}/notifications/page", auth.user_id())
													element=move |obj| view! { <Item item=obj sep=true always=true /> }
												/>
											})
										} else {
											Either::Right(view! { <Unauthorized /> })
										} />

										<Route path=path!("tags/:id") view=move || {
											let params = use_params::<IdParam>();
											let tag = params.get().ok().and_then(|x| x.id).unwrap_or_default();
											view! {
												<Loadable
													base=format!("{URL_BASE}/tags/{tag}/page", )
													element=move |obj| view! { <Item item=obj sep=true always=true /> }
												/>
											}
										} />

										<Route path=path!("groups") view=move || view! {
											<Loadable
												base=format!("{URL_BASE}/groups/page")
												convert=U::Actor
												element=|obj| view! { <ActorBanner object=obj /><hr/> }
											/>
										} />

										// static pages, configs and tools
										<Route path=path!("about") view=AboutPage />
										<Route path=path!("config") view=move || view! { <ConfigPage setter=set_config /> } />
										<Route path=path!("explore") view=DebugPage />
										<Route path=path!("search") view=SearchPage />
										<Route path=path!("register") view=RegisterPage />

										// actors
										<ParentRoute path=path!("actors/:id") view=ActorHeader > // TODO can we avoid this?
											<Route path=path!("") view=ActorPosts />
											<Route path=path!("likes") view=ActorLikes />
											<Route path=path!("following") view=move || view! { <FollowList outgoing=true /> } />
											<Route path=path!("followers") view=move || view! { <FollowList outgoing=false /> } />
										</ParentRoute>

										// objects
										<ParentRoute path=path!("objects/:id") view=ObjectView >
											<Route path=path!("") view=move || {
												let params = use_params::<IdParam>();
												let id = params.get().ok().and_then(|x| x.id).unwrap_or_default();
												let oid = Uri::full(U::Object, &id);
												let context_id = crate::cache::OBJECTS.get(&oid)
													.and_then(|obj| obj.context().id().ok())
													.unwrap_or(oid.clone());
												view! {
													<Loadable
														base=format!("{}/context/page", Uri::api(U::Object, &context_id, false))
														convert=U::Object
														element=move |obj| view! { <Item item=obj always=true slim=true /> }
														thread=oid
													/>
												}
											} />
											<Route path=path!("replies") view=move || {
												let params = use_params::<IdParam>();
												let id = params.get().ok().and_then(|x| x.id).unwrap_or_default();
												let oid = Uri::full(U::Object, &id);
												view! {
													<Loadable
														base=format!("{}/replies/page", Uri::api(U::Object, &oid, false))
														convert=U::Object
														element=move |obj| view! { <Item item=obj always=true slim=true /> }
													/>
												}
											} />
											<Route path=path!("likes") view=move || {
												let params = use_params::<IdParam>();
												let id = params.get().ok().and_then(|x| x.id).unwrap_or_default();
												let oid = Uri::full(U::Object, &id);
												view! {
													<Loadable
														base=format!("{}/likes/page", Uri::api(U::Object, &oid, false))
														element=move |obj| view! { <Item item=obj always=true /> }
													/>
												}
											} />
											// <Route path="announced" view=ObjectAnnounced />
										</ParentRoute>

										// TODO a standalone way to view activities?
										// <Route path="/web/activities/:id" view=move || view! { <ActivityPage tl=context_tl /> } />
									</ParentRoute>
								</Routes>
						</main>
					</Router>
				</div>
			</div>
		</div>
		<footer>
			<div class="sep-top">
				<span class="footer" >"\u{26fc} woven under moonlight :: "<a class="clean" href="https://github.com/alemidev/upub" target="_blank" >"μpub"</a>" :: FE v"{crate::VERSION}" :: BE v"{be_version}" :: "<a class="clean" href="javascript:window.scrollTo({top:0, behavior:'smooth'})">top</a></span>
			</div>
		</footer>
	}
}

#[derive(Debug, Clone, Copy)]
pub(crate) enum FeedRoute {
	Unknown, Home, Global, Server, Threads, Lists, ListFeed, ListMembers, Notifications, User, Following, Followers, ActorLikes, ObjectLikes, Replies, Context
}

impl FeedRoute {
	fn is_refreshable(&self) -> bool {
		!matches!(self, Self::Unknown)
	}
}

#[component]
fn Scrollable() -> impl IntoView {
	let location = use_location();
	// TODO this is terrible!! omg maybe it should receive from context current timeline?? idk this
	//      is awful and i patched it another time instead of doing it properly...
	//      at least im going to provide a route enum to use in other places
	// UPDATE it's a bit less terrible since now we just update an enum but still probs should do
	//        something fancier than this string stuff... now leptos has path segments as structs,
	//        maybe maybe maybe it's accessible to us??
	let (route, set_route) = signal(FeedRoute::Unknown);
	let _ = Effect::watch(
		move || location.pathname.get(),
		move |path, _path_prev, _| {
			if path.contains("/web/home") {
				set_route.set(FeedRoute::Home);
			} else if path.contains("/web/global") {
				set_route.set(FeedRoute::Global);
			} else if path.contains("/web/local") {
				set_route.set(FeedRoute::Server);
			} else if path.contains("/web/threads") {
				set_route.set(FeedRoute::Threads);
			} else if path.starts_with("/web/lists") {
				if let Some("feed") = path.split('/').nth(4) {
						set_route.set(FeedRoute::ListFeed);
				} else if let Some(_id) = path.split('/').nth(3) {
					set_route.set(FeedRoute::ListMembers);
				} else {
					set_route.set(FeedRoute::Lists);
				}
			} else if path.starts_with("/web/notifications") {
				set_route.set(FeedRoute::Notifications);
			} else if path.starts_with("/web/actors") {
				match path.split('/').nth(4) {
					Some("following") => {
						set_route.set(FeedRoute::Following);
					},
					Some("followers") => {
						set_route.set(FeedRoute::Followers);
					},
					Some("likes") => {
						set_route.set(FeedRoute::ActorLikes);
					},
					_ => {
						set_route.set(FeedRoute::User);
					},
				}
			} else if path.starts_with("/web/objects") {
				match path.split('/').nth(4) {
					Some("likes") => {
						set_route.set(FeedRoute::ObjectLikes);
					},
					Some("replies") => {
						set_route.set(FeedRoute::Replies);
					},
					_ => {
						set_route.set(FeedRoute::Context);
					},
				}
			} else {
				set_route.set(FeedRoute::Unknown);
			}
		},
		true
	);
	provide_context(route);
	let breadcrumb = Signal::derive(move || {
		let path = location.pathname.get();
		let mut path_iter = path.split('/').skip(2);
		// TODO wow this breadcrumb logic really isnt nice can we make it better??
		match path_iter.next() {
			Some("actors") => match path_iter.next() {
				None => "actors :: all".to_string(),
				Some(id) => {
					let mut out = "actors :: ".to_string();
					if id.starts_with('+') {
						out.push_str("proxy");
					} else {
						out.push_str(id);
					}
					if let Some(x) = path_iter.next() {
						out.push_str(" :: ");
						out.push_str(x);
					}
					out
				},
			},
			Some("tags") => format!("tags :: {}", path_iter.next().unwrap_or_default()),
			Some(p) => p.to_string(),
			None => "?".to_string(),
		}
	});
	let element = NodeRef::new();
	let should_load = use_scroll_limit(element, 500.0);
	provide_context(should_load);
	let (refresh, set_refresh) = signal(());
	provide_context(refresh);
	provide_context(set_refresh);
	view! {
		<div class="mb-1" node_ref=element>
			<div class="tl-header w-100 center mb-1">
				<a class="breadcrumb mr-1" href="javascript:history.back()" ><b>"<<"</b></a>
				<b>{crate::NAME}</b>" :: "{breadcrumb}
				{move || if route.get().is_refreshable() {
					Some(view! {
						<a class="breadcrumb ml-1" href="#" on:click=move|_| set_refresh.set(())  ><b>""</b></a>
					})
				} else {
					None
				}}
			</div>
			<Outlet />
		</div>
	}
}

#[component]
pub fn NotFound() -> impl IntoView {
	view! {
		<div class="center">
			<h3>nothing to see here!</h3>
			<p><a href="/web"><button type="button">back to root</button></a></p>
		</div>
	}
}

#[component]
pub fn Unauthorized() -> impl IntoView {
	view! {
		<p>
			<code class="color center cw">please log in first</code>
		</p>
	}
}

#[component]
//#[deprecated = "should not be displaying this directly"]
pub fn Loader() -> impl IntoView {
	view! {
		<div class="center mt-1 mb-1" >
			<button type="button" disabled>"loading "<span class="dots"></span></button>
		</div>
	}
}

pub fn use_scroll_limit<T, Marker>(el: NodeRef<T>, offset: f64) -> Signal<bool>
where
	T: leptos::html::ElementType,
	NodeRef<T>: leptos_use::core::IntoElementMaybeSignal<web_sys::Element, Marker>,
{
	let (load, set_load) = signal(false);
	let (_x, y) = use_window_scroll();
	let UseElementSizeReturn { height: screen_height, .. } = use_element_size(document().document_element().expect("could not get DOM"));
	let UseElementSizeReturn { height, .. } = use_element_size(el);
	let scroll_state = Signal::derive(move || (y.get(), height.get(), screen_height.get()));
	let scroll_state_throttled = signal_debounced(
		scroll_state,
		50.
	);
	let _ = Effect::watch(
		move || scroll_state_throttled.get(),
		move |(y, height, screen), _, _| {
			let before = load.get_untracked();
			let after = *height <= *screen || y + screen + offset >= *height;
			let force = *y + screen >= *height;
			if force || after != before || *height < *screen {
				set_load.set(after)
			}
		},
		false,
	);
	load.into()
}