Skip to main content

max / makenotwork

19.8 KB · 549 lines History Blame Raw
1 //! The public creator profile, described.
2 //!
3 //! `/u/{username}`: who the creator is, what they have published, and the four
4 //! ways to keep up with them. It replaces `templates/pages/user.html` and
5 //! `UserTemplate`.
6 //!
7 //! The second of the three pages that wrote `<link rel="alternate">` by hand,
8 //! after [`super::project_blog`]. The screen says it has a feed and
9 //! [`quasi_router::FeedKind::media_type`] spells it.
10 //!
11 //! # Why the route stays an axum handler
12 //!
13 //! [`super::project_blog`]'s reason. The handler resolves a username, refuses a
14 //! sandbox account, loads projects, links, collections and follow state, and
15 //! records a page view unless the caller is a crawler. None of that needs a
16 //! described route, and `super::Viewer` carries no session. What is described
17 //! is the document.
18 //!
19 //! # The creator's theme
20 //!
21 //! `user.html` injected `<style id="creator-theme">` in its `{% block head %}`,
22 //! unlayered so it outranks every named layer.
23 //! [`quasi_router::Document::styled`] is the member for exactly that -- a sheet
24 //! that is data rather than build output -- and `quasi-webview` writes it last
25 //! in the head and outside the layer statement, which is the property the
26 //! hand-written block existed to hold. Nothing keyed off the element's id.
27 //!
28 //! # What stayed markup
29 //!
30 //! The JSON-LD block. It is head markup that is neither
31 //! [`quasi_router::Discovery`] nor [`quasi_router::Document`], and it goes in
32 //! through [`quasi_webview::Shell::with_head`], the way `auth_pages` puts the
33 //! CSRF meta there. Growing `Discovery` a structured-data member is a
34 //! vocabulary change for one tag on three pages, and the tag is already built
35 //! from fields the screen holds.
36
37 use makeover_layout as layout;
38 use quasi_router::screen::Row;
39 use quasi_router::{
40 Act, Action, Document, Feed, FeedKind, Node, RegionKind, Screen as Described, Slot,
41 };
42 use quasi_webview::Webview;
43
44 use crate::types::{Collection, CustomLink, Project, User};
45
46 /// The page's own region, and what the skip link points at.
47 pub const PAGE_REGION: &str = "user-profile";
48
49 /// How wide it runs. The template wrote this on the body.
50 const MEASURE: layout::Measure = layout::Measure::Wide;
51
52 /// Where a creator's feed answers.
53 #[must_use]
54 pub fn feed_path(username: &str) -> String {
55 format!("/u/{username}/rss")
56 }
57
58 /// Everything the screen is about.
59 ///
60 /// A struct rather than sixteen arguments, which is what `UserTemplate`
61 /// carried: the page is a lot of facts about one creator, and a positional list
62 /// of them is a list somebody transposes.
63 pub struct Profile<'a> {
64 /// The creator.
65 pub user: &'a User,
66 /// Their id, which is what the follow route takes.
67 pub user_id: &'a str,
68 /// The site's own base address, for the absolute URLs a crawler and a share
69 /// sheet read.
70 pub host_url: &'a str,
71 /// Their off-site links.
72 pub custom_links: &'a [CustomLink],
73 /// Their public projects.
74 pub projects: &'a [Project],
75 /// Their public collections.
76 pub collections: &'a [Collection],
77 /// How many people follow them.
78 pub follower_count: i64,
79 /// Whether the viewer does.
80 pub is_following: bool,
81 /// Whether the viewer is the creator.
82 pub is_own_profile: bool,
83 /// Whether there is a session at all.
84 pub signed_in: bool,
85 /// Whether the creator has voluntarily paused.
86 pub paused: bool,
87 /// Whether they take tips.
88 pub tips_enabled: bool,
89 }
90
91 impl Profile<'_> {
92 /// The address this page answers at.
93 fn canonical(&self) -> String {
94 format!("{}/u/{}", self.host_url, self.user.username)
95 }
96
97 /// The sentence a link preview shows.
98 ///
99 /// The template's `og:description`, which fell back to a fixed line when
100 /// the creator had written no bio.
101 fn summary(&self) -> String {
102 self.user
103 .bio
104 .as_deref()
105 .filter(|bio| !bio.trim().is_empty())
106 .map_or_else(|| "Creator on Makenotwork".to_owned(), str::to_owned)
107 }
108
109 /// The picture a link preview shows: their avatar, or the site's card.
110 fn image(&self) -> String {
111 self.user
112 .avatar_url
113 .clone()
114 .unwrap_or_else(|| format!("{}/static/images/og-card.png", self.host_url))
115 }
116
117 /// The structured data the template opened a `ProfilePage` block with.
118 ///
119 /// Built from the same three facts the screen already carries, and escaped
120 /// through the same `json_escape` the template called: this is a JSON
121 /// document inside a `<script>`, so HTML escaping would corrupt it and JSON
122 /// escaping is what it needs.
123 fn structured_data(&self) -> String {
124 let mut json = format!(
125 "{{\"@context\":\"https://schema.org\",\"@type\":\"ProfilePage\",\
126 \"mainEntity\":{{\"@type\":\"Person\",\"name\":\"{}\",\"url\":\"{}\"",
127 self.user.display_name_json(),
128 crate::types::json_escape(&self.canonical()),
129 );
130 if self.user.bio.is_some() {
131 use std::fmt::Write as _;
132 let _ = write!(json, ",\"description\":\"{}\"", self.user.bio_json());
133 }
134 json.push_str("}}");
135 format!("<script type=\"application/ld+json\">{json}</script>")
136 }
137 }
138
139 /// The whole document.
140 #[must_use]
141 pub fn screen(profile: &Profile<'_>, theme_css: &str) -> Described {
142 let user = profile.user;
143 let name = user.display_name_or_username();
144 let feed = feed_path(&user.username);
145
146 let mut page = Slot::new(PAGE_REGION, RegionKind::Pane);
147
148 // A creator on a break, said before anything they published: a reader
149 // about to buy is the one who needs it.
150 if profile.paused {
151 page = page.with(Node::banner(
152 layout::Tone::Info,
153 "This creator is currently on break. Existing purchases remain accessible.",
154 ));
155 }
156
157 page = page
158 .with(Node::page(name.to_owned()))
159 .with(Node::text(user.username.clone()));
160
161 if let Some(bio) = user.bio.as_deref().filter(|bio| !bio.trim().is_empty()) {
162 page = page.with(Node::text(bio.to_owned()));
163 }
164
165 if profile.is_own_profile {
166 page = page.with(Node::act(
167 "Edit Profile",
168 Action::get("/dashboard?tab=settings").navigating(),
169 ));
170 }
171
172 // Following is a write, so it needs a session, and a creator does not
173 // follow themselves. Both were the template's conditions.
174 if profile.signed_in && !profile.is_own_profile {
175 page = page.with(super::follow::control(
176 "user",
177 profile.user_id,
178 profile.is_following,
179 profile.follower_count,
180 ));
181 } else if let Some(count) = super::follow::count_only(profile.follower_count) {
182 page = page.with(count);
183 }
184
185 if profile.tips_enabled {
186 page = page.with(super::tip::control(&super::tip::Offer {
187 creator_id: profile.user_id,
188 project_id: None,
189 signed_in: profile.signed_in,
190 }));
191 }
192
193 page = page
194 .with(Node::act(
195 "RSS Feed",
196 Action::get(feed.clone()).navigating(),
197 ))
198 // Already sayable: `Act::copies` is "the value this press puts on the
199 // clipboard", which is the whole of what `data-copy-link` meant. The
200 // value is absolute, because a relative path on a clipboard is not an
201 // address anybody can paste anywhere.
202 .with(Node::Act(
203 Act::new("Copy link", Action::local()).copying(profile.canonical()),
204 ));
205
206 if !profile.custom_links.is_empty() {
207 page = page.with(Node::list(profile.custom_links.iter().map(|link| {
208 Row::new(link.title.clone())
209 .secondary(link.description.clone())
210 // Somewhere else on the web, and the reader is expected to come
211 // back: `External` rather than `Leaving`. The template said the
212 // same thing with `target="_blank" rel="ugc nofollow noopener"`.
213 .activate(Action::external(link.url.clone()))
214 })));
215 }
216
217 if !profile.projects.is_empty() {
218 page = page
219 .with(Node::section("Projects"))
220 .with(Node::list(profile.projects.iter().map(|project| {
221 Row::new(project.title.clone())
222 .meta(format!(
223 "{} - {} items",
224 project.project_type, project.item_count
225 ))
226 .activate(Action::get(format!("/p/{}", project.slug)).navigating())
227 })));
228 }
229
230 if !profile.collections.is_empty() {
231 page = page.with(Node::section("Collections")).with(Node::list(
232 profile.collections.iter().map(|collection| {
233 Row::new(collection.title.clone())
234 .meta(format!("{} items", collection.item_count))
235 .activate(
236 Action::get(format!("/c/{}/{}", user.username, collection.slug))
237 .navigating(),
238 )
239 }),
240 ));
241 }
242
243 // The footer attribution. Content rather than decoration, for the reason
244 // `project_blog` keeps its: it is where a reader on a creator's page finds
245 // out whose platform they are on.
246 page = page.with(Node::Link {
247 text: "Powered by Makenot.work".to_owned(),
248 action: Action::get("/").navigating(),
249 });
250
251 Described::single(format!("{name} - Makenotwork"))
252 .measured(MEASURE)
253 .documented(
254 Document::default()
255 .classed(crate::shell::body_class(MEASURE, &["user-page"]))
256 // Tier 0: the creator's own primitive-layer override, written
257 // last in the head and unlayered, which is where the
258 // hand-written block put it.
259 .styled(theme_css.to_owned()),
260 )
261 .summarised(profile.summary())
262 .illustrated(profile.image())
263 .canonical_at(profile.canonical())
264 .about(quasi_router::SocialKind::Profile)
265 .syndicating(Feed::new(FeedKind::Rss, format!("{name} - RSS Feed"), feed))
266 .with(page)
267 }
268
269 /// The document this screen is drawn in.
270 #[must_use]
271 pub fn renderer(
272 user: Option<&crate::auth::SessionUser>,
273 csrf: Option<&str>,
274 structured_data: &str,
275 ) -> Webview {
276 let csrf = csrf.unwrap_or_default();
277 Webview::new().with_shell(
278 crate::shell::described()
279 .sending("X-CSRF-Token", csrf)
280 .with_body_last(crate::shell::body_last())
281 .with_body_first(format!(
282 "{}{}",
283 crate::shell::skip_link(PAGE_REGION),
284 crate::shell::site_header(user)
285 ))
286 .with_head(format!(
287 "<meta name=\"csrf-token\" content=\"{}\">{structured_data}",
288 crate::helpers::escape_html(csrf)
289 )),
290 )
291 }
292
293 /// Render it.
294 #[must_use]
295 pub fn document(
296 viewer: Option<&crate::auth::SessionUser>,
297 csrf: Option<&str>,
298 profile: &Profile<'_>,
299 theme_css: &str,
300 ) -> String {
301 use quasi_axum::Serves as _;
302
303 let screen = screen(profile, theme_css);
304 renderer(viewer, csrf, &profile.structured_data()).screen(&screen)
305 }
306
307 #[cfg(test)]
308 mod tests {
309 use super::*;
310
311 fn creator() -> User {
312 User {
313 username: "maxj".to_owned(),
314 email: "max@example.com".to_owned(),
315 display_name: Some("Max Johnson".to_owned()),
316 bio: Some("Writes things".to_owned()),
317 avatar_initials: "MJ".to_owned(),
318 avatar_url: None,
319 stripe_connected: false,
320 stripe_account_id: None,
321 stripe_onboarding_complete: false,
322 stripe_payouts_enabled: false,
323 stripe_charges_enabled: false,
324 stripe_tax_enabled: false,
325 tips_enabled: false,
326 }
327 }
328
329 fn profile(user: &User) -> Profile<'_> {
330 Profile {
331 user,
332 user_id: "u1",
333 host_url: "https://makenot.work",
334 custom_links: &[],
335 projects: &[],
336 collections: &[],
337 follower_count: 0,
338 is_following: false,
339 is_own_profile: false,
340 signed_in: false,
341 paused: false,
342 tips_enabled: false,
343 }
344 }
345
346 fn html(profile: &Profile<'_>) -> String {
347 document(None, Some("t"), profile, ":root{--x:1}")
348 }
349
350 /// The tag the template wrote by hand, said by the screen instead, and the
351 /// media type off `FeedKind` rather than out of a template.
352 #[test]
353 fn the_feed_is_declared_once_and_spelled_by_the_vocabulary() {
354 let user = creator();
355 let screen = screen(&profile(&user), "");
356 let feed = screen.discovery.feed.as_ref().expect("declared");
357 assert_eq!(feed.href, "/u/maxj/rss");
358 assert_eq!(feed.title, "Max Johnson - RSS Feed");
359
360 let rendered = html(&profile(&user));
361 assert!(
362 rendered.contains(
363 "<link rel=\"alternate\" type=\"application/rss+xml\" \
364 title=\"Max Johnson - RSS Feed\" href=\"/u/maxj/rss\">"
365 ),
366 "{rendered}"
367 );
368 assert_eq!(
369 rendered.matches("rel=\"alternate\"").count(),
370 1,
371 "{rendered}"
372 );
373 }
374
375 /// A reader clicks a link and a reader's app finds a tag. The template
376 /// offered both and so does this.
377 #[test]
378 fn the_feed_is_offered_to_a_reader_as_well_as_to_their_app() {
379 let user = creator();
380 assert!(html(&profile(&user)).contains(">RSS Feed<"));
381 }
382
383 /// The creator's Tier 0 sheet lands after every stylesheet the shell
384 /// carries, which is the property the unlayered `<style>` block held. A
385 /// rule that landed before them would be overridden by the thing it exists
386 /// to override.
387 #[test]
388 fn the_creator_theme_outranks_the_site_stylesheets() {
389 let user = creator();
390 let rendered = document(None, Some("t"), &profile(&user), ":root{--accent:red}");
391 let theme = rendered.find(":root{--accent:red}").expect("written");
392 let last_sheet = rendered.rfind("/static/style.css").expect("linked");
393 assert!(theme > last_sheet, "the theme landed before style.css");
394 // And outside the layer statement, which is what makes it win without
395 // naming a layer.
396 assert!(
397 rendered.contains("<style>:root{--accent:red}</style>"),
398 "{rendered}"
399 );
400 }
401
402 /// The `ProfilePage` block the template opened, built from the same facts
403 /// and escaped for JSON rather than for HTML.
404 #[test]
405 fn the_page_still_describes_itself_to_a_crawler() {
406 let user = creator();
407 let rendered = html(&profile(&user));
408 assert!(rendered.contains("application/ld+json"), "{rendered}");
409 assert!(rendered.contains("\"@type\":\"ProfilePage\""), "{rendered}");
410 assert!(rendered.contains("\"name\":\"Max Johnson\""), "{rendered}");
411 assert!(
412 rendered.contains("\"url\":\"https://makenot.work/u/maxj\""),
413 "{rendered}"
414 );
415 }
416
417 /// Following is a write, so a signed-out reader gets the count and not the
418 /// control, and a creator does not follow themselves.
419 #[test]
420 fn only_a_reader_who_could_follow_is_offered_the_control() {
421 let user = creator();
422
423 let mut signed_out = profile(&user);
424 signed_out.follower_count = 4;
425 let rendered = html(&signed_out);
426 assert!(!rendered.contains("/api/follow/user/u1"), "{rendered}");
427 assert!(rendered.contains("4 followers"), "{rendered}");
428
429 let mut own = profile(&user);
430 own.signed_in = true;
431 own.is_own_profile = true;
432 let rendered = html(&own);
433 assert!(!rendered.contains("/api/follow/user/u1"), "{rendered}");
434 assert!(rendered.contains("Edit Profile"), "{rendered}");
435
436 let mut other = profile(&user);
437 other.signed_in = true;
438 let rendered = html(&other);
439 assert!(rendered.contains("/api/follow/user/u1"), "{rendered}");
440 }
441
442 /// A creator with no followers is not told so on their own page.
443 #[test]
444 fn a_profile_with_no_followers_says_nothing_about_it() {
445 let user = creator();
446 assert!(!html(&profile(&user)).contains("followers"));
447 }
448
449 /// `Act::copies` is what `data-copy-link` meant, and the value is absolute:
450 /// a relative path on a clipboard is not an address anybody can paste.
451 #[test]
452 fn the_share_control_copies_the_whole_address() {
453 let user = creator();
454 let rendered = html(&profile(&user));
455 assert!(
456 rendered.contains("data-copies=\"https://makenot.work/u/maxj\""),
457 "{rendered}"
458 );
459 }
460
461 /// A creator's off-site links leave the app and are expected to be come
462 /// back from, which is what the template's `target="_blank"` said.
463 #[test]
464 fn a_custom_link_opens_away_from_the_page() {
465 let user = creator();
466 let links = vec![CustomLink {
467 url: "https://example.com".to_owned(),
468 title: "My shop".to_owned(),
469 description: "Elsewhere".to_owned(),
470 }];
471 let mut with_links = profile(&user);
472 with_links.custom_links = &links;
473 let rendered = html(&with_links);
474 assert!(rendered.contains("https://example.com"), "{rendered}");
475 assert!(rendered.contains("target=\"_blank\""), "{rendered}");
476 assert!(rendered.contains("noopener"), "{rendered}");
477 }
478
479 /// Every project and every collection keeps its row and its address.
480 #[test]
481 fn the_published_work_keeps_its_rows_and_its_addresses() {
482 let user = creator();
483 let projects = vec![Project {
484 id: "p1".to_owned(),
485 slug: "blue-hour".to_owned(),
486 title: "Blue Hour".to_owned(),
487 description: String::new(),
488 item_count: 3,
489 project_type: "Music".to_owned(),
490 cover_image_url: None,
491 }];
492 let collections = vec![Collection {
493 id: "c1".to_owned(),
494 slug: "favourites".to_owned(),
495 title: "Favourites".to_owned(),
496 description: None,
497 is_public: true,
498 item_count: 2,
499 created_at: String::new(),
500 }];
501 let mut full = profile(&user);
502 full.projects = &projects;
503 full.collections = &collections;
504
505 let rendered = html(&full);
506 assert!(rendered.contains("Blue Hour"), "{rendered}");
507 assert!(rendered.contains("/p/blue-hour"), "{rendered}");
508 assert!(rendered.contains("3 items"), "{rendered}");
509 assert!(rendered.contains("Favourites"), "{rendered}");
510 assert!(rendered.contains("/c/maxj/favourites"), "{rendered}");
511 }
512
513 /// A creator on a break says so above their work, which is where the
514 /// template put it: a reader about to buy is the one who needs it.
515 #[test]
516 fn a_paused_creator_says_so_before_anything_they_published() {
517 let user = creator();
518 let mut paused = profile(&user);
519 paused.paused = true;
520 let rendered = html(&paused);
521 let notice = rendered.find("currently on break").expect("said");
522 let name = rendered.rfind("Max Johnson").expect("drawn");
523 assert!(notice < name, "the notice landed under the work");
524 }
525
526 /// A creator with no bio still gets a preview sentence, which is what the
527 /// template's `{% else %}` wrote.
528 #[test]
529 fn a_creator_with_no_bio_still_previews() {
530 let mut user = creator();
531 user.bio = None;
532 let rendered = html(&profile(&user));
533 assert!(
534 rendered.contains("content=\"Creator on Makenotwork\""),
535 "{rendered}"
536 );
537 }
538
539 /// `736f45a5`: none of the four spellings.
540 #[test]
541 fn the_page_spells_no_spinner() {
542 let user = creator();
543 let rendered = html(&profile(&user));
544 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
545 assert!(!rendered.contains(spelling), "{spelling} survives");
546 }
547 }
548 }
549