use ellipse::Ellipse;
pub static POSTS_PER_PAGE: usize = 20;
/// Return a pretty location specifier from a geo: URI.
fn decode_geo_uri(uri: &str) -> String {
if let Some(part) = uri.split(':').collect::<Vec<_>>().get(1) {
if let Some(part) = part.split(';').next() {
let mut parts = part.split(',');
let lat = parts.next().unwrap();
let lon = parts.next().unwrap();
// TODO - format them as proper latitude and longitude
format!("{}, {}", lat, lon)
} else {
uri.to_string()
}
} else {
uri.to_string()
}
}
markup::define! {
Entry<'a>(post: &'a serde_json::Value, from_feed: bool) {
@if post.pointer("/properties/like-of").is_none() && post.pointer("/properties/bookmark-of").is_none() {
@FullEntry { post, from_feed: *from_feed }
} else {
// Show a mini-post.
@MiniEntry { post }
}
}
MiniEntry<'a>(post: &'a serde_json::Value) {
article."h-entry mini-entry" {
@if let Some(author) = post["properties"]["author"][0].as_object() {
span."mini-h-card"."u-author" {
a."u-author"[href=author["properties"]["uid"][0].as_str().unwrap()] {
@if let Some(photo) = author["properties"]["photo"][0].as_str() {
img[src=photo, loading="lazy"];
} else if author["properties"]["photo"][0].is_object() {
img[
src=author["properties"]["photo"][0]["value"].as_str().unwrap(),
alt=author["properties"]["photo"][0]["alt"].as_str().unwrap(),
loading="lazy"
];
}
@author["properties"]["name"][0].as_str().unwrap()
}
}
@if post["properties"].as_object().unwrap().contains_key("like-of") {
" "
span."like-icon"["aria-label"="liked"] {
span."like-icon-label"["aria-hidden"="true"] {
"❤️"
}
}
" "
@if let Some(likeof) = post["properties"]["like-of"][0].as_str() {
a."u-like-of"[href=likeof] { @likeof }
} else if let Some(likeof) = post["properties"]["like-of"][0].as_object() {
a."u-like-of"[href=likeof["properties"]["url"][0].as_str().unwrap()] {
@likeof["properties"]["name"][0]
.as_str()
.unwrap_or_else(|| likeof["properties"]["url"][0].as_str().unwrap())
}
}
} else if post["properties"].as_object().unwrap().contains_key("bookmark-of") {
" "
span."bookmark-icon"["aria-label"="bookmarked"] {
span."bookmark-icon-label"["aria-hidden"="true"] {
"🔖"
}
}
" "
@if let Some(bookmarkof) = post["properties"]["bookmark-of"][0].as_str() {
a."u-bookmark-of"[href=bookmarkof] { @bookmarkof }
} else if let Some(bookmarkof) = post["properties"]["bookmark-of"][0].as_object() {
a."u-bookmark-of"[href=bookmarkof["properties"]["url"][0].as_str().unwrap()] {
@bookmarkof["properties"]["name"][0]
.as_str()
.unwrap_or_else(|| bookmarkof["properties"]["url"][0].as_str().unwrap())
}
}
}
" "
a."u-url"."u-uid"[href=post["properties"]["uid"][0].as_str().unwrap()] {
@if let Some(published) = post["properties"]["published"][0].as_str() {
time."dt-published"[datetime=published] {
@chrono::DateTime::parse_from_rfc3339(published)
.map(|dt| dt.format("on %a %b %e %T %Y").to_string())
.unwrap_or("sometime in the past".to_string())
}
} else {
"sometime in the past"
}
}
}
}
}
FullEntry<'a>(post: &'a serde_json::Value, from_feed: bool) {
article."h-entry" {
header.metadata {
@if let Some(name) = post["properties"]["name"][0].as_str() {
h1."p-name" { @name }
}
@if let Some(author) = post["properties"]["author"][0].as_object() {
section."mini-h-card" {
a.larger."u-author"[href=author["properties"]["uid"][0].as_str().unwrap()] {
@if let Some(photo) = author["properties"]["photo"][0].as_str() {
img[src=photo, loading="lazy"];
} else if let Some(photo) = author["properties"]["photo"][0].as_object() {
img[
src=photo["value"].as_str().unwrap(),
alt=photo["alt"].as_str().unwrap(),
loading="lazy"
];
}
@author["properties"]["name"][0].as_str().unwrap()
}
}
}
div {
span {
a."u-url"."u-uid"[href=post["properties"]["uid"][0].as_str().unwrap(), rel=(!from_feed).then_some("canonical")] {
@if let Some(published) = post["properties"]["published"][0].as_str() {
time."dt-published"[datetime=published] {
@chrono::DateTime::parse_from_rfc3339(published)
.map(|dt| dt.format("%a %b %e %T %Y").to_string())
.unwrap_or("sometime in the past".to_string())
}
}
}
}
@if post["properties"]["visibility"][0].as_str().unwrap_or("public") != "public" {
span."p-visibility"[value=post["properties"]["visibility"][0].as_str().unwrap()] {
@post["properties"]["visibility"][0].as_str().unwrap()
}
}
@if post["properties"]["category"].is_array() {
span {
ul.categories {
"Tagged: "
@for cat in post["properties"]["category"].as_array().unwrap() {
li."p-category" { @cat.as_str().unwrap() }
}
}
}
}
@if post["properties"]["in-reply-to"].is_array() {
span {
"In reply to: "
ul.replyctx {
@for ctx in post["properties"]["in-reply-to"].as_array().unwrap() {
@if let Some(ctx) = ctx.as_str() {
li {
a."u-in-reply-to"[href=ctx] {
@ctx.truncate_ellipse(48).as_ref()
}
}
} else if let Some(ctx) = ctx.as_object() {
li {
a."u-in-reply-to"[href=ctx["properties"]["uid"][0]
.as_str()
.unwrap_or_else(|| ctx["properties"]["url"][0].as_str().unwrap())]
{
@ctx["properties"]["uid"][0]
.as_str()
.unwrap_or_else(|| ctx["properties"]["url"][0].as_str().unwrap())
.truncate_ellipse(48)
.as_ref()
}
}
}
}
}
}
}
}
@if post["properties"]["url"].as_array().unwrap().len() > 1 {
hr;
ul {
"Pretty permalinks for this post:"
@for url in post["properties"]["url"].as_array().unwrap().iter().filter(|i| **i != post["properties"]["uid"][0]).map(|i| i.as_str().unwrap()) {
li {
a."u-url"[href=url] { @url }
}
}
}
}
@if let Some(links) = post["properties"]["syndication"].as_array() {
@if !links.is_empty() {
hr;
ul {
"Also published on:"
@for url in links.iter().filter_map(|i| i.as_str()) {
li { a."u-syndication"[href=url] { @url } }
}
}
}
}
@if post["properties"]["location"].is_array() || post["properties"]["checkin"].is_array() {
div {
@if post["properties"]["checkin"].is_array() {
span {
"Check-in to: "
@if post["properties"]["checkin"][0].is_string() {
// It's a URL
a."u-checkin"[href=post["properties"]["checkin"][0].as_str().unwrap()] {
@post["properties"]["checkin"][0].as_str().unwrap().truncate_ellipse(24).as_ref()
}
} else {
a."u-checkin"[href=post["properties"]["checkin"][0]["properties"]["uid"][0].as_str().unwrap()] {
@post["properties"]["checkin"][0]["properties"]["name"][0].as_str().unwrap()
}
}
}
}
@if post["properties"]["location"].is_array() {
span {
"Location: "
@if post["properties"]["location"][0].is_string() {
// It's a geo: URL
// We need to decode it
a."u-location"[href=post["properties"]["location"][0].as_str().unwrap()] {
@decode_geo_uri(post["properties"]["location"][0].as_str().unwrap())
}
} else {
// It's an inner h-geo object
a."u-location"[href=post["properties"]["location"][0]["value"].as_str().map(|x| x.to_string()).unwrap_or(format!("geo:{},{}", post["properties"]["location"][0]["properties"]["latitude"][0].as_str().unwrap(), post["properties"]["location"][0]["properties"]["longitude"][0].as_str().unwrap()))] {
// I'm a lazy bitch
@decode_geo_uri(&post["properties"]["location"][0]["value"].as_str().map(|x| x.to_string()).unwrap_or(format!("geo:{},{}", post["properties"]["location"][0]["properties"]["latitude"][0].as_str().unwrap(), post["properties"]["location"][0]["properties"]["longitude"][0].as_str().unwrap())))
}
}
}
}
}
}
@if post["properties"]["ate"].is_array() || post["properties"]["drank"].is_array() {
div {
@if post["properties"]["ate"].is_array() {
span { ul {
"Ate:"
@for food in post["properties"]["ate"].as_array().unwrap() {
li {
@if food.is_string() {
// If this is a string, it's a URL.
a."u-ate"[href=food.as_str().unwrap()] {
@food.as_str().unwrap().truncate_ellipse(24).as_ref()
}
} else {
// This is a rich food object (mm, sounds tasty! I wanna eat something tasty)
a."u-ate"[href=food["properties"]["uid"][0].as_str().unwrap_or("#")] {
@food["properties"]["name"][0].as_str()
.unwrap_or(food["properties"]["uid"][0].as_str().unwrap_or("#").truncate_ellipse(24).as_ref())
}
}
}
}
} }
}
@if post["properties"]["drank"].is_array() {
span { ul {
"Drank:"
@for food in post["properties"]["drank"].as_array().unwrap() {
li {
@if food.is_string() {
// If this is a string, it's a URL.
a."u-drank"[href=food.as_str().unwrap()] {
@food.as_str().unwrap().truncate_ellipse(24).as_ref()
}
} else {
// This is a rich food object (mm, sounds tasty! I wanna eat something tasty)
a."u-drank"[href=food["properties"]["uid"][0].as_str().unwrap_or("#")] {
@food["properties"]["name"][0].as_str()
.unwrap_or(food["properties"]["uid"][0].as_str().unwrap_or("#").truncate_ellipse(24).as_ref())
}
}
}
}
} }
}
}
}
}
@PhotoGallery { photos: post["properties"]["photo"].as_array() }
@if post["properties"]["content"][0]["html"].is_string() {
main."e-content" {
@markup::raw(post["properties"]["content"][0]["html"].as_str().unwrap().trim())
}
}
@WebInteractions { post }
}
}
VCard<'a>(card: &'a serde_json::Value) {
article."h-card" {
@if card["properties"]["photo"][0].is_string() {
img."u-photo"[src=card["properties"]["photo"][0].as_str().unwrap()];
} else if card["properties"]["photo"][0].is_object() {
img."u-photo"[
src=card["properties"]["photo"][0]["value"].as_str().unwrap(),
alt=card["properties"]["photo"][0]["alt"].as_str().unwrap()
];
}
h1 {
a."u-url"."u-uid"."p-name"[href=card["properties"]["uid"][0].as_str().unwrap()] {
@card["properties"]["name"][0].as_str().unwrap()
}
}
@if card["properties"]["pronoun"].is_array() {
span {
"("
@for (i, pronoun) in card["properties"]["pronoun"].as_array().unwrap().iter().filter_map(|v| v.as_str()).enumerate() {
span."p-pronoun" {
@pronoun
}
// Insert commas between multiple sets of pronouns
@if i < (card["properties"]["pronoun"].as_array().unwrap().len() - 1) {", "}
}
")"
}
}
@if card["properties"]["note"].is_array() {
p."p-note" {
@card["properties"]["note"][0]["value"].as_str().unwrap_or_else(|| card["properties"]["note"][0].as_str().unwrap())
}
}
@if card["properties"]["url"].is_array() {
ul {
"Can be found elsewhere at:"
@for url in card["properties"]["url"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.filter(|v| v != &card["properties"]["uid"][0].as_str().unwrap())
.filter(|v| !card["properties"]["author"][0].as_str().is_some_and(|a| v.starts_with(a)))
{
li { a."u-url"[href=url, rel="me"] { @url } }
}
}
}
}
}
Food<'a>(food: &'a serde_json::Value) {
article."h-food" {
header.metadata {
h1 {
a."p-name"."u-url"[href=food["properties"]["url"][0].as_str().unwrap()] {
@food["properties"]["name"][0].as_str().unwrap()
}
}
}
@PhotoGallery { photos: food["properties"]["photo"].as_array() }
}
}
Feed<'a>(feed: &'a serde_json::Value, cursor: Option<&'a str>) {
div."h-feed" {
div.metadata {
@if feed["properties"]["name"][0].is_string() {
h1."p-name".titanic {
a[href=feed["properties"]["uid"][0].as_str().unwrap(), rel="feed"] {
@feed["properties"]["name"][0].as_str().unwrap()
}
}
}
}
@if feed["children"].is_array() {
@for child in feed["children"].as_array().unwrap() {
@match child["type"][0].as_str().unwrap() {
"h-entry" => { @Entry { post: child, from_feed: true, } }
"h-feed" => { @Feed { feed: child, cursor: None } }
"h-food" => { @Food { food: child } }
//"h-event" => { }
"h-card" => { @VCard { card: child } }
something_else => {
p {
"There's supposed to be an "
@something_else
" object here. But Kittybox can't render it right now."
small { "Sorry! TToTT" }
}
}
}
}
}
@if let Some(cursor) = cursor {
a[rel="prev", href=format!("{}?after={}", feed["properties"]["uid"][0].as_str().unwrap(), cursor)] {
"Older posts"
}
} else {
p {
"Looks like you reached the end. Wanna jump back to the "
a[href=feed["properties"]["uid"][0].as_str().unwrap()] {
"beginning"
} "?"
}
}
}
}
//=======================================
// Components library
//=======================================
PhotoGallery<'a>(photos: Option<&'a Vec<serde_json::Value>>) {
@if let Some(photos) = photos {
@for photo in photos.iter() {
@if let Some(photo) = photo.as_str() {
img."u-photo"[src=photo, loading="lazy"];
} else if photo.is_object() {
@if let Some(thumbnail) = photo["thumbnail"].as_str() {
a."u-photo"[href=photo["value"].as_str().unwrap()] {
img[src=thumbnail,
loading="lazy",
alt=photo["alt"].as_str().unwrap_or("")
];
}
} else {
img."u-photo"[src=photo["value"].as_str().unwrap(),
loading="lazy",
alt=photo["alt"].as_str().unwrap_or("")
];
}
}
}
}
}
WebInteractions<'a>(post: &'a serde_json::Value) {
footer.webinteractions {
p[style="display: none", "aria-hidden"="false"] {
"Webmention counters:"
}
ul.counters {
li {
span."icon like-icon"["aria-label"="likes"] {
span."like-icon-label"["aria-hidden"="true"] {
"❤️"
}
}
span.counter { @post["properties"]["like"].as_array().map(|a| a.len()).unwrap_or(0) }
}
li {
span.icon["aria-label"="replies"] { "💬" }
span.counter { @post["properties"]["comment"].as_array().map(|a| a.len()).unwrap_or(0) }
}
li {
span.icon["aria-label"="reposts"] { "🔄" }
span.counter { @post["properties"]["repost"].as_array().map(|a| a.len()).unwrap_or(0) }
}
li {
span.icon["aria-label"="bookmarks"] { "🔖" }
span.counter { @post["properties"]["bookmark"].as_array().map(|a| a.len()).unwrap_or(0) }
}
}
/*@if (
post["properties"]["like"].as_array().map(|a| a.len()).unwrap_or(0)
+ post["properties"]["bookmark"].as_array().map(|a| a.len()).unwrap_or(0)
+ post["properties"]["repost"].as_array().map(|a| a.len()).unwrap_or(0)
+ post["properties"]["comment"].as_array().map(|a| a.len()).unwrap_or(0)
) > 0 {
details {
summary { "Show comments and reactions" }
// TODO actually render facepiles and comments
@if let Some(likes) = post["properties"]["like"].as_array() {
@if !likes.is_empty() {
// Show a facepile of likes for a post
}
}
@if let Some(bookmarks) = post["properties"]["bookmark"].as_array() {
@if !bookmarks.is_empty() {
// Show a facepile of bookmarks for a post
}
}
@if let Some(reposts) = post["properties"]["repost"].as_array() {
@if !reposts.is_empty() {
// Show a facepile of reposts for a post
}
}
@if let Some(comments) = post["properties"]["comment"].as_array() {
@for comment in comments.iter() {
// Show all the comments recursively (so we could do Salmention with them)
}
}
}
}*/
}
}
}