Skip to main content

max / makenotwork

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