d4c836aef91b0171a994afd57ae487d5ea91d4db / frogue / src / views / index.rs
Raw
use maud::{Markup, Render, html};
use tracing::instrument;
use super::components::Pagination;
use super::{ActionTarget, Note, NoteActions, NoteAuthor, NoteAuthorHandle, Page};
use crate::activitypub::feed::FeedEntry;
use crate::activitypub::notes::NoteReactions;
use crate::registration::RegistrationMode;
pub const FEED_PAGE_SIZE: usize = 20;
/// The landing page for anonymous visitors: explains what Frogue is, shows
/// instance statistics, and indicates whether registration is open.
pub(super) struct Landing {
pub user_count: u64,
pub repository_count: u64,
pub registration_mode: RegistrationMode,
}
impl Render for Landing {
fn render(&self) -> Markup {
html! {
section class="landing-intro" {
h2 { "What is Frogue?" }
p {
"Frogue is a " em { "software forge" } ". It's a place to host and collaborate on software projects,"
"track issues, and review patch proposals. It speaks " a href="https://activitypub.rocks/" { "ActivityPub" }
" and " a href="https://forgefed.org/" { "ForgeFed" } ", so it can federate with other forges and ActivityPub clients."
}
}
section class="landing-stats" {
h2 { "This instance" }
ul {
li { strong { (self.user_count) } " users" }
li { strong { (self.repository_count) } " public repositories" }
}
}
section class="landing-registration" {
h2 { "Join" }
@match self.registration_mode {
RegistrationMode::Open => {
p {
"Registrations are open. "
a href="/register" { "Create an account" }
" or "
a href="/login" { "log in" }
"."
}
}
RegistrationMode::InviteOnly => {
p {
"Registrations are invite-only. Ask an existing user for an invite link, "
"then "
a href="/register" { "register" }
"."
}
}
}
}
}
}
}
/// The body of the index page: when a user is logged in and federation is
/// enabled, show their home feed. Otherwise show the landing page.
pub(super) struct Index {
pub entries: Vec<FeedEntry>,
pub public_url: String,
pub viewer_username: Option<String>,
pub page: u64,
pub landing: Landing,
}
impl Render for Index {
fn render(&self) -> Markup {
if self.viewer_username.is_none() {
// Anonymous visitor: show the landing page
self.landing.render()
} else if self.entries.is_empty() {
// Empty feed - show a message
html! {
p { "Your feed is empty. Follow some remote users or post a note!" }
}
} else {
// Logged in with feed entries
html! {
div class="feed" {
@for entry in &self.entries {
(render_feed_entry(entry, &self.public_url, &self.viewer_username))
}
}
(Pagination {
current_page: self.page,
has_next: self.entries.len() == FEED_PAGE_SIZE,
base_url: "/".to_string(),
})
}
}
}
}
/// Render a single feed entry.
fn render_feed_entry(
entry: &FeedEntry,
public_url: &str,
viewer_username: &Option<String>,
) -> Markup {
let (
permalink,
author,
content_html,
timestamp,
target,
reacted,
boosted,
likes,
announces,
emojis,
) = match entry {
FeedEntry::Local {
id: _,
uuid,
content_html,
created_at,
author_username,
author_icon_url,
reacted,
boosted,
likes,
announces,
emojis,
} => {
let permalink = format!("{public_url}/~{author_username}/notes/{uuid}");
let actor_url = format!("/~{author_username}");
let author = NoteAuthor {
display_name: author_username.clone(),
handle: NoteAuthorHandle::Local {
username: author_username.clone(),
actor_url,
},
icon_url: author_icon_url.clone(),
};
let target = ActionTarget::Local {
user: author_username.clone(),
uuid: uuid.clone(),
};
(
permalink,
author,
content_html.clone(),
*created_at,
target,
*reacted,
*boosted,
*likes,
*announces,
emojis.clone(),
)
}
FeedEntry::Remote {
id: _,
remote_note_id,
object_url,
content_html,
published,
author_username,
author_host,
author_icon_url,
author_actor_url,
reacted,
boosted,
likes,
announces,
emojis,
} => {
let permalink = object_url.clone();
let author = NoteAuthor {
display_name: author_username.clone(),
handle: NoteAuthorHandle::Remote {
username: author_username.clone(),
host: author_host.clone(),
actor_url: author_actor_url.clone(),
},
icon_url: author_icon_url.clone(),
};
let target = ActionTarget::Remote {
id: *remote_note_id,
};
(
permalink,
author,
content_html.clone(),
*published,
target,
*reacted,
*boosted,
*likes,
*announces,
emojis.clone(),
)
}
};
let is_author = match entry {
FeedEntry::Local {
author_username, ..
} => viewer_username.as_ref() == Some(author_username),
FeedEntry::Remote { .. } => false,
};
let actions = viewer_username.as_ref().map(|_| NoteActions {
target,
reacted,
boosted,
is_author,
});
let permalink_url = url::Url::parse(&permalink)
.unwrap_or_else(|_| url::Url::parse(&format!("{public_url}/unknown")).unwrap());
let reactions = if likes == 0 && announces == 0 && emojis.is_empty() {
None
} else {
Some(NoteReactions {
likes,
announces,
emojis,
})
};
let note = Note {
author,
content_html,
created_at: super::components::DateTime::from(×tamp),
updated_at: None,
permalink: permalink_url,
reactions,
actions,
};
note.render()
}
/// The index page, as seen by `viewer` - nobody, when it is `None`.
/// When logged in and federation is enabled, shows the home feed.
/// When not logged in, shows the landing page with instance stats.
#[instrument(skip(entries))]
pub fn index(
viewer: Option<&str>,
entries: Vec<FeedEntry>,
public_url: &str,
page: u64,
user_count: u64,
repository_count: u64,
registration_mode: RegistrationMode,
) -> HttpResponse {
let viewer_username = viewer.map(str::to_string);
let title = if viewer.is_some() {
"Home - Frogue".to_string()
} else {
"Frogue".to_string()
};
Page {
title,
viewer: viewer_username.clone(),
extra_head: None,
body: Index {
entries,
public_url: public_url.to_string(),
viewer_username,
page,
landing: Landing {
user_count,
repository_count,
registration_mode,
},
},
}
.into_response()
}
use actix_web::HttpResponse;