~ololduck/Frogue
feat: implement /tags/{tag} webui for hashtag pages
Browse the tree at this commit
6 files changed, +298, −1
frogue/src/views/mod.rs+2−0
@@ -30,6 +30,7 @@ mod remote_forge;
pub mod report;
mod repositories;
mod ssh_keys;
+mod tags;
mod tickets;
mod tree;
@@ -220,6 +221,7 @@ pub use repositories::{
RepositorySettingsParams, new_repository, repository, repository_settings,
};
pub use ssh_keys::{SshKeyForm, SshKeySummary, ssh_keys};
+pub use tags::tag;
pub use tickets::{
AttachmentView, TicketDetail, TicketForm, TicketRow, TicketsDetail, ticket, ticket_form,
tickets,
frogue/src/views/notes.rs+1−1
@@ -2,7 +2,7 @@
//
// SPDX-License-Identifier: GPL-3.0-or-later
−use maud::{html, Markup, PreEscaped, Render};
+use maud::{Markup, PreEscaped, Render, html};
use url::Url;
use super::components;
frogue/src/views/tags.rs+71−0
@@ -0,0 +1,71 @@
+// SPDX-FileCopyrightText: 2026 The Frogue Authors
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+//! Tag page view: displays all notes containing a specific hashtag.
+
+use actix_web::HttpResponse;
+use maud::{Markup, Render, html};
+
+use super::components::Pagination;
+use super::profile::NoteView;
+use super::{Page, ViewCtx};
+use crate::auth::CurrentUser;
+
+const PAGE_SIZE: u64 = 20;
+
+/// The body of the tag page: displays all notes containing a specific hashtag.
+struct TagPage {
+ tag: String,
+ notes: Vec<NoteView>,
+ page: u64,
+}
+
+impl Render for TagPage {
+ fn render(&self) -> Markup {
+ html! {
+ h1 { "#" (self.tag) }
+ @if self.notes.is_empty() {
+ p class="empty-message" { "No notes found with this tag." }
+ } @else {
+ div class="feed" {
+ @for note_view in &self.notes {
+ (note_view)
+ }
+ }
+ (Pagination {
+ current_page: self.page,
+ has_next: self.notes.len() == PAGE_SIZE as usize,
+ base_url: format!("/tags/{}", self.tag),
+ })
+ }
+ }
+ }
+}
+
+/// Render the tag page showing all notes with the given hashtag.
+pub fn tag(
+ viewer: Option<&CurrentUser>,
+ notes: Vec<NoteView>,
+ page: u64,
+ tag: &str,
+) -> HttpResponse {
+ let ctx = match viewer {
+ Some(user) if crate::auth::is_admin(user.id) => {
+ ViewCtx::admin(user.id, user.name.clone(), 0)
+ }
+ Some(user) => ViewCtx::user(user.id, user.name.clone()),
+ None => ViewCtx::anonymous(),
+ };
+
+ Page {
+ title: format!("#{} - Frogue", tag),
+ ctx,
+ extra_head: None,
+ body: TagPage {
+ tag: tag.to_string(),
+ notes,
+ page,
+ },
+ }
+ .into_response()
+}
frogue/src/webui/mod.rs+2−0
@@ -22,6 +22,7 @@ pub mod nodeinfo;
pub mod remote_forge;
pub mod repositories;
pub mod ssh_keys;
+pub mod tags;
pub mod user;
// TODO: Split this? :D
@@ -47,6 +48,7 @@ pub(crate) fn configure(cfg: &mut web::ServiceConfig) {
.service(federation::get_webfinger)
.service(nodeinfo::get_nodeinfo_jdd)
.service(nodeinfo::get_nodeinfo_21)
+ .service(tags::get_tag)
.service(
web::resource("/actor")
.route(
frogue/src/webui/tags.rs+194−0
@@ -0,0 +1,194 @@
+// SPDX-FileCopyrightText: 2026 The Frogue Authors
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+//! Tag page handler: displays all notes containing a specific hashtag.
+
+use std::sync::Arc;
+
+use actix_web::{HttpResponse, get, web};
+use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
+use tokio::sync::RwLock;
+
+use crate::auth::CurrentUser;
+use crate::entities::ap_notes;
+use crate::entities::prelude::*;
+use crate::views::profile::{ActorProfile, NoteView};
+use crate::{AppState, views};
+
+#[derive(Debug, serde::Deserialize)]
+pub struct TagQuery {
+ pub page: Option<u64>,
+}
+
+#[get("/tags/{tag}")]
+#[tracing::instrument(skip(data))]
+pub async fn get_tag(
+ tag: web::Path<String>,
+ viewer: Option<CurrentUser>,
+ data: web::Data<Arc<RwLock<AppState>>>,
+ query: web::Query<TagQuery>,
+) -> HttpResponse {
+ let state = data.read().await;
+ let db = state.db.clone();
+ let public_url = state.public_url().to_string();
+ let tag_name = tag.into_inner();
+
+ let page = query.page.unwrap_or(0);
+ const PAGE_SIZE: u64 = 20;
+
+ // Query local notes containing this hashtag
+ // Match #tag followed by a non-hashtag character or end of string
+ let pattern = format!("%#{}%", tag_name);
+
+ let notes = match ApNotes::find()
+ .filter(ap_notes::Column::ContentMd.like(&pattern))
+ .order_by_desc(ap_notes::Column::CreatedAt)
+ .limit(PAGE_SIZE)
+ .offset(page * PAGE_SIZE)
+ .all(&db)
+ .await
+ {
+ Ok(notes) => notes,
+ Err(e) => {
+ tracing::error!(error = %e, tag = %tag_name, "failed to query notes for tag");
+ return HttpResponse::InternalServerError().finish();
+ }
+ };
+
+ // Build note views for each note
+ let mut note_views = Vec::new();
+ for note in notes {
+ // Get the actor for this note
+ let actor = match ApActors::find_by_id(note.actor_id).one(&db).await {
+ Ok(Some(actor)) => actor,
+ _ => continue,
+ };
+
+ // Get the user for this actor
+ let user = match actor.user_id {
+ Some(user_id) => match Users::find_by_id(user_id).one(&db).await {
+ Ok(Some(user)) => user,
+ _ => continue,
+ },
+ None => continue,
+ };
+
+ // Get attachments for this note
+ let attachments = match crate::attachments::store::list_for(
+ &db,
+ &crate::attachments::store::Owner::ApNote(note.id),
+ )
+ .await
+ {
+ Ok(atts) => atts
+ .into_iter()
+ .map(|a| views::NoteAttachment {
+ uuid: a.uuid.to_string(),
+ filename: a.filename,
+ content_type: a.content_type,
+ remote_url: a.remote_url,
+ })
+ .collect(),
+ Err(e) => {
+ tracing::warn!(error = %e, note_id = note.id, "failed to load attachments");
+ Vec::new()
+ }
+ };
+
+ // Get reaction counts
+ let reactions = match crate::activitypub::notes::get_reactions(
+ &db,
+ note.id,
+ viewer.as_ref().map(|v| v.id),
+ )
+ .await
+ {
+ Ok(r) => r,
+ Err(e) => {
+ tracing::warn!(error = %e, note_id = note.id, "failed to load reactions");
+ crate::activitypub::notes::NoteReactions {
+ likes: 0,
+ announces: 0,
+ emojis: Vec::new(),
+ }
+ }
+ };
+
+ // Check if viewer has reacted/boosted
+ let (viewer_reacted, viewer_boosted) = if let Some(ref viewer) = viewer {
+ let reacted = match ApReactions::find()
+ .filter(crate::entities::ap_reactions::Column::NoteId.eq(note.id))
+ .filter(crate::entities::ap_reactions::Column::LocalUserId.eq(Some(viewer.id)))
+ .filter(crate::entities::ap_reactions::Column::Kind.eq("like"))
+ .one(&db)
+ .await
+ {
+ Ok(Some(_)) => true,
+ _ => false,
+ };
+ let boosted = match ApReactions::find()
+ .filter(crate::entities::ap_reactions::Column::NoteId.eq(note.id))
+ .filter(crate::entities::ap_reactions::Column::LocalUserId.eq(Some(viewer.id)))
+ .filter(crate::entities::ap_reactions::Column::Kind.eq("announce"))
+ .one(&db)
+ .await
+ {
+ Ok(Some(_)) => true,
+ _ => false,
+ };
+ (reacted, boosted)
+ } else {
+ (false, false)
+ };
+
+ // Check if viewer has blocked/silenced the author
+ let (viewer_blocked_author, viewer_silenced_author) = if let Some(ref viewer) = viewer {
+ let blocked = match crate::entities::prelude::UserBlocks::find()
+ .filter(crate::entities::user_blocks::Column::BlockerId.eq(viewer.id))
+ .filter(crate::entities::user_blocks::Column::BlockedUserId.eq(Some(user.id)))
+ .one(&db)
+ .await
+ {
+ Ok(Some(_)) => true,
+ _ => false,
+ };
+ let silenced = match crate::entities::prelude::UserSilences::find()
+ .filter(crate::entities::user_silences::Column::BlockerId.eq(viewer.id))
+ .filter(crate::entities::user_silences::Column::BlockedUserId.eq(Some(user.id)))
+ .one(&db)
+ .await
+ {
+ Ok(Some(_)) => true,
+ _ => false,
+ };
+ (blocked, silenced)
+ } else {
+ (false, false)
+ };
+
+ note_views.push(NoteView {
+ actor_profile: ActorProfile {
+ display_name: user.name.clone(),
+ handle: user.name.clone(),
+ host: None,
+ profile_url: None,
+ icon_url: None,
+ actor_url: format!("/~{}", user.name),
+ remote_actor_id: None,
+ },
+ note,
+ reactions,
+ viewer_reacted,
+ viewer_boosted,
+ public_url: public_url.clone(),
+ viewer_username: viewer.as_ref().map(|v| v.name.clone()),
+ attachments,
+ viewer_blocked_author,
+ viewer_silenced_author,
+ });
+ }
+
+ drop(state);
+
+ views::tag(viewer.as_ref(), note_views, page, &tag_name)
+}
frogue/tests/webui.rs+28−0
@@ -173,3 +173,31 @@ async fn profile_is_only_served_under_the_tilde() -> miette::Result<()> {
Ok(())
}
+
+#[tokio::test]
+async fn tag_page_renders() -> miette::Result<()> {
+ let port = free_port();
+ let mut server = Server::spawn(&format!("127.0.0.1:{port}"));
+ server.wait_for_connection(port);
+
+ let response = get(port, "/tags/rust");
+
+ assert_eq!(response.status, 200, "tag page");
+ assert_eq!(
+ response.content_type.as_deref(),
+ Some("text/html; charset=utf-8"),
+ "the tag page should be sent as HTML"
+ );
+ assert!(
+ response.body.contains("<title>#rust - Frogue</title>"),
+ "the tag page should carry its title, got: {}",
+ response.body
+ );
+ assert!(
+ response.body.contains("<h1>#rust</h1>"),
+ "the tag page should show the tag name, got: {}",
+ response.body
+ );
+
+ Ok(())
+}