555287fbe2da54538fc82f014941f11804f9e7fe / frogue / src / views / profile.rs
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()
}