426a121f23b76660d4e726c990a4ce183c1c0384 / frogue / src / views / notes.rs
Raw
use super::components;
use crate::activitypub::notes::NoteReactions;
use maud::{Markup, PreEscaped, Render, html};
use url::Url;
/// The target for note actions (like/announce endpoints).
#[derive(Debug, Clone)]
pub enum ActionTarget {
/// Local note: /~{user}/notes/{uuid}/react|announce
Local { user: String, uuid: String },
/// Remote note: /remote-notes/{id}/react|announce
Remote { id: i64 },
}
impl ActionTarget {
/// Get the react endpoint URL.
pub fn react_url(&self) -> String {
match self {
Self::Local { user, uuid } => format!("/~{user}/notes/{uuid}/react"),
Self::Remote { id } => format!("/remote-notes/{id}/react"),
}
}
/// Get the unreact endpoint URL.
pub fn unreact_url(&self) -> String {
match self {
Self::Local { user, uuid } => format!("/~{user}/notes/{uuid}/unreact"),
Self::Remote { id } => format!("/remote-notes/{id}/unreact"),
}
}
/// Get the announce endpoint URL.
pub fn announce_url(&self) -> String {
match self {
Self::Local { user, uuid } => format!("/~{user}/notes/{uuid}/announce"),
Self::Remote { id } => format!("/remote-notes/{id}/announce"),
}
}
/// Get the unannounce endpoint URL.
pub fn unannounce_url(&self) -> String {
match self {
Self::Local { user, uuid } => format!("/~{user}/notes/{uuid}/unannounce"),
Self::Remote { id } => format!("/remote-notes/{id}/unannounce"),
}
}
}
/// Actions available for a note (react/boost state).
#[derive(Debug, Clone)]
pub struct NoteActions {
/// The target for form actions.
pub target: ActionTarget,
/// Whether the viewer has reacted (liked).
pub reacted: bool,
/// Whether the viewer has announced (boosted).
pub boosted: bool,
/// Whether the viewer is the author (suppresses React, keeps Announce).
pub is_author: bool,
}
/// A note author display.
pub struct NoteAuthor {
pub display_name: String,
pub handle: NoteAuthorHandle,
pub icon_url: Option<String>,
}
impl Render for NoteAuthor {
fn render(&self) -> Markup {
html! {
span class="note-author" {
(Avatar {
icon_url: self.icon_url.clone(),
alt: self.display_name.clone(),
})
(self.handle)
}
}
}
}
/// Avatar image with fallback to frogue logo
pub struct Avatar {
pub icon_url: Option<String>,
pub alt: String,
}
impl Render for Avatar {
fn render(&self) -> Markup {
let src = self
.icon_url
.as_deref()
.unwrap_or("/static/frogue-logo.svg");
html! {
img.avatar src=(src) alt=(self.alt) loading="lazy" width="24" height="24" {}
}
}
}
/// A note author handle (local or remote).
pub enum NoteAuthorHandle {
Local {
username: String,
actor_url: String,
},
Remote {
username: String,
host: String,
actor_url: String,
},
}
impl Render for NoteAuthorHandle {
fn render(&self) -> Markup {
match self {
Self::Local {
username,
actor_url,
} => {
html! {
a class="note-author-handle frogue-username" href=(actor_url) {
"~" (username)
}
}
}
Self::Remote {
username,
host,
actor_url,
} => {
html! {
span class="note-author-handle" {
a href=(actor_url) {
"@" (username) "@" (host)
}
" "
span class="remote-badge" { "remote" }
}
}
}
}
}
}
/// A unified note component with author, content, permalink, counts, and actions.
pub struct Note {
/// The author display info.
pub author: NoteAuthor,
/// The HTML content (will be rendered as-is, not escaped).
pub content_html: String,
/// Creation timestamp.
pub created_at: components::DateTime,
/// Optional update timestamp (shown as "edited").
pub updated_at: Option<components::DateTime>,
/// Permalink URL.
pub permalink: Url,
/// Reaction counts (likes, announces, emojis).
pub reactions: Option<NoteReactions>,
/// Viewer actions (react/boost state), None if not logged in.
pub actions: Option<NoteActions>,
}
impl Render for Note {
fn render(&self) -> Markup {
html! {
article class="note" {
header {
(self.author)
span class="note-time" {
(self.created_at)
@if let Some(_updated_at) = &self.updated_at {
" "
span class="note-edited" { "(edited)" }
}
}
}
main {
(PreEscaped(&self.content_html))
}
footer {
a href=(self.permalink) { "permalink" }
@if let Some(reactions) = &self.reactions {
@if !reactions.is_empty() {
span class="note-reactions" {
@if reactions.likes > 0 {
span class="reaction-count" { (reactions.likes) " ❤️" }
}
@if reactions.announces > 0 {
span class="reaction-count" { (reactions.announces) " 🔁" }
}
@for (emoji, count) in &reactions.emojis {
span class="reaction-count" { (count) " " (emoji) }
}
}
}
}
@if let Some(actions) = &self.actions {
// React button: hidden for author
@if !actions.is_author {
@if actions.reacted {
form method="post" action=(actions.target.unreact_url()) class="note-action-form" {
button type="submit" class="react-button reacted" { "❤️ Unreact" }
}
} @else {
form method="post" action=(actions.target.react_url()) class="note-action-form" {
button type="submit" class="react-button" { "❤️ React" }
}
}
}
// Announce button: always shown (author can announce)
@if actions.boosted {
form method="post" action=(actions.target.unannounce_url()) class="note-action-form" {
button type="submit" class="react-button reacted" { "🔁 Unboost" }
}
} @else {
form method="post" action=(actions.target.announce_url()) class="note-action-form" {
button type="submit" class="react-button" { "🔁 Boost" }
}
}
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
#[test]
fn test_note_renders_basic_structure() {
let note = Note {
author: NoteAuthor {
display_name: "Alice".to_string(),
handle: NoteAuthorHandle::Local {
username: "alice".to_string(),
actor_url: "/~alice".to_string(),
},
icon_url: None,
},
content_html: "<p>Hello world!</p>".to_string(),
created_at: components::DateTime::from(&Utc::now()),
updated_at: None,
permalink: Url::try_from("http://localhost/notes/1").expect("valid URL"),
reactions: None,
actions: None,
};
let html = note.render().into_string();
assert!(html.contains("<article class=\"note\">"));
assert!(html.contains("<p>Hello world!</p>"));
assert!(html.contains("permalink"));
assert!(html.contains("http://localhost/notes/1"));
assert!(html.contains("~alice"));
}
#[test]
fn test_note_with_reactions() {
let note = Note {
author: NoteAuthor {
display_name: "Bob".to_string(),
handle: NoteAuthorHandle::Remote {
username: "bob".to_string(),
host: "example.com".to_string(),
actor_url: "http://example.com/users/bob".to_string(),
},
icon_url: None,
},
content_html: "<p>Test</p>".to_string(),
created_at: components::DateTime::from(&Utc::now()),
updated_at: None,
permalink: Url::try_from("http://localhost/notes/2").expect("valid URL"),
reactions: Some(NoteReactions {
likes: 3,
announces: 2,
emojis: vec![("🎉".to_string(), 1)],
}),
actions: None,
};
let html = note.render().into_string();
assert!(html.contains("3 ❤️"));
assert!(html.contains("2 🔁"));
assert!(html.contains("🎉"));
}
#[test]
fn test_note_with_actions_hides_react_for_author() {
let note = Note {
author: NoteAuthor {
display_name: "Alice".to_string(),
handle: NoteAuthorHandle::Local {
username: "alice".to_string(),
actor_url: "/~alice".to_string(),
},
icon_url: None,
},
content_html: "<p>My note</p>".to_string(),
created_at: components::DateTime::from(&Utc::now()),
updated_at: None,
permalink: Url::try_from("http://localhost/~alice/notes/abc").expect("valid URL"),
reactions: None,
actions: Some(NoteActions {
target: ActionTarget::Local {
user: "alice".to_string(),
uuid: "abc".to_string(),
},
reacted: false,
boosted: false,
is_author: true,
}),
};
let html = note.render().into_string();
// Should NOT contain React button
assert!(!html.contains("❤️ React"));
assert!(!html.contains("❤️ Unreact"));
// Should contain Boost button
assert!(html.contains("🔁 Boost"));
assert!(html.contains("/~alice/notes/abc/announce"));
}
#[test]
fn test_note_with_actions_shows_react_for_non_author() {
let note = Note {
author: NoteAuthor {
display_name: "Bob".to_string(),
handle: NoteAuthorHandle::Local {
username: "bob".to_string(),
actor_url: "/~bob".to_string(),
},
icon_url: None,
},
content_html: "<p>Bob's note</p>".to_string(),
created_at: components::DateTime::from(&Utc::now()),
updated_at: None,
permalink: Url::try_from("http://localhost/~bob/notes/xyz").expect("valid URL"),
reactions: None,
actions: Some(NoteActions {
target: ActionTarget::Local {
user: "bob".to_string(),
uuid: "xyz".to_string(),
},
reacted: false,
boosted: false,
is_author: false,
}),
};
let html = note.render().into_string();
// Should contain both React and Boost buttons
assert!(html.contains("❤️ React"));
assert!(html.contains("🔁 Boost"));
assert!(html.contains("/~bob/notes/xyz/react"));
assert!(html.contains("/~bob/notes/xyz/announce"));
}
#[test]
fn test_note_with_remote_actions() {
let note = Note {
author: NoteAuthor {
display_name: "Remote User".to_string(),
handle: NoteAuthorHandle::Remote {
username: "remote".to_string(),
host: "example.org".to_string(),
actor_url: "http://example.org/remote".to_string(),
},
icon_url: None,
},
content_html: "<p>Remote note</p>".to_string(),
created_at: components::DateTime::from(&Utc::now()),
updated_at: None,
permalink: Url::try_from("http://example.org/notes/123").expect("valid URL"),
reactions: None,
actions: Some(NoteActions {
target: ActionTarget::Remote { id: 42 },
reacted: true,
boosted: false,
is_author: false,
}),
};
let html = note.render().into_string();
// Should show Unreact (already reacted) and Boost
assert!(html.contains("❤️ Unreact"));
assert!(html.contains("🔁 Boost"));
assert!(html.contains("/remote-notes/42/unreact"));
assert!(html.contains("/remote-notes/42/announce"));
}
#[test]
fn test_note_content_html_is_not_escaped() {
let note = Note {
author: NoteAuthor {
display_name: "Test".to_string(),
handle: NoteAuthorHandle::Local {
username: "test".to_string(),
actor_url: "/~test".to_string(),
},
icon_url: None,
},
content_html: "<strong>Bold</strong> & <em>italic</em>".to_string(),
created_at: components::DateTime::from(&Utc::now()),
updated_at: None,
permalink: Url::try_from("http://localhost/notes/1").expect("valid URL"),
reactions: None,
actions: None,
};
let html = note.render().into_string();
// HTML should be rendered as-is, not escaped
assert!(html.contains("<strong>Bold</strong>"));
assert!(html.contains("<em>italic</em>"));
assert!(html.contains(" & "));
}
}
/// View for displaying a single note as a full page.
pub struct NotePage {
pub note: Note,
pub title: String,
}
impl Render for NotePage {
fn render(&self) -> Markup {
html! {
div class="note-page" {
h1 { (self.title) }
(&self.note)
}
}
}
}