~ololduck/Frogue
feat(webui): harmonize note footers with shared component
Browse the tree at this commit
14 files changed, +1482, β162
README.md+85β7
@@ -22,16 +22,18 @@ There was once IronBridge, which was a pretty advanced unreleased ForgeFed proje
### Out of scope
βThese are features that are not considered planned, be it at the moment or ever.
+These are features that are not considered planned, either at the moment or ever.
β- CI/CD
+- CI/CD (hooks are still planned so you can notify your favorite CI system)
+- Releases: use tags.
+- Pages: no web hosting.
- ???
## Federation status
So, it's still heavily a work in progress but when Federation support is enabled you:
- Have a fediverse handle (e.g., `@alice@forge.example.org`)
β- Publish notes that appear in their followers' feeds (in theory, integration tests pass but it the time of writing i haven't tested in the real world)
+- Publish notes that appear in their followers' feeds
- Receive follows, likes, and announces from other ActivityPub-compatible platforms (same as above)
- Create and federate tickets across ForgeFed-compatible forges
- Submit patches (merge requests) via federation from remote repositories
@@ -48,7 +50,7 @@ Mhhhhh phew that's kind of a hard one. Basically everything apart from the stuff
- `Repositories` and their `PatchTracker`/`IssueTracker` are kinda implemented
- No moderation: you can't block/silence/defederate users/instances for now
- mentions and stuff beyond basic Like/Announce are not yet supported
β- ForgeFed & ActivityPub's integration tests work but have not been really tested in the real world. I have no doubt i'll see some really weird stuff once it's out there.
+- ForgeFed & ActivityPub's integration tests work and AP successfully interacts in the real world but it's still a bit early. I have no doubt i'll see some really weird stuff.
- I'll make a more complete precise list of supported actors/activities later.
## Installation
@@ -56,9 +58,85 @@ Mhhhhh phew that's kind of a hard one. Basically everything apart from the stuff
For now, it's very roots:
1. Clone this repository; `cd` into it
β2. run `cargo build --release`. This will download the internet and produce a binary you can use at `target/release/frogue`.
β3. Run this binary with the appropriate envvars: See [lib.rs](frogue/src/lib.rs).
β4. check everything works
+2. run `make`. This will download the internet and produce a binary at `target/release/frogue`. It is heavily recommended you copy this binary to a "static" path: it should not change, as this path is also used by git hooks when receiving commits. Running `sudo make install` will put this binary at `/usr/local/bin/frogue`.
+3. Run this binary with the appropriate envvars: See [lib.rs](frogue/src/lib.rs). One setup i'd recommend is running via systemd with a `Service` unit file that looks like this:
+ ```ini
+ # /etc/systemd/system/frogue.service
+ [Unit]
+ Description=Frogue ForgeFed server
+ After=network-online.target
+ Wants=network-online.target
+
+ [Service]
+ Type=exec
+ # of course, you need to create the UNIX user
+ User=git
+ Group=git
+ WorkingDirectory=/var/lib/frogue
+ EnvironmentFile=/etc/frogue/frogue.env
+ ExecStart=/usr/local/bin/frogue
+ Restart=on-failure
+ RestartSec=5
+
+ # /var/lib/frogue, created and chowned to git: automatically.
+ StateDirectory=frogue
+ StateDirectoryMode=0750
+
+ # Hardening
+ NoNewPrivileges=yes
+ PrivateTmp=yes
+ ProtectSystem=strict
+ ProtectHome=yes
+ ProtectKernelTunables=yes
+ ProtectKernelModules=yes
+ ProtectControlGroups=yes
+ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
+ RestrictNamespaces=yes
+ RestrictRealtime=yes
+ RestrictSUIDSGID=yes
+ LockPersonality=yes
+ MemoryDenyWriteExecute=yes
+
+ [Install]
+ WantedBy=multi-user.target
+ ```
+
+ `/etc/frogue/frogue.env` could look like this:
+ ```sh
+ # /etc/frogue/frogue.env
+ # Federation / public identity
+ FROGUE_PUBLIC_URL=https://forge.example.com
+
+ # HTTP, localhost only; use nginx/certbot to terminate TLS connections
+ FROGUE_BIND_ADDR=127.0.0.1:6565
+
+ # SSH β bound directly, advertised as git@example.com:2222
+ FROGUE_SSH_BIND_ADDR=0.0.0.0:2222
+ FROGUE_SSH_HOST=example.com:2222
+ FROGUE_SSH_USER=git
+ FROGUE_SSH_HOST_KEY=/var/lib/frogue/ssh_host_ed25519_key
+
+ # Storage
+ FROGUE_REPO_ROOT=/var/lib/frogue/repos
+ FROGUE_DATABASE_URL=sqlite:///var/lib/frogue/frogue.sqlite?mode=rwc
+
+ # Generate once: openssl rand -hex 32
+ # Without it, every restart invalidates all browser sessions.
+ FROGUE_SESSION_SECRET=changemechangemechangemechangemechangemechangemechangeme
+
+ # Invite-only registration
+ FROGUE_REGISTRATION_MODE=invite_only
+ # Optional tuning:
+ #FROGUE_INVITES_VALIDITY_SECONDS=172800
+ #FROGUE_INVITES_ALLOWED_PER_DAY=5
+
+ # Telemetry that can be useful if you want to inspect traces. i use jaeger. no personal data saved.
+ # OTEL_TRACES_EXPORTER=otlp
+ # OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 # example if you use local jaeger
+ # OTEL_SERVICE_NAME=frogue
+ # OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=production
+ ```
+4. check everything works.
Configuration is done via envvar, you can view a list of them and their defaults in [the first lines of `frogue/src/lib.rs`](frogue/src/lib.rs).
frogue/src/activitypub/feed.rs+126β3
@@ -10,6 +10,9 @@ use serde::Serialize;
use crate::entities::prelude::*;
use crate::entities::{ap_actors, ap_following, ap_notes, ap_remote_notes};
+/// Reaction counts for a note: (likes, announces, emoji_reactions)
+type ReactionCounts = (u64, u64, Vec<(String, u64)>);
+
/// A feed entry, either a local note or a remote note.
#[derive(Debug, Clone, Serialize)]
pub enum FeedEntry {
@@ -20,6 +23,10 @@ pub enum FeedEntry {
created_at: DateTime<FixedOffset>,
author_username: String,
reacted: bool,
+ boosted: bool,
+ likes: u64,
+ announces: u64,
+ emojis: Vec<(String, u64)>,
},
Remote {
id: i64,
@@ -31,6 +38,9 @@ pub enum FeedEntry {
author_host: String,
reacted: bool,
boosted: bool,
+ likes: u64,
+ announces: u64,
+ emojis: Vec<(String, u64)>,
},
}
@@ -157,8 +167,11 @@ pub async fn get_home_feed(
published: note.published,
author_username,
author_host,
β reacted: false, // Will be updated below
β boosted: false, // Will be updated below
+ reacted: false, // Will be updated below
+ boosted: false, // Will be updated below
+ likes: 0, // Will be updated below
+ announces: 0, // Will be updated below
+ emojis: Vec::new(), // Will be updated below
});
}
}
@@ -181,7 +194,11 @@ pub async fn get_home_feed(
content_html: note.content_html,
created_at: note.created_at,
author_username: user.name.clone(),
β reacted: false, // Will be updated below
+ reacted: false, // Will be updated below
+ boosted: false, // Will be updated below
+ likes: 0, // Will be updated below
+ announces: 0, // Will be updated below
+ emojis: Vec::new(), // Will be updated below
});
}
@@ -257,6 +274,112 @@ pub async fn get_home_feed(
}
}
+ // Query reaction counts for all notes
+ if !local_note_ids.is_empty() || !remote_note_ids.is_empty() {
+ use crate::entities::ap_reactions;
+ use sea_orm::Condition;
+
+ // Build OR condition for note_id IN or remote_note_id IN
+ let mut or_condition = Condition::any();
+ if !local_note_ids.is_empty() {
+ or_condition =
+ or_condition.add(ap_reactions::Column::NoteId.is_in(local_note_ids.clone()));
+ }
+ if !remote_note_ids.is_empty() {
+ or_condition =
+ or_condition.add(ap_reactions::Column::RemoteNoteId.is_in(remote_note_ids.clone()));
+ }
+
+ let all_reactions = ApReactions::find().filter(or_condition).all(db).await?;
+
+ // Aggregate counts by note
+ let mut local_counts: std::collections::HashMap<i64, ReactionCounts> =
+ std::collections::HashMap::new();
+ let mut remote_counts: std::collections::HashMap<i64, ReactionCounts> =
+ std::collections::HashMap::new();
+
+ for reaction in all_reactions {
+ let is_like = reaction.kind == "like";
+ let is_announce = reaction.kind == "announce";
+
+ if let Some(note_id) = reaction.note_id {
+ let entry = local_counts.entry(note_id).or_insert((0, 0, Vec::new()));
+ if is_like {
+ entry.0 += 1;
+ }
+ if is_announce {
+ entry.1 += 1;
+ }
+ if !is_like
+ && !is_announce
+ && let Some(ref emoji) = reaction.emoji
+ {
+ if let Some(existing) = entry.2.iter_mut().find(|(e, _)| e == emoji) {
+ existing.1 += 1;
+ } else {
+ entry.2.push((emoji.clone(), 1));
+ }
+ }
+ }
+
+ if let Some(remote_note_id) = reaction.remote_note_id {
+ let entry = remote_counts
+ .entry(remote_note_id)
+ .or_insert((0, 0, Vec::new()));
+ if is_like {
+ entry.0 += 1;
+ }
+ if is_announce {
+ entry.1 += 1;
+ }
+ if !is_like
+ && !is_announce
+ && let Some(ref emoji) = reaction.emoji
+ {
+ if let Some(existing) = entry.2.iter_mut().find(|(e, _)| e == emoji) {
+ existing.1 += 1;
+ } else {
+ entry.2.push((emoji.clone(), 1));
+ }
+ }
+ }
+ }
+
+ // Update local notes with counts
+ for entry in &mut local_entries {
+ if let FeedEntry::Local {
+ id,
+ likes,
+ announces,
+ emojis,
+ ..
+ } = entry
+ && let Some((l, a, e)) = local_counts.get(id)
+ {
+ *likes = *l;
+ *announces = *a;
+ *emojis = e.clone();
+ }
+ }
+
+ // Update remote notes with counts
+ for entry in &mut remote_notes {
+ if let FeedEntry::Remote {
+ remote_note_id,
+ likes,
+ announces,
+ emojis,
+ ..
+ } = entry
+ && let Some((l, a, e)) = remote_counts.get(remote_note_id)
+ {
+ *likes = *l;
+ *announces = *a;
+ *emojis = e.clone();
+ }
+ }
+ }
+
// Merge and sort by timestamp descending
let mut all_entries: Vec<FeedEntry> = remote_notes.into_iter().chain(local_entries).collect();
all_entries.sort_by_key(|a| std::cmp::Reverse(a.timestamp()));
frogue/src/activitypub/notes.rs+2β1
@@ -177,6 +177,7 @@ pub async fn list(
}
/// Reaction counts for a note.
+#[derive(Clone)]
pub struct NoteReactions {
pub likes: u64,
pub announces: u64,
@@ -220,7 +221,7 @@ pub async fn get_reactions(
}
let mut emojis: Vec<(String, u64)> = emoji_counts.into_iter().collect();
β emojis.sort_by(|a, b| b.1.cmp(&a.1)); // Sort by count descending
+ emojis.sort_by_key(|a| std::cmp::Reverse(a.1)); // Sort by count descending
Ok(NoteReactions {
likes,
frogue/src/views/index.rs+120β52
@@ -1,9 +1,11 @@
βuse maud::{Markup, PreEscaped, Render, html};
+use maud::{Markup, Render, html};
use tracing::instrument;
use super::Page;
use super::components::Pagination;
+use super::{ActionTarget, Note, NoteActions, NoteAuthor, NoteAuthorHandle};
use crate::activitypub::feed::FeedEntry;
+use crate::activitypub::notes::NoteReactions;
use crate::registration::RegistrationMode;
pub const FEED_PAGE_SIZE: usize = 20;
@@ -106,66 +108,132 @@ fn render_feed_entry(
public_url: &str,
viewer_username: &Option<String>,
) -> Markup {
β let author_handle = entry.author_handle();
β let content = entry.content();
β let permalink = match entry {
+ let (
+ permalink,
+ author,
+ content_html,
+ timestamp,
+ target,
+ reacted,
+ boosted,
+ likes,
+ announces,
+ emojis,
+ ) = match entry {
FeedEntry::Local {
+ id: _,
uuid,
+ content_html,
+ created_at,
author_username,
β ..
β } => format!("{public_url}/~{author_username}/notes/{uuid}"),
β FeedEntry::Remote { object_url, .. } => object_url.clone(),
β };
β
β let timestamp = entry.timestamp();
β let time_str = timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
β
β let (reacted, boosted, remote_note_id) = match entry {
β FeedEntry::Local { reacted, .. } => (*reacted, false, None),
+ reacted,
+ boosted,
+ likes,
+ announces,
+ emojis,
+ } => {
+ let permalink = format!("{public_url}/~{author_username}/notes/{uuid}");
+ let author = NoteAuthor {
+ display_name: author_username.clone(),
+ handle: NoteAuthorHandle::Local {
+ username: author_username.clone(),
+ },
+ };
+ let target = ActionTarget::Local {
+ user: author_username.clone(),
+ uuid: uuid.clone(),
+ };
+ (
+ permalink,
+ author,
+ content_html.clone(),
+ *created_at,
+ target,
+ *reacted,
+ *boosted,
+ *likes,
+ *announces,
+ emojis.clone(),
+ )
+ }
FeedEntry::Remote {
+ id: _,
remote_note_id,
+ object_url,
+ content_html,
+ published,
+ author_username,
+ author_host,
reacted,
boosted,
β ..
β } => (*reacted, *boosted, Some(*remote_note_id)),
+ likes,
+ announces,
+ emojis,
+ } => {
+ let permalink = object_url.clone();
+ let author = NoteAuthor {
+ display_name: author_username.clone(),
+ handle: NoteAuthorHandle::Remote {
+ username: author_username.clone(),
+ host: author_host.clone(),
+ },
+ };
+ let target = ActionTarget::Remote {
+ id: *remote_note_id,
+ };
+ (
+ permalink,
+ author,
+ content_html.clone(),
+ *published,
+ target,
+ *reacted,
+ *boosted,
+ *likes,
+ *announces,
+ emojis.clone(),
+ )
+ }
};
β html! {
β article class="feed-entry" {
β header class="feed-entry-header" {
β strong class="feed-entry-author" { (author_handle) }
β time datetime=(timestamp.to_rfc3339()) { (time_str) }
β }
β div class="feed-entry-content" {
β (PreEscaped(content))
β }
β footer class="feed-entry-footer" {
β a href=(permalink) { "permalink" }
β @if viewer_username.is_some() {
β @if let Some(note_id) = remote_note_id {
β @if reacted {
β form method="post" action=(format!("/remote-notes/{}/unreact", note_id)) {
β button type="submit" class="react-button reacted" { "β€οΈ Unreact" }
β }
β } @else {
β form method="post" action=(format!("/remote-notes/{}/react", note_id)) {
β button type="submit" class="react-button" { "β€οΈ React" }
β }
β }
β @if boosted {
β form method="post" action=(format!("/remote-notes/{}/unannounce", note_id)) {
β button type="submit" class="react-button reacted" { "π Unboost" }
β }
β } @else {
β form method="post" action=(format!("/remote-notes/{}/announce", note_id)) {
β button type="submit" class="react-button" { "π Boost" }
β }
β }
β }
β }
β }
β }
β }
+ let is_author = match entry {
+ FeedEntry::Local {
+ author_username, ..
+ } => viewer_username.as_ref() == Some(author_username),
+ FeedEntry::Remote { .. } => false,
+ };
+
+ let actions = viewer_username.as_ref().map(|_| NoteActions {
+ target,
+ reacted,
+ boosted,
+ is_author,
+ });
+
+ let permalink_url = url::Url::parse(&permalink)
+ .unwrap_or_else(|_| url::Url::parse(&format!("{public_url}/unknown")).unwrap());
+
+ let reactions = if likes == 0 && announces == 0 && emojis.is_empty() {
+ None
+ } else {
+ Some(NoteReactions {
+ likes,
+ announces,
+ emojis,
+ })
+ };
+
+ let note = Note {
+ author,
+ content_html,
+ created_at: super::components::DateTime::from(×tamp),
+ updated_at: None,
+ permalink: permalink_url,
+ reactions,
+ actions,
+ };
+
+ note.render()
}
/// The index page, as seen by `viewer` - nobody, when it is `None`.
frogue/src/views/mod.rs+9β7
@@ -12,11 +12,12 @@ mod comments;
mod commit;
mod commits;
mod compare;
βmod components;
+pub mod components;
mod file;
mod form;
pub mod index;
mod invites;
+mod notes;
mod patches;
pub mod profile;
mod refs;
@@ -35,19 +36,19 @@ const HTML: &str = "text/html; charset=utf-8";
/// A complete html document: the shell every page is served inside, wrapped
/// around the component that makes up its body.
βstruct Page<B: Render> {
β title: String,
+pub struct Page<B: Render> {
+ pub title: String,
/// Who is signed in, if anyone. It decides what the navigation offers;
/// the body of the page is the same either way.
β viewer: Option<String>,
+ pub viewer: Option<String>,
/// Extra HTML to insert into the <head>, e.g. <link rel="alternate">.
β extra_head: Option<String>,
β body: B,
+ pub extra_head: Option<String>,
+ pub body: B,
}
impl<B: Render> Page<B> {
/// Renders the page and pairs it with the content type it is served as.
β fn into_response(self) -> HttpResponse {
+ pub fn into_response(self) -> HttpResponse {
let extra = self.extra_head.as_deref().unwrap_or("");
let document = html! {
(DOCTYPE)
@@ -89,6 +90,7 @@ pub use compare::{PatchSummary, compare, compare_form};
pub use file::{FileDetail, FileDisplay, file};
pub use index::index;
pub use invites::{InviteSummary, invites};
+pub use notes::{ActionTarget, Note, NoteActions, NoteAuthor, NoteAuthorHandle, NotePage};
pub use patches::{
PatchDetail, PatchEditForm, PatchRow, PatchesDetail, patch, patch_form, patches,
};
frogue/src/views/notes.rs+300β57
@@ -1,7 +1,65 @@
use super::components;
βuse maud::{Markup, Render, html};
+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,
@@ -11,13 +69,15 @@ impl Render for NoteAuthor {
fn render(&self) -> Markup {
html! {
span class="note-author" {
β span class="note-author-display-name" {(self.display_name)}
+ 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 },
@@ -27,64 +87,90 @@ 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}")
β }
β })}
+ (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 {
β id: i64,
β author: NoteAuthor,
β content_html: String,
β created_at: components::DateTime,
β updated_at: Option<components::DateTime>,
β permalink: Url,
β reacted: bool,
β boosted: bool,
β logged_in_viewer: Option<String>,
+ /// 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{
+ header {
(self.author)
span class="note-time" {
(self.created_at)
β @if let Some(updated_at) = &self.updated_at {
β (updated_at)
+ @if let Some(_updated_at) = &self.updated_at {
+ " "
+ span class="note-edited" { "(edited)" }
}
}
}
main {
β (self.content_html)
+ (PreEscaped(&self.content_html))
}
footer {
a href=(self.permalink) { "permalink" }
β @if self.logged_in_viewer.is_some() {
β @if self.reacted {
β form method="post" action=(format!("/remote-notes/{}/unreact", self.id)) {
β button type="submit" class="react-button reacted" { "β€οΈ Unreact" }
+
+ @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) }
+ }
}
β } @else {
β form method="post" action=(format!("/remote-notes/{}/react", self.id)) {
β button type="submit" class="react-button" { "β€οΈ React" }
+ }
+ }
+
+ @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" }
+ }
}
}
β @if self.boosted {
β form method="post" action=(format!("/remote-notes/{}/unannounce", self.id)) {
+
+ // 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=(format!("/remote-notes/{}/announce", self.id)) {
+ form method="post" action=(actions.target.announce_url()) class="note-action-form" {
button type="submit" class="react-button" { "π Boost" }
}
}
@@ -97,41 +183,198 @@ impl Render for Note {
#[cfg(test)]
mod tests {
+ use super::*;
use chrono::Utc;
β use super::*;
#[test]
β fn test_sample_note() {
+ fn test_note_renders_basic_structure() {
let note = Note {
β id: 1,
author: NoteAuthor {
β display_name: "Alice In Wonderland".to_string(),
+ display_name: "Alice".to_string(),
handle: NoteAuthorHandle::Local {
username: "alice".to_string(),
},
},
β content_html: "<h1>hiiiiiiiii!</h1><p>Sample note content.</p>".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("Could not parse URL"),
β reacted: false,
β boosted: false,
β logged_in_viewer: None,
+ permalink: Url::try_from("http://localhost/notes/1").expect("valid URL"),
+ reactions: None,
+ actions: None,
};
β let res = note.render().into_string();
β const EXPECTED: &str = "<article class=\"note\">
β <header>
β <span class=\"note-author\">
β <span class=\"note-author-display-name\">Alice In Wonderland</span>
β <span class=\"note-author-handle\">@alice</span>
β </span>
β <span class=\"note-time\">
β <time datetime=\"2026-10-22T10:06:21.083097274+00:00\">2026-10-22T10:06:21.083097274+00:00</time>
β </span>
β </header>
β <main><h1>hiiiiiiiii!</h1><p>Sample note content.</p></main>
β <footer><a href=\"http://localhost/notes/1\">permalink</a></footer>
β </article>";
β assert_eq!(res, EXPECTED.replace("\n", ""));
+
+ 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)
+ }
+ }
}
}
frogue/src/views/profile.rs+50β22
@@ -3,7 +3,7 @@ use maud::{Markup, Render, html};
use tracing::instrument;
use super::{
β Page,
+ ActionTarget, Note, NoteActions, NoteAuthor, NoteAuthorHandle, Page,
components::{Complaint, TextArea},
};
use crate::{entities::ap_notes, views::components};
@@ -48,33 +48,61 @@ pub struct NoteView {
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 {
β html! {
β article class="note" {
β header{
β (&self.actor_profile)
β " - "
β (components::DateTime::from(&self.note.created_at))
β }
β main {
β (maud::PreEscaped(&self.note.content_html))
+ 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(),
}
β 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) }
β }
β }
β }
+ } 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()
}
}
frogue/src/webui/federation.rs+376β1
@@ -312,7 +312,6 @@ pub async fn get_outbox(
}
}
β#[get("/~{user_or_team}/notes/{uuid}")]
pub async fn get_note(
data: web::Data<Arc<RwLock<AppState>>>,
path: web::Path<(String, String)>,
@@ -358,6 +357,165 @@ pub async fn get_note(
}
}
+/// GET /~{user_or_team}/notes/{uuid} with HTML Accept header
+pub async fn get_note_html(
+ data: web::Data<Arc<RwLock<AppState>>>,
+ path: web::Path<(String, String)>,
+ viewer: Option<CurrentUser>,
+) -> HttpResponse {
+ let (user_or_team, uuid_str) = path.into_inner();
+ let state = state_read(&data).await;
+ if !activitypub::enabled(&state) {
+ return HttpResponse::NotFound().finish();
+ }
+
+ let uuid = match uuid::Uuid::parse_str(&uuid_str) {
+ Ok(uuid) => uuid,
+ Err(_) => return HttpResponse::NotFound().finish(),
+ };
+
+ let user = match Users::find()
+ .filter(users::Column::Name.eq(user_or_team.as_str()))
+ .filter(users::Column::DeletedAt.is_null())
+ .one(&state.db)
+ .await
+ {
+ Ok(Some(user)) => user,
+ Ok(None) => return HttpResponse::NotFound().finish(),
+ Err(err) => {
+ error!(%err, user = %user_or_team, "looking up the user failed");
+ return HttpResponse::InternalServerError().finish();
+ }
+ };
+
+ let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
+ Ok(actor) => actor,
+ Err(err) => {
+ error!(%err, user = %user.name, "getting actor failed");
+ return HttpResponse::InternalServerError().finish();
+ }
+ };
+
+ // Fetch the note
+ let note = match crate::entities::prelude::ApNotes::find()
+ .filter(crate::entities::ap_notes::Column::Uuid.eq(uuid))
+ .filter(crate::entities::ap_notes::Column::ActorId.eq(actor.id))
+ .one(&state.db)
+ .await
+ {
+ Ok(Some(note)) => note,
+ Ok(None) => return HttpResponse::NotFound().finish(),
+ Err(err) => {
+ error!(%err, user = %user.name, uuid = %uuid, "fetching note failed");
+ return HttpResponse::InternalServerError().finish();
+ }
+ };
+
+ // Get reactions
+ let reactions = match crate::activitypub::notes::get_reactions(&state.db, note.id).await {
+ Ok(reactions) => reactions,
+ Err(err) => {
+ error!(%err, note_id = note.id, "loading reactions failed");
+ crate::activitypub::notes::NoteReactions {
+ likes: 0,
+ announces: 0,
+ emojis: vec![],
+ }
+ }
+ };
+
+ // Get viewer state
+ let (reacted, boosted, is_author) = if let Some(viewer_user) = &viewer {
+ let is_author = viewer_user.id == user.id;
+
+ let reacted = match crate::entities::prelude::ApReactions::find()
+ .filter(crate::entities::ap_reactions::Column::NoteId.eq(Some(note.id)))
+ .filter(crate::entities::ap_reactions::Column::LocalUserId.eq(Some(viewer_user.id)))
+ .filter(crate::entities::ap_reactions::Column::Kind.eq("like"))
+ .one(&state.db)
+ .await
+ {
+ Ok(Some(_)) => true,
+ Ok(None) => false,
+ Err(err) => {
+ error!(%err, "checking viewer reaction failed");
+ false
+ }
+ };
+
+ let boosted = match crate::entities::prelude::ApReactions::find()
+ .filter(crate::entities::ap_reactions::Column::NoteId.eq(Some(note.id)))
+ .filter(crate::entities::ap_reactions::Column::LocalUserId.eq(Some(viewer_user.id)))
+ .filter(crate::entities::ap_reactions::Column::Kind.eq("announce"))
+ .one(&state.db)
+ .await
+ {
+ Ok(Some(_)) => true,
+ Ok(None) => false,
+ Err(err) => {
+ error!(%err, "checking viewer boost failed");
+ false
+ }
+ };
+
+ (reacted, boosted, is_author)
+ } else {
+ (false, false, false)
+ };
+
+ let public_url = state.public_url().to_string();
+ drop(state);
+
+ let permalink = format!("{}/~{}/notes/{}", public_url, user.name, uuid);
+ let permalink_url = match url::Url::parse(&permalink) {
+ Ok(url) => url,
+ Err(err) => {
+ error!(%err, "parsing permalink failed");
+ return HttpResponse::InternalServerError().finish();
+ }
+ };
+
+ let author = crate::views::NoteAuthor {
+ display_name: user.name.clone(),
+ handle: crate::views::NoteAuthorHandle::Local {
+ username: user.name.clone(),
+ },
+ };
+
+ let actions = viewer.as_ref().map(|_| crate::views::NoteActions {
+ target: crate::views::ActionTarget::Local {
+ user: user.name.clone(),
+ uuid: uuid.to_string(),
+ },
+ reacted,
+ boosted,
+ is_author,
+ });
+
+ let note_component = crate::views::Note {
+ author,
+ content_html: note.content_html,
+ created_at: crate::views::components::DateTime::from(¬e.created_at),
+ updated_at: None,
+ permalink: permalink_url,
+ reactions: Some(reactions),
+ actions,
+ };
+
+ let page = crate::views::NotePage {
+ title: format!("Note by @{}", user.name),
+ note: note_component,
+ };
+
+ crate::views::Page {
+ title: format!("Note by @{}", user.name),
+ viewer: viewer.as_ref().map(|v| v.name.clone()),
+ extra_head: None,
+ body: page,
+ }
+ .into_response()
+}
+
#[derive(serde::Deserialize)]
struct PostNoteForm {
content: String,
@@ -491,6 +649,11 @@ pub async fn post_react(
}
};
+ // Prevent authors from liking their own notes
+ if viewer.id == user.id {
+ return HttpResponse::Forbidden().body("cannot react to your own note");
+ }
+
let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
Ok(actor) => actor,
Err(err) => {
@@ -664,6 +827,218 @@ pub async fn post_unreact(
}
}
+#[post("/~{user_or_team}/notes/{uuid}/announce")]
+pub async fn post_announce(
+ data: web::Data<Arc<RwLock<AppState>>>,
+ path: web::Path<(String, String)>,
+ session: actix_session::Session,
+) -> HttpResponse {
+ let (user_or_team, uuid_str) = path.into_inner();
+ let state = state_read(&data).await;
+ if !activitypub::enabled(&state) {
+ return HttpResponse::NotFound().finish();
+ }
+
+ let viewer_name = match session.get::<String>("user_name") {
+ Ok(Some(name)) => name,
+ Ok(None) => return HttpResponse::Unauthorized().finish(),
+ Err(err) => {
+ error!(%err, "reading session failed");
+ return HttpResponse::InternalServerError().finish();
+ }
+ };
+
+ let uuid = match uuid::Uuid::parse_str(&uuid_str) {
+ Ok(uuid) => uuid,
+ Err(_) => return HttpResponse::NotFound().finish(),
+ };
+
+ let user = match Users::find()
+ .filter(users::Column::Name.eq(user_or_team.as_str()))
+ .filter(users::Column::DeletedAt.is_null())
+ .one(&state.db)
+ .await
+ {
+ Ok(Some(user)) => user,
+ Ok(None) => return HttpResponse::NotFound().finish(),
+ Err(err) => {
+ error!(%err, user = %user_or_team, "looking up the user failed");
+ return HttpResponse::InternalServerError().finish();
+ }
+ };
+
+ let viewer = match Users::find()
+ .filter(users::Column::Name.eq(viewer_name.as_str()))
+ .filter(users::Column::DeletedAt.is_null())
+ .one(&state.db)
+ .await
+ {
+ Ok(Some(user)) => user,
+ Ok(None) => return HttpResponse::Unauthorized().finish(),
+ Err(err) => {
+ error!(%err, "looking up viewer failed");
+ return HttpResponse::InternalServerError().finish();
+ }
+ };
+
+ let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
+ Ok(actor) => actor,
+ Err(err) => {
+ error!(%err, user = %user.name, "getting actor failed");
+ return HttpResponse::InternalServerError().finish();
+ }
+ };
+
+ // Query the note entity directly to get the ID
+ let note = match crate::entities::prelude::ApNotes::find()
+ .filter(crate::entities::ap_notes::Column::Uuid.eq(uuid))
+ .filter(crate::entities::ap_notes::Column::ActorId.eq(actor.id))
+ .one(&state.db)
+ .await
+ {
+ Ok(Some(note)) => note,
+ Ok(None) => return HttpResponse::NotFound().finish(),
+ Err(err) => {
+ error!(%err, user = %user.name, uuid = %uuid, "fetching note failed");
+ return HttpResponse::InternalServerError().finish();
+ }
+ };
+
+ use crate::entities::ap_reactions;
+ use sea_orm::ActiveModelTrait;
+ use sea_orm::Set;
+
+ let reaction = ap_reactions::ActiveModel {
+ note_id: Set(Some(note.id)),
+ kind: Set("announce".to_string()),
+ emoji: Set(None),
+ remote_actor_id: Set(None),
+ local_user_id: Set(Some(viewer.id)),
+ activity_id: Set(None),
+ ..Default::default()
+ };
+
+ match reaction.insert(&state.db).await {
+ Ok(_) => {
+ let profile_url = format!("/~{}", user.name);
+ HttpResponse::SeeOther()
+ .insert_header(("Location", profile_url))
+ .finish()
+ }
+ Err(err) if err.to_string().contains("UNIQUE constraint failed") => {
+ // Already announced, just redirect
+ let profile_url = format!("/~{}", user.name);
+ HttpResponse::SeeOther()
+ .insert_header(("Location", profile_url))
+ .finish()
+ }
+ Err(err) => {
+ error!(%err, "inserting announce failed");
+ HttpResponse::InternalServerError().finish()
+ }
+ }
+}
+
+#[post("/~{user_or_team}/notes/{uuid}/unannounce")]
+pub async fn post_unannounce(
+ data: web::Data<Arc<RwLock<AppState>>>,
+ path: web::Path<(String, String)>,
+ session: actix_session::Session,
+) -> HttpResponse {
+ let (user_or_team, uuid_str) = path.into_inner();
+ let state = state_read(&data).await;
+ if !activitypub::enabled(&state) {
+ return HttpResponse::NotFound().finish();
+ }
+
+ let viewer_name = match session.get::<String>("user_name") {
+ Ok(Some(name)) => name,
+ Ok(None) => return HttpResponse::Unauthorized().finish(),
+ Err(err) => {
+ error!(%err, "reading session failed");
+ return HttpResponse::InternalServerError().finish();
+ }
+ };
+
+ let uuid = match uuid::Uuid::parse_str(&uuid_str) {
+ Ok(uuid) => uuid,
+ Err(_) => return HttpResponse::NotFound().finish(),
+ };
+
+ let user = match Users::find()
+ .filter(users::Column::Name.eq(user_or_team.as_str()))
+ .filter(users::Column::DeletedAt.is_null())
+ .one(&state.db)
+ .await
+ {
+ Ok(Some(user)) => user,
+ Ok(None) => return HttpResponse::NotFound().finish(),
+ Err(err) => {
+ error!(%err, user = %user_or_team, "looking up the user failed");
+ return HttpResponse::InternalServerError().finish();
+ }
+ };
+
+ let viewer = match Users::find()
+ .filter(users::Column::Name.eq(viewer_name.as_str()))
+ .filter(users::Column::DeletedAt.is_null())
+ .one(&state.db)
+ .await
+ {
+ Ok(Some(user)) => user,
+ Ok(None) => return HttpResponse::Unauthorized().finish(),
+ Err(err) => {
+ error!(%err, "looking up viewer failed");
+ return HttpResponse::InternalServerError().finish();
+ }
+ };
+
+ let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
+ Ok(actor) => actor,
+ Err(err) => {
+ error!(%err, user = %user.name, "getting actor failed");
+ return HttpResponse::InternalServerError().finish();
+ }
+ };
+
+ // Query the note entity directly to get the ID
+ let note = match crate::entities::prelude::ApNotes::find()
+ .filter(crate::entities::ap_notes::Column::Uuid.eq(uuid))
+ .filter(crate::entities::ap_notes::Column::ActorId.eq(actor.id))
+ .one(&state.db)
+ .await
+ {
+ Ok(Some(note)) => note,
+ Ok(None) => return HttpResponse::NotFound().finish(),
+ Err(err) => {
+ error!(%err, user = %user.name, uuid = %uuid, "fetching note failed");
+ return HttpResponse::InternalServerError().finish();
+ }
+ };
+
+ use crate::entities::ap_reactions;
+
+ let delete_result = ap_reactions::Entity::delete_many()
+ .filter(ap_reactions::Column::NoteId.eq(Some(note.id)))
+ .filter(ap_reactions::Column::Kind.eq("announce"))
+ .filter(ap_reactions::Column::LocalUserId.eq(Some(viewer.id)))
+ .exec(&state.db)
+ .await;
+
+ match delete_result {
+ Ok(_) => {
+ let profile_url = format!("/~{}", user.name);
+ HttpResponse::SeeOther()
+ .insert_header(("Location", profile_url))
+ .finish()
+ }
+ Err(err) => {
+ error!(%err, "deleting announce failed");
+ HttpResponse::InternalServerError().finish()
+ }
+ }
+}
+
/// POST /remote-notes/{id}/react
///
/// React to a remote note with a Like activity.
frogue/src/webui/mod.rs+16β8
@@ -47,10 +47,20 @@ pub(crate) fn configure(cfg: &mut web::ServiceConfig) {
)
.service(federation::get_followers)
.service(federation::get_outbox)
β .service(federation::get_note)
+ .service(
+ web::resource("/~{user_or_team}/notes/{uuid}")
+ .route(
+ web::get()
+ .guard(federation::accept_ap())
+ .to(federation::get_note),
+ )
+ .route(web::get().to(federation::get_note_html)),
+ )
.service(federation::post_note)
.service(federation::post_react)
.service(federation::post_unreact)
+ .service(federation::post_announce)
+ .service(federation::post_unannounce)
.service(federation::post_react_remote)
.service(federation::post_unreact_remote)
.service(federation::post_announce_remote)
@@ -232,14 +242,12 @@ pub async fn index(
if viewer_name.is_none() || !crate::activitypub::enabled(&state) {
// For anonymous visitors, query instance stats
let (user_count, repository_count) = if viewer_name.is_none() {
β let user_count = Users::find()
β .count(&db)
β .await
β .unwrap_or(0);
+ let user_count = Users::find().count(&db).await.unwrap_or(0);
let repository_count = Repositories::find()
β .filter(crate::entities::repositories::Column::Visibility.eq(
β crate::entities::repositories::Visibility::Public,
β ))
+ .filter(
+ crate::entities::repositories::Column::Visibility
+ .eq(crate::entities::repositories::Visibility::Public),
+ )
.count(&db)
.await
.unwrap_or(0);
frogue/src/webui/user.rs+33β0
@@ -121,10 +121,40 @@ pub(crate) async fn get_profile(
Vec::new()
};
+ // Query viewer's boosted notes
+ let viewer_boosted_note_ids = if let Some(viewer_user) = &viewer {
+ let note_ids: Vec<i64> = notes.iter().map(|n| n.id).collect();
+ if !note_ids.is_empty() {
+ match crate::entities::prelude::ApReactions::find()
+ .filter(crate::entities::ap_reactions::Column::NoteId.is_in(note_ids))
+ .filter(
+ crate::entities::ap_reactions::Column::LocalUserId.eq(Some(viewer_user.id)),
+ )
+ .filter(crate::entities::ap_reactions::Column::Kind.eq("announce"))
+ .all(&state.db)
+ .await
+ {
+ Ok(reactions) => reactions.iter().filter_map(|r| r.note_id).collect(),
+ Err(error) => {
+ error!(%error, "querying viewer boosts failed");
+ Vec::new()
+ }
+ }
+ } else {
+ Vec::new()
+ }
+ } else {
+ Vec::new()
+ };
+
+ let public_url = state.public_url().to_string();
+ let viewer_username = viewer.as_ref().map(|v| v.name.clone());
+
for note in notes {
match crate::activitypub::notes::get_reactions(&state.db, note.id).await {
Ok(reactions) => {
let viewer_reacted = viewer_reacted_note_ids.contains(¬e.id);
+ let viewer_boosted = viewer_boosted_note_ids.contains(¬e.id);
note_view_list.push(views::NoteView {
actor_profile: ActorProfile {
display_name: user.name.clone(),
@@ -135,6 +165,9 @@ pub(crate) async fn get_profile(
note,
reactions,
viewer_reacted,
+ viewer_boosted,
+ public_url: public_url.clone(),
+ viewer_username: viewer_username.clone(),
});
}
Err(error) => {
frogue/static/style.css+75β1
@@ -252,10 +252,84 @@ article.note header {
border-bottom: solid #e3e3e3 1px;
padding: 0.25em 0.50em;
}
+article.note main {
+ padding: 0.5em 0.75em;
+}
article.note footer {
margin-top: initial;
β text-align: right;
+ text-align: left;
padding: 0.25em 0.50em;
+ display: flex;
+ align-items: center;
+ gap: 0.75em;
+ flex-wrap: wrap;
+ background: #fafafa;
+ border-top: solid #e3e3e3 1px;
+ font-size: 0.9em;
+}
+article.note footer a {
+ color: #2f7a4d;
+}
+article.note .note-author {
+ font-weight: 600;
+}
+article.note .note-author-handle {
+ color: #666;
+ font-weight: normal;
+}
+article.note .note-time {
+ color: #666;
+ font-size: 0.9em;
+}
+article.note .note-edited {
+ color: #888;
+ font-style: italic;
+}
+article.note .note-reactions {
+ color: #666;
+}
+article.note .note-reactions .reaction-count {
+ margin-right: 0.5em;
+}
+
+/* Inline action forms for react/announce buttons */
+form.note-action-form {
+ display: inline;
+ margin: 0;
+ padding: 0;
+ background: none;
+ border: none;
+ max-width: none;
+ box-shadow: none;
+}
+
+button.react-button {
+ background: none;
+ border: 1px solid #d8d8d8;
+ padding: 0.25em 0.6em;
+ border-radius: 3px;
+ color: #555;
+ cursor: pointer;
+ font-size: 0.95em;
+ transition: background-color 0.15s ease, border-color 0.15s ease;
+ margin: 0;
+}
+
+button.react-button:hover {
+ background: #f0f0f0;
+ border-color: #c8c8c8;
+}
+
+button.react-button.reacted {
+ background: #e9f3e6;
+ border-color: #3b8a54;
+ color: #2f7a4d;
+ font-weight: 600;
+}
+
+button.react-button.reacted:hover {
+ background: #d4e8c9;
+ border-color: #2f7a4d;
}
p.error {
frogue/tests/local_reactions.rs+126β0
@@ -0,0 +1,126 @@
+//! Tests for local note reactions (like/announce) with proper authorization.
+
+mod support;
+
+use support::{Client, Server, free_port};
+
+/// Register a user through the web form.
+fn register(client: &mut Client, name: &str) {
+ let email = format!("{name}@example.org");
+ let response = client.post_form(
+ "/register",
+ &[
+ ("name", name),
+ ("email", &email),
+ ("password", "correct horse battery staple"),
+ ],
+ );
+ assert_eq!(
+ response.status, 303,
+ "registering should log in\n{}",
+ response.body
+ );
+}
+
+/// Extract note UUID from profile page HTML.
+fn extract_note_uuid(html: &str, author: &str) -> Option<String> {
+ // Look for permalink pattern: href="http://.../~author/notes/{uuid}"
+ let pattern = format!("/~{author}/notes/");
+ let start = html.find(&pattern)?;
+ let after_pattern = &html[start + pattern.len()..];
+ let end = after_pattern.find('"')?;
+ Some(after_pattern[..end].to_string())
+}
+
+/// Test: Author cannot like their own note (server-side 403)
+#[tokio::test]
+async fn author_cannot_like_own_note() -> miette::Result<()> {
+ let port = free_port();
+ let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
+ server.wait_for_connection(port);
+
+ let mut alice_client = Client::new(port);
+ register(&mut alice_client, "alice");
+
+ // Post a note
+ let response = alice_client.post_form("/~alice/notes", &[("content", "My note")]);
+ assert_eq!(response.status, 303);
+
+ // Visit profile to get the note UUID
+ let response = alice_client.get("/~alice");
+ assert_eq!(response.status, 200);
+ let uuid =
+ extract_note_uuid(&response.body, "alice").expect("should find note UUID in profile");
+
+ // Try to react to own note - should fail with 403
+ let response = alice_client.post_form(&format!("/~alice/notes/{uuid}/react"), &[]);
+ assert_eq!(
+ response.status, 403,
+ "author should not be able to like their own note"
+ );
+
+ Ok(())
+}
+
+/// Test: Author CAN announce (boost) their own note
+#[tokio::test]
+async fn author_can_announce_own_note() -> miette::Result<()> {
+ let port = free_port();
+ let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
+ server.wait_for_connection(port);
+
+ let mut alice_client = Client::new(port);
+ register(&mut alice_client, "alice");
+
+ // Post a note
+ let response = alice_client.post_form("/~alice/notes", &[("content", "My note to boost")]);
+ assert_eq!(response.status, 303);
+
+ // Visit profile to get the note UUID
+ let response = alice_client.get("/~alice");
+ assert_eq!(response.status, 200);
+ let uuid =
+ extract_note_uuid(&response.body, "alice").expect("should find note UUID in profile");
+
+ // Author should be able to announce their own note
+ let response = alice_client.post_form(&format!("/~alice/notes/{uuid}/announce"), &[]);
+ assert_eq!(
+ response.status, 303,
+ "author should be able to announce their own note"
+ );
+
+ Ok(())
+}
+
+/// Test: User can like a note by another user
+#[tokio::test]
+async fn user_can_like_other_users_note() -> miette::Result<()> {
+ let port = free_port();
+ let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
+ server.wait_for_connection(port);
+
+ let mut alice_client = Client::new(port);
+ let mut bob_client = Client::new(port);
+
+ register(&mut alice_client, "alice");
+ register(&mut bob_client, "bob");
+
+ // Alice posts a note
+ let response = alice_client.post_form("/~alice/notes", &[("content", "Alice's note")]);
+ assert_eq!(response.status, 303);
+
+ // Visit Alice's profile to get the note UUID
+ let response = alice_client.get("/~alice");
+ assert_eq!(response.status, 200);
+ let uuid =
+ extract_note_uuid(&response.body, "alice").expect("should find note UUID in profile");
+
+ // Bob should be able to react to Alice's note
+ let response = bob_client.post_form(&format!("/~alice/notes/{uuid}/react"), &[]);
+ assert_eq!(
+ response.status, 303,
+ "non-author should be able to react to a note"
+ );
+
+ Ok(())
+}
frogue/tests/note_footer.rs+161β0
@@ -0,0 +1,161 @@
+//! Tests for unified note footer behavior across feed and profile pages.
+
+mod support;
+
+use std::time::Duration;
+
+use support::{Client, Server, free_port};
+
+/// Register a user through the web form.
+fn register(client: &mut Client, name: &str) {
+ let email = format!("{name}@example.org");
+ let response = client.post_form(
+ "/register",
+ &[
+ ("name", name),
+ ("email", &email),
+ ("password", "correct horse battery staple"),
+ ],
+ );
+ assert_eq!(
+ response.status, 303,
+ "registering should log in\n{}",
+ response.body
+ );
+}
+
+/// Extract note UUID from profile page HTML.
+fn extract_note_uuid(html: &str, author: &str) -> Option<String> {
+ // Look for permalink pattern: href="http://.../~author/notes/{uuid}"
+ let pattern = format!("/~{author}/notes/");
+ let start = html.find(&pattern)?;
+ let after_pattern = &html[start + pattern.len()..];
+ let end = after_pattern.find('"')?;
+ Some(after_pattern[..end].to_string())
+}
+
+/// Test: Profile page shows a permalink for each note
+#[tokio::test]
+async fn profile_shows_note_permalink() -> miette::Result<()> {
+ let port = free_port();
+ let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
+ server.wait_for_connection(port);
+
+ let mut alice_client = Client::new(port);
+ register(&mut alice_client, "alice");
+
+ // Post a note
+ let response = alice_client.post_form("/~alice/notes", &[("content", "Test note content")]);
+ assert_eq!(
+ response.status, 303,
+ "posting a note should redirect. Body:\n{}",
+ response.body
+ );
+
+ // Visit the profile page
+ let response = alice_client.get("/~alice");
+ assert_eq!(response.status, 200, "profile page should load");
+ assert!(
+ response.body.contains("Test note content"),
+ "profile should contain the note content. Body:\n{}",
+ response.body
+ );
+
+ // Check for permalink anchor
+ assert!(
+ response.body.contains("permalink"),
+ "profile should contain a permalink link. Body:\n{}",
+ response.body
+ );
+
+ // Check for a link to the note URL (should be /~alice/notes/{uuid})
+ assert!(
+ response.body.contains("/~alice/notes/"),
+ "profile should contain a link to the note. Body:\n{}",
+ response.body
+ );
+
+ Ok(())
+}
+
+/// Test: Feed shows reaction counts
+#[tokio::test]
+async fn feed_shows_reaction_counts() -> miette::Result<()> {
+ let port = free_port();
+ let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
+ server.wait_for_connection(port);
+
+ let mut alice_client = Client::new(port);
+ register(&mut alice_client, "alice");
+
+ // Post a note
+ let response = alice_client.post_form("/~alice/notes", &[("content", "Test note")]);
+ assert_eq!(response.status, 303);
+
+ // Visit the home page - should see the feed with counts
+ let response = alice_client.get("/");
+ assert_eq!(response.status, 200, "home page should load");
+ assert!(
+ response.body.contains("Test note"),
+ "feed should contain the note. Body:\n{}",
+ response.body
+ );
+
+ // The feed should show reaction counts (even if 0)
+ // After we implement, this will show the counts markup
+ // For now, just check the note is there
+
+ Ok(())
+}
+
+/// Test: Note page content negotiation - HTML vs JSON
+#[tokio::test]
+async fn note_page_content_negotiation() -> miette::Result<()> {
+ let port = free_port();
+ let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
+ server.wait_for_connection(port);
+
+ let mut alice_client = Client::new(port);
+ register(&mut alice_client, "alice");
+
+ // Post a note
+ let response =
+ alice_client.post_form("/~alice/notes", &[("content", "Test note for negotiation")]);
+ assert_eq!(response.status, 303);
+
+ // Visit profile to get the note UUID
+ let response = alice_client.get("/~alice");
+ assert_eq!(response.status, 200);
+ let uuid =
+ extract_note_uuid(&response.body, "alice").expect("should find note UUID in profile");
+
+ // Request with Accept: text/html should return HTML
+ let response =
+ alice_client.get_with(&format!("/~alice/notes/{uuid}"), &[("Accept", "text/html")]);
+ assert_eq!(response.status, 200, "HTML request should succeed with 200");
+ assert!(
+ response.body.contains("<!DOCTYPE html>"),
+ "should return HTML page"
+ );
+ assert!(
+ response.body.contains("Test note for negotiation"),
+ "HTML should contain note content"
+ );
+
+ // Request with Accept: application/activity+json should return JSON
+ let response = alice_client.get_with(
+ &format!("/~alice/notes/{uuid}"),
+ &[("Accept", "application/activity+json")],
+ );
+ assert_eq!(
+ response.status, 200,
+ "ActivityPub JSON request should succeed with 200"
+ );
+ assert!(
+ response.body.contains("\"type\"") && response.body.contains("\"Note\""),
+ "should return ActivityPub Note JSON. Body:\n{}",
+ response.body
+ );
+
+ Ok(())
+}
frogue/tests/webui.rs+3β3
@@ -53,9 +53,9 @@ async fn index_renders_the_home_page() -> miette::Result<()> {
response.body
);
assert!(
β response
β .body
β .contains("<h1><a href=\"/\">πΈ Frogue<span style=\"color: #ccc\">(Fed)</span></a></h1>"),
+ response.body.contains(
+ "<h1><a href=\"/\">πΈ Frogue<span style=\"color: #ccc\">(Fed)</span></a></h1>"
+ ),
"the index should carry the site header, got: {}",
response.body
);