🐸 Frogue(Fed)

~ololduck/Frogue

Raw

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

use super::{
    ActionTarget, Note, NoteActions, NoteAuthor, NoteAuthorHandle, Page,
    components::{Complaint, TextArea},
};
use crate::{entities::ap_notes, views::components};

/// What can be displayed about a user, for instance to tell who made this note
pub struct ActorProfile {
    pub display_name: String,
    pub handle: String,
    pub host: Option<String>,
    pub profile_url: Option<String>,
}
impl Render for ActorProfile {
    fn render(&self) -> Markup {
        let url = if let Some(url) = &self.profile_url {
            url
        } else {
            &format!("/~{}", self.handle)
        };
        html! {
            span class="note-author" {
                a href=(url) {
                    span class="note-author-display-name" {
                        (self.display_name)
                    }
                    " (@"
                    @if let Some(host) = &self.host {
                        (self.handle)"@"(host)
                    }@else {
                        (self.handle)
                    }
                    ")"
                }
            }
        }
    }
}

/// A note with its reaction counts for display.
pub struct NoteView {
    pub note: ap_notes::Model,
    pub actor_profile: ActorProfile,
    pub reactions: crate::activitypub::notes::NoteReactions,
    /// Whether the current viewer has reacted to this note.
    pub viewer_reacted: bool,
    /// Whether the current viewer has boosted this note.
    pub viewer_boosted: bool,
    /// The public URL of the instance (for building permalinks).
    pub public_url: String,
    /// The viewer's username, if logged in.
    pub viewer_username: Option<String>,
}

impl Render for NoteView {
    fn render(&self) -> Markup {
        let permalink = format!(
            "{}/~{}/notes/{}",
            self.public_url, self.actor_profile.handle, self.note.uuid
        );

        let author = NoteAuthor {
            display_name: self.actor_profile.display_name.clone(),
            handle: if let Some(host) = &self.actor_profile.host {
                NoteAuthorHandle::Remote {
                    username: self.actor_profile.handle.clone(),
                    host: host.clone(),
                }
            } else {
                NoteAuthorHandle::Local {
                    username: self.actor_profile.handle.clone(),
                }
            },
        };

        let is_author = self.viewer_username.as_ref() == Some(&self.actor_profile.handle);

        let actions = self.viewer_username.as_ref().map(|_| NoteActions {
            target: ActionTarget::Local {
                user: self.actor_profile.handle.clone(),
                uuid: self.note.uuid.to_string(),
            },
            reacted: self.viewer_reacted,
            boosted: self.viewer_boosted,
            is_author,
        });

        let permalink_url = url::Url::parse(&permalink)
            .unwrap_or_else(|_| url::Url::parse(&format!("{}/unknown", self.public_url)).unwrap());

        let note = Note {
            author,
            content_html: self.note.content_html.clone(),
            created_at: components::DateTime::from(&self.note.created_at),
            updated_at: None,
            permalink: permalink_url,
            reactions: Some(self.reactions.clone()),
            actions,
        };

        note.render()
    }
}

/// A remote actor that the user is following.
pub struct FollowingEntry {
    pub actor_url: String,
    pub display_name: String,
    pub state: String,
}

/// The body of a profile page: who this user is, and what they keep here.
pub struct ProfileBody<'a> {
    pub name: &'a str,
    pub description: Option<&'a str>,
    pub handle: Option<String>,
    pub follower_count: u64,
    /// Whether the visitor is this user, and so may create repositories.
    pub is_owner: bool,
    pub federation_enabled: bool,
    pub following: Vec<FollowingEntry>,
    pub error_message: Option<String>,
    pub notice_message: Option<String>,
    pub repositories: &'a [RepositorySummary],
    pub notes: &'a [NoteView],
}

impl Render for ProfileBody<'_> {
    fn render(&self) -> Markup {
        html! {
            h2 { (super::components::FrogueUsername(self.name)) }
            @if let Some(description) = self.description {
                p { (description) }
            }
            @if let Some(handle) = &self.handle {
                p class="fediverse-handle" { "Fediverse: " code { (handle) } }
            }
            @if self.follower_count > 0 {
                p class="follower-count" { (self.follower_count) " follower"
                    @if self.follower_count != 1 { "s" }
                }
            }
            @if self.is_owner {
                details {
                    summary {"Actions"}
                    ul {
                        li {
                            a href=(format!("/~{}/repositories/new", self.name)) { "New repository" }
                        }
                        li {
                            a href=(format!("/~{}/ssh-keys", self.name)) { "Manage SSH keys" }
                        }
                        @if self.is_owner && self.federation_enabled {
                            li {
                                details {
                                    summary { "Follow a remote user" }
                                    form method="post" action=(format!("/~{}/follow", self.name)) {
                                        label for="handle" { "Handle or URL" }
                                        input type="text" name="handle" id="handle" placeholder="@user@host or https://..." required;
                                        button type="submit" { "Follow" }
                                    }
                                }
                            }
                        }
                    }
                }
            }

            @if self.is_owner && self.federation_enabled {
                (Complaint(self.error_message.as_deref()))
                @if let Some(notice) = &self.notice_message {
                    div class="notice-message" style="color: green; padding: 0.5em; border: 1px solid green;" { (notice) }
                }
            }
            @if self.is_owner && self.federation_enabled {
                @if !self.following.is_empty() {
                    details {
                        summary {
                         "Following (" (self.follower_count) ") users."
                        }
                        ul class="following" {
                            @for entry in &self.following {
                                li class="following-entry" {
                                    span class="following-display-name" { (entry.display_name) }
                                    " "
                                    span class="following-state" style="color: gray;" { "(" (entry.state) ")" }
                                    form method="post" action=(format!("/~{}/unfollow", self.name)) style="display: inline;" {
                                        input type="hidden" name="actor_url" value=(entry.actor_url);
                                        button type="submit" class="unfollow-button" { "Unfollow" }
                                    }
                                }
                            }
                        }
                    }
                }
            }

            h3 { "Repositories" }
            @if self.repositories.is_empty() {
                p { "Nothing here yet." }
            } @else {
                ul class="repositories" {
                    @for repository in self.repositories {
                        li {
                            (repository)
                        }
                    }
                }
            }

            h3 { "Notes" }
            @if self.is_owner {
                details {
                    summary { "New note" }
                    form method="post" action=(format!("/~{}/notes", self.name)) {
                        (TextArea {
                            label: Some("New note (Markdown supported)"),
                            name: "content",
                            rows: 4,
                            value: None,
                            required: true,
                        })
                        button type="submit" { "Post" }
                    }
                }
            }
            @if self.notes.is_empty() {
                p { "No notes yet." }
            } @else {
                ul class="notes" {
                    @for note_view in self.notes {
                        (note_view)
                    }
                }
            }
        }
    }
}

/// A repository as a listing shows it.
pub struct RepositorySummary {
    pub owner_name: String,
    pub slug: String,
    pub description: Option<String>,
    pub visibility: &'static str,
}

impl Render for RepositorySummary {
    fn render(&self) -> Markup {
        html! {
            div class="repository-listing-entry" {
                a href=(format!("/~{}/{}", self.owner_name, self.slug)) {
                    (self.slug)
                }
                " "
                (super::components::FrogueRepositoryVisibility(self.visibility))
                @if let Some(description) = &self.description {
                    p { (description) }
                }
            }
        }
    }
}

/// The profile page of the user called `name`, listing what they keep here.
#[instrument(skip_all, fields(profile=name, error=error_message, notice=notice_message, federation_enabled=federation_enabled))]
pub fn profile(
    viewer: Option<&str>,
    name: &str,
    description: Option<&str>,
    handle: Option<String>,
    actor_url: Option<String>,
    follower_count: u64,
    federation_enabled: bool,
    following: Vec<FollowingEntry>,
    error_message: Option<String>,
    notice_message: Option<String>,
    repositories: &[RepositorySummary],
    notes: &[NoteView],
) -> HttpResponse {
    let is_owner = viewer == Some(name);

    Page {
        title: format!("~{name} - Frogue"),
        viewer: viewer.map(str::to_string),
        extra_head: actor_url.as_ref().map(|url| {
            format!(
                r#"<link rel="alternate" type="application/activity+json" href="{}">"#,
                url
            )
        }),
        body: ProfileBody {
            name,
            description,
            handle,
            follower_count,
            is_owner,
            federation_enabled,
            following,
            error_message,
            notice_message,
            repositories,
            notes,
        },
    }
    .into_response()
}

/// The body of the profile editing form.
pub struct EditProfileBody<'a> {
    pub name: &'a str,
    pub description: Option<&'a str>,
}

impl Render for EditProfileBody<'_> {
    fn render(&self) -> Markup {
        html! {
            h2 { "Edit " (self.name) }
            form method="post" action=(format!("/~{}/edit", self.name)) {
                (TextArea {
                    label: Some("Description"),
                    name: "description",
                    rows: 8,
                    value: self.description,
                    required: false,
                })
                button type="submit" { "Save" }
            }
        }
    }
}

/// The page editing `name`'s profile.
pub fn edit_profile(viewer: Option<&str>, name: &str, description: Option<&str>) -> HttpResponse {
    Page {
        title: format!("Frogue - Edit {name}"),
        viewer: viewer.map(str::to_string),
        extra_head: None,
        body: EditProfileBody { name, description },
    }
    .into_response()
}