8ae997da4a23609f42421f9fbc031e904a9570d8 / frogue / src / views / profile.rs
Raw
use actix_web::HttpResponse;
use maud::{Markup, Render, html};
use tracing::instrument;
use super::{
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,
}
impl Render for NoteView {
fn render(&self) -> Markup {
html! {
article class="note" {
header{
(&self.actor_profile)
" - "
(components::DateTime::from(&self.note.created_at))
}
main {
(maud::PreEscaped(&self.note.content_html))
}
footer {
@if !self.reactions.is_empty() {
span class="note-reactions" {
span class="reaction-count" { (self.reactions.likes) " ❤️" }
span class="reaction-count" { (self.reactions.announces) " 🔁" }
@for (emoji, count) in &self.reactions.emojis {
span class="reaction-count" { (count) " " (emoji) }
}
}
}
}
}
}
}
}
/// 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()
}