🐸 Frogue(Fed)

~ololduck/Frogue

Raw

use maud::{Markup, PreEscaped, Render, html};
use tracing::instrument;

use super::Page;
use super::components::Pagination;
use crate::activitypub::feed::FeedEntry;
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 author_handle = entry.author_handle();
    let content = entry.content();
    let permalink = match entry {
        FeedEntry::Local {
            uuid,
            author_username,
            ..
        } => format!("{public_url}/~{author_username}/notes/{uuid}"),
        FeedEntry::Remote { object_url, .. } => object_url.clone(),
    };

    let timestamp = entry.timestamp();
    let time_str = timestamp.format("%Y-%m-%d %H:%M UTC").to_string();

    let (reacted, boosted, remote_note_id) = match entry {
        FeedEntry::Local { reacted, .. } => (*reacted, false, None),
        FeedEntry::Remote {
            remote_note_id,
            reacted,
            boosted,
            ..
        } => (*reacted, *boosted, Some(*remote_note_id)),
    };

    html! {
        article class="feed-entry" {
            header class="feed-entry-header" {
                strong class="feed-entry-author" { (author_handle) }
                time datetime=(timestamp.to_rfc3339()) { (time_str) }
            }
            div class="feed-entry-content" {
                (PreEscaped(content))
            }
            footer class="feed-entry-footer" {
                a href=(permalink) { "permalink" }
                @if viewer_username.is_some() {
                    @if let Some(note_id) = remote_note_id {
                        @if reacted {
                            form method="post" action=(format!("/remote-notes/{}/unreact", note_id)) {
                                button type="submit" class="react-button reacted" { "❤️ Unreact" }
                            }
                        } @else {
                            form method="post" action=(format!("/remote-notes/{}/react", note_id)) {
                                button type="submit" class="react-button" { "❤️ React" }
                            }
                        }
                        @if boosted {
                            form method="post" action=(format!("/remote-notes/{}/unannounce", note_id)) {
                                button type="submit" class="react-button reacted" { "🔁 Unboost" }
                            }
                        } @else {
                            form method="post" action=(format!("/remote-notes/{}/announce", note_id)) {
                                button type="submit" class="react-button" { "🔁 Boost" }
                            }
                        }
                    }
                }
            }
        }
    }
}

/// 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;