9e4c8060df3b9ccf7c74773f14327d4c289e653c / 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,
}
impl Render for NoteAuthor {
fn render(&self) -> Markup {
html! {
span class="note-author" {
span class="note-author-display-name" { (self.display_name) }
" "
(self.handle)
}
}
}
}
/// A note author handle (local or remote).
pub enum NoteAuthorHandle {
Local { username: String },
Remote { username: String, host: String },
}
impl Render for NoteAuthorHandle {
fn render(&self) -> Markup {
html! {
span class="note-author-handle" {
(match self {
Self::Local { username } => format!("@{username}"),
Self::Remote { username, host } => format!("@{username}@{host}"),
})
}
}
}
}
/// 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(),
},
},
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(),
},
},
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(),
},
},
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(),
},
},
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(),
},
},
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(),
},
},
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)
}
}
}
}