Compare commits

...

42 Commits

Author SHA1 Message Date
37d03a06dd docs(openapi): fix schema registration for federation actors and management
Some checks failed
lint / lint (push) Failing after 8m56s
test / unit (push) Successful in 16m29s
2026-05-29 02:09:16 +02:00
55e5bcc2bb docs(openapi): register federation management and actors doc modules 2026-05-29 02:08:08 +02:00
ac26eaca6b docs(openapi): annotate federation actors handlers and add doc module 2026-05-29 02:06:40 +02:00
86d0497509 docs(openapi): annotate all federation management handlers and add doc module 2026-05-29 02:04:59 +02:00
989004dd74 docs(openapi): annotate all missing user handlers 2026-05-29 02:02:31 +02:00
64cc11c2a1 docs(openapi): annotate get_followers, get_following, get_popular_tags handlers 2026-05-29 02:00:53 +02:00
01ef118b0a docs(openapi): add FeedOptionsQuery IntoParams and update feed annotations 2026-05-29 01:59:17 +02:00
4ab6da67c7 fix(frontend): fix instance badge overflow on narrow screens 2026-05-29 01:47:50 +02:00
dc75ac5f6c fix(frontend): prevent handle overflow in instance badge row 2026-05-29 01:44:03 +02:00
b14b8592a2 fix(frontend): add instance badge to remote author movie diary cards 2026-05-29 01:42:43 +02:00
4db7194838 fix(frontend): wider search input on large and 2K screens 2026-05-29 01:40:11 +02:00
c94b42cba8 feat(frontend): add follow request explanation to remote user card 2026-05-29 01:37:57 +02:00
1ad6f8ae8f feat(frontend): add fediverse handle format hints to search page 2026-05-29 01:36:53 +02:00
d76ff9dafb feat(frontend): add fediverse handle widget to feed sidebar 2026-05-29 01:35:36 +02:00
522ee9c1b1 feat(frontend): add instance badge to remote author posts in feed 2026-05-29 01:34:12 +02:00
00996327fb feat(frontend): add styled 404 and error pages 2026-05-29 01:20:32 +02:00
7ed639c9ea fix(frontend): remove emojis from landing page feature cards and badges 2026-05-29 01:14:29 +02:00
3ad609a793 fix(frontend): de-AI landing page copy, remove em dashes and manifesto tone 2026-05-29 01:12:29 +02:00
9849bb4991 fix(frontend): prevent iOS Safari auto-zoom on input focus 2026-05-29 01:10:50 +02:00
2199e5c66d fix(frontend): move search to header center on mobile, nav links in hamburger only 2026-05-29 01:08:57 +02:00
6e7bf05942 feat(frontend): add hamburger sheet menu for mobile nav 2026-05-29 01:07:27 +02:00
037217960e fix(frontend): place scroll indicator directly below hero card in flow 2026-05-29 01:03:59 +02:00
44b3a6de60 fix(frontend): move scroll indicator inside hero section so it anchors correctly 2026-05-29 01:02:43 +02:00
1fd46f3f2a feat(frontend): add animated scroll indicator to landing page hero 2026-05-29 01:01:29 +02:00
9c5d5518bb fix(frontend): add blur overlay on landing page for better text contrast 2026-05-29 01:00:02 +02:00
95ea633e78 fix(frontend): restore background image on landing page by removing gradient override 2026-05-29 00:59:07 +02:00
a97507cc15 feat(frontend): replace inline LandingPage with new multi-section component 2026-05-29 00:56:28 +02:00
858faddda9 feat(frontend): add LandingPage server component with all 5 sections 2026-05-29 00:54:59 +02:00
ea3a32ccaf feat(frontend): add LandingFeatures client component with scroll animation 2026-05-29 00:52:01 +02:00
8fad8eefa0 feat(frontend): add landing page CSS keyframes and utility classes 2026-05-29 00:50:44 +02:00
5a05968ae9 fix(frontend): rewrite FiltersSortingPanel with shadcn, correct styling, useTransition 2026-05-29 00:23:07 +02:00
8229285a2f refactor(postgres): format FeedSqlBuilder for improved readability 2026-05-29 00:13:55 +02:00
145b07d636 refactor(postgres): introduce FeedSqlBuilder to consolidate SQL construction 2026-05-29 00:11:07 +02:00
7991aef47b feat(frontend): wire FiltersSortingPanel into home feed with sort/filter params 2026-05-28 23:56:49 +02:00
ed6a4f9f72 feat(frontend): add FiltersSortingPanel client component 2026-05-28 23:55:04 +02:00
f815d71c32 feat(frontend): add FeedOptions type and update getFeed to support sort/filter params 2026-05-28 23:52:58 +02:00
0688ffe0ae feat(backend): wire FeedRequest/FeedOptions sort+filter through all feed layers 2026-05-28 23:45:46 +02:00
95728302b7 feat(domain): add FeedSort, FeedFilter, FeedOptions, FeedRequest CQRS query types 2026-05-28 23:39:35 +02:00
4d00d856c1 feat: swap TopFriends for ProfileFriendsWidget on profile page 2026-05-28 22:54:13 +02:00
a279988d39 feat: add ProfileFriendsWidget with top-friends/all-friends conditional 2026-05-28 22:52:56 +02:00
2f56839938 feat: add AllFriendsCard component for local and remote friends 2026-05-28 22:50:50 +02:00
2ffdd5e269 feat: show Friends nav link only when logged in 2026-05-28 22:47:53 +02:00
32 changed files with 1472 additions and 335 deletions

View File

@@ -9,7 +9,7 @@ use domain::{
thought::{Thought, Visibility}, thought::{Thought, Visibility},
user::User, user::User,
}, },
ports::{FeedQuery, FeedRepository, FeedScope}, ports::{FeedOptions, FeedRepository, FeedRequest, FeedScope, FeedSort},
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username}, value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
}; };
use sqlx::PgPool; use sqlx::PgPool;
@@ -55,59 +55,6 @@ struct FeedRow {
boosted_by_viewer: bool, boosted_by_viewer: bool,
} }
fn federation_following_clause(follower: Option<uuid::Uuid>) -> String {
match follower {
Some(fid) => format!(
" OR t.user_id IN (
SELECT u2.id FROM users u2
JOIN federation_following ff ON u2.ap_id = ff.remote_actor_url
WHERE ff.local_user_id = '{fid}'
)"
),
None => String::new(),
}
}
fn feed_select(viewer: Option<uuid::Uuid>) -> String {
let viewer_checks = match viewer {
Some(uid) => format!(
"EXISTS(SELECT 1 FROM likes WHERE user_id='{uid}' AND thought_id=t.id) AS liked_by_viewer,
EXISTS(SELECT 1 FROM boosts WHERE user_id='{uid}' AND thought_id=t.id) AS boosted_by_viewer"
),
None => "false AS liked_by_viewer, false AS boosted_by_viewer".to_string(),
};
format!(
"
SELECT
t.id AS thought_id, t.user_id AS t_user_id, t.content,
t.in_reply_to_id,
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
t.created_at AS thought_created_at, t.updated_at,
t.note_extensions,
u.id AS author_id,
CASE WHEN NOT u.local AND ra.handle IS NOT NULL AND ra.handle != ''
THEN '@' || ra.handle ||
CASE WHEN ra.handle NOT LIKE '%@%'
THEN '@' || SPLIT_PART(ra.url, '/', 3)
ELSE '' END
ELSE u.username END AS username,
u.email, u.password_hash,
COALESCE(ra.display_name, u.display_name) AS display_name,
u.bio,
COALESCE(ra.avatar_url, u.avatar_url) AS avatar_url,
u.header_url, u.custom_css,
u.local AS author_local,
u.created_at AS author_created_at, u.updated_at AS author_updated_at,
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,
(SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count,
{viewer_checks}
FROM thoughts t
JOIN users u ON u.id=t.user_id
LEFT JOIN remote_actors ra ON u.ap_id = ra.url"
)
}
fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, DomainError> { fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, DomainError> {
let thought = Thought { let thought = Thought {
id: ThoughtId::from_uuid(r.thought_id), id: ThoughtId::from_uuid(r.thought_id),
@@ -151,36 +98,213 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
}) })
} }
struct FeedSqlBuilder<'a> {
options: &'a FeedOptions,
scope: &'a FeedScope,
viewer: Option<uuid::Uuid>,
}
impl<'a> FeedSqlBuilder<'a> {
fn new(options: &'a FeedOptions, scope: &'a FeedScope, viewer: Option<uuid::Uuid>) -> Self {
Self {
options,
scope,
viewer,
}
}
fn select(&self) -> String {
let viewer_checks = match self.viewer {
Some(uid) => format!(
"EXISTS(SELECT 1 FROM likes WHERE user_id='{uid}' AND thought_id=t.id) AS liked_by_viewer,
EXISTS(SELECT 1 FROM boosts WHERE user_id='{uid}' AND thought_id=t.id) AS boosted_by_viewer"
),
None => "false AS liked_by_viewer, false AS boosted_by_viewer".to_string(),
};
format!(
"
SELECT
t.id AS thought_id, t.user_id AS t_user_id, t.content,
t.in_reply_to_id,
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
t.created_at AS thought_created_at, t.updated_at,
t.note_extensions,
u.id AS author_id,
CASE WHEN NOT u.local AND ra.handle IS NOT NULL AND ra.handle != ''
THEN '@' || ra.handle ||
CASE WHEN ra.handle NOT LIKE '%@%'
THEN '@' || SPLIT_PART(ra.url, '/', 3)
ELSE '' END
ELSE u.username END AS username,
u.email, u.password_hash,
COALESCE(ra.display_name, u.display_name) AS display_name,
u.bio,
COALESCE(ra.avatar_url, u.avatar_url) AS avatar_url,
u.header_url, u.custom_css,
u.local AS author_local,
u.created_at AS author_created_at, u.updated_at AS author_updated_at,
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,
(SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count,
{viewer_checks}
FROM thoughts t
JOIN users u ON u.id=t.user_id
LEFT JOIN remote_actors ra ON u.ap_id = ra.url"
)
}
fn fed_clause(&self) -> String {
match self.viewer {
Some(fid) => format!(
" OR t.user_id IN (
SELECT u2.id FROM users u2
JOIN federation_following ff ON u2.ap_id = ff.remote_actor_url
WHERE ff.local_user_id = '{fid}'
)"
),
None => String::new(),
}
}
fn filter_sql(&self) -> String {
let f = &self.options.filter;
let mut s = String::new();
if f.originals_only {
s += " AND t.in_reply_to_id IS NULL";
}
if f.replies_only {
s += " AND t.in_reply_to_id IS NOT NULL";
}
if f.local_only {
s += " AND t.local = true";
}
if f.hide_sensitive {
s += " AND t.sensitive = false";
}
s
}
fn order_sql(&self) -> &'static str {
if matches!(self.scope, FeedScope::Search { .. }) {
return "ORDER BY similarity(t.content, $1) DESC";
}
match &self.options.sort {
FeedSort::Newest => "ORDER BY t.created_at DESC",
FeedSort::Oldest => "ORDER BY t.created_at ASC",
FeedSort::MostLiked => "ORDER BY like_count DESC, t.created_at DESC",
FeedSort::MostBoosted => "ORDER BY boost_count DESC, t.created_at DESC",
FeedSort::MostDiscussed => "ORDER BY reply_count DESC, t.created_at DESC",
}
}
fn public(&self) -> (String, String) {
let filter = self.filter_sql();
let order = self.order_sql();
let count = format!(
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'{}",
filter
);
let data = format!(
"{} WHERE t.local=true AND t.visibility='public'{} {} LIMIT $1 OFFSET $2",
self.select(),
filter,
order
);
(count, data)
}
fn home(&self) -> (String, String) {
let fed = self.fed_clause();
let filter = self.filter_sql();
let order = self.order_sql();
let count = format!(
"SELECT COUNT(*) FROM thoughts t WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'{}",
fed, filter
);
let data =
format!(
"{} WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'{} {} LIMIT $2 OFFSET $3",
self.select(), fed, filter, order
);
(count, data)
}
fn search(&self) -> (String, String) {
let filter = self.filter_sql();
let order = self.order_sql();
let count = format!(
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'{}",
filter
);
let data = format!(
"{} WHERE t.content % $1 AND t.visibility='public'{} {} LIMIT $2 OFFSET $3",
self.select(),
filter,
order
);
(count, data)
}
fn tag(&self) -> (String, String) {
let filter = self.filter_sql();
let order = self.order_sql();
let count = format!(
"SELECT COUNT(*) FROM thoughts t
JOIN thought_tags tt ON tt.thought_id = t.id
JOIN tags tg ON tg.id = tt.tag_id
WHERE tg.name = $1 AND t.visibility = 'public'{}",
filter
);
let data = format!(
"{}
JOIN thought_tags tt ON tt.thought_id = t.id
JOIN tags tg ON tg.id = tt.tag_id
WHERE tg.name = $1 AND t.visibility = 'public'{} {} LIMIT $2 OFFSET $3",
self.select(),
filter,
order
);
(count, data)
}
fn user(&self) -> (String, String) {
let filter = self.filter_sql();
let order = self.order_sql();
let count = format!(
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id = $1 AND ($2::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $2 AND following_id = $1 AND state = 'accepted'))))){}",
filter
);
let data = format!(
"{} WHERE t.user_id = $1 AND ($4::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $4 AND following_id = $1 AND state = 'accepted'))))){} {} LIMIT $2 OFFSET $3",
self.select(), filter, order
);
(count, data)
}
}
#[async_trait] #[async_trait]
impl FeedRepository for PgFeedRepository { impl FeedRepository for PgFeedRepository {
async fn query(&self, q: &FeedQuery) -> Result<Paginated<FeedEntry>, DomainError> { async fn query(&self, req: &FeedRequest) -> Result<Paginated<FeedEntry>, DomainError> {
let viewer = q.viewer_id.as_ref().map(|v| v.as_uuid()); let viewer = req.query.viewer_id.as_ref().map(|v| v.as_uuid());
let page = &q.page; let page = &req.query.page;
let builder = FeedSqlBuilder::new(&req.options, &req.query.scope, viewer);
match &q.scope { match &req.query.scope {
FeedScope::Home { following_ids } => { FeedScope::Home { following_ids } => {
let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect(); let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect();
let fed_clause = federation_following_clause(viewer); let (count_sql, data_sql) = builder.home();
let count_sql = format!(
"SELECT COUNT(*) FROM thoughts t WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'",
fed_clause
);
let total: i64 = sqlx::query_scalar(&count_sql) let total: i64 = sqlx::query_scalar(&count_sql)
.bind(&ids) .bind(&ids)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
let sel = feed_select(viewer);
let sql = format!("{sel} WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3", fed_clause);
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(&ids) .bind(&ids)
.bind(page.limit()) .bind(page.limit())
.bind(page.offset()) .bind(page.offset())
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
Ok(Paginated { Ok(Paginated {
items: rows items: rows
.into_iter() .into_iter()
@@ -193,22 +317,17 @@ impl FeedRepository for PgFeedRepository {
} }
FeedScope::Public => { FeedScope::Public => {
let total: i64 = sqlx::query_scalar( let (count_sql, data_sql) = builder.public();
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'", let total: i64 = sqlx::query_scalar(&count_sql)
)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
let sel = feed_select(viewer);
let sql = format!("{sel} WHERE t.local=true AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $1 OFFSET $2");
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(page.limit()) .bind(page.limit())
.bind(page.offset()) .bind(page.offset())
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
Ok(Paginated { Ok(Paginated {
items: rows items: rows
.into_iter() .into_iter()
@@ -221,24 +340,19 @@ impl FeedRepository for PgFeedRepository {
} }
FeedScope::Search { query } => { FeedScope::Search { query } => {
let total: i64 = sqlx::query_scalar( let (count_sql, data_sql) = builder.search();
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'", let total: i64 = sqlx::query_scalar(&count_sql)
)
.bind(query) .bind(query)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
let sel = feed_select(viewer);
let sql = format!("{sel} WHERE t.content % $1 AND t.visibility='public' ORDER BY similarity(t.content, $1) DESC LIMIT $2 OFFSET $3");
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(query) .bind(query)
.bind(page.limit()) .bind(page.limit())
.bind(page.offset()) .bind(page.offset())
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
Ok(Paginated { Ok(Paginated {
items: rows items: rows
.into_iter() .into_iter()
@@ -251,33 +365,19 @@ impl FeedRepository for PgFeedRepository {
} }
FeedScope::Tag { tag_name } => { FeedScope::Tag { tag_name } => {
let total: i64 = sqlx::query_scalar( let (count_sql, data_sql) = builder.tag();
"SELECT COUNT(*) FROM thoughts t let total: i64 = sqlx::query_scalar(&count_sql)
JOIN thought_tags tt ON tt.thought_id = t.id
JOIN tags tg ON tg.id = tt.tag_id
WHERE tg.name = $1 AND t.visibility = 'public'",
)
.bind(tag_name) .bind(tag_name)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
let sel = feed_select(viewer);
let sql = format!(
"{sel}
JOIN thought_tags tt ON tt.thought_id = t.id
JOIN tags tg ON tg.id = tt.tag_id
WHERE tg.name = $1 AND t.visibility = 'public'
ORDER BY t.created_at DESC LIMIT $2 OFFSET $3"
);
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(tag_name) .bind(tag_name)
.bind(page.limit()) .bind(page.limit())
.bind(page.offset()) .bind(page.offset())
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
Ok(Paginated { Ok(Paginated {
items: rows items: rows
.into_iter() .into_iter()
@@ -291,21 +391,15 @@ impl FeedRepository for PgFeedRepository {
FeedScope::User { user_id } => { FeedScope::User { user_id } => {
let uid = user_id.as_uuid(); let uid = user_id.as_uuid();
// Use nil UUID for unauthenticated viewers — won't match owner or follower checks.
let viewer_uuid = viewer.unwrap_or(uuid::Uuid::nil()); let viewer_uuid = viewer.unwrap_or(uuid::Uuid::nil());
let (count_sql, data_sql) = builder.user();
let total: i64 = sqlx::query_scalar( let total: i64 = sqlx::query_scalar(&count_sql)
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id = $1 AND ($2::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $2 AND following_id = $1 AND state = 'accepted')))))",
)
.bind(uid) .bind(uid)
.bind(viewer_uuid) .bind(viewer_uuid)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
let sel = feed_select(viewer);
let sql = format!("{sel} WHERE t.user_id = $1 AND ($4::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $4 AND following_id = $1 AND state = 'accepted'))))) ORDER BY t.created_at DESC LIMIT $2 OFFSET $3");
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(uid) .bind(uid)
.bind(page.limit()) .bind(page.limit())
.bind(page.offset()) .bind(page.offset())
@@ -313,7 +407,6 @@ impl FeedRepository for PgFeedRepository {
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
Ok(Paginated { Ok(Paginated {
items: rows items: rows
.into_iter() .into_iter()

View File

@@ -6,7 +6,7 @@ use domain::{
thought::{NewThought, Thought, Visibility}, thought::{NewThought, Thought, Visibility},
user::User, user::User,
}, },
ports::{FeedQuery, ThoughtRepository, UserWriter}, ports::{FeedOptions, FeedQuery, FeedRequest, ThoughtRepository, UserWriter},
value_objects::*, value_objects::*,
}; };
@@ -38,13 +38,16 @@ async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) {
let (_, _) = seed(&pool, "alice", "hello").await; let (_, _) = seed(&pool, "alice", "hello").await;
let repo = PgFeedRepository::new(pool); let repo = PgFeedRepository::new(pool);
let result = repo let result = repo
.query(&FeedQuery::public( .query(&FeedRequest {
query: FeedQuery::public(
PageParams { PageParams {
page: 1, page: 1,
per_page: 20, per_page: 20,
}, },
None, None,
)) ),
options: FeedOptions::default(),
})
.await .await
.unwrap(); .unwrap();
assert_eq!(result.total, 1); assert_eq!(result.total, 1);
@@ -57,14 +60,17 @@ async fn search_returns_matching_thoughts(pool: sqlx::PgPool) {
let (_, _) = seed(&pool, "bob", "goodbye world").await; let (_, _) = seed(&pool, "bob", "goodbye world").await;
let repo = PgFeedRepository::new(pool); let repo = PgFeedRepository::new(pool);
let result = repo let result = repo
.query(&FeedQuery::search( .query(&FeedRequest {
query: FeedQuery::search(
"hello world", "hello world",
PageParams { PageParams {
page: 1, page: 1,
per_page: 20, per_page: 20,
}, },
None, None,
)) ),
options: FeedOptions::default(),
})
.await .await
.unwrap(); .unwrap();
assert!(result.total >= 1); assert!(result.total >= 1);

View File

@@ -9,8 +9,8 @@ use domain::{
}, },
ports::{ ports::{
EventPublisher, FederationActionPort, FederationFollowPort, FederationFollowRequestPort, EventPublisher, FederationActionPort, FederationFollowPort, FederationFollowRequestPort,
FederationSchedulerPort, FeedQuery, FeedRepository, FollowRepository, FederationSchedulerPort, FeedOptions, FeedQuery, FeedRepository, FeedRequest,
RemoteActorConnectionRepository, UserReader, FollowRepository, RemoteActorConnectionRepository, UserReader,
}, },
value_objects::UserId, value_objects::UserId,
}; };
@@ -136,11 +136,10 @@ pub async fn get_remote_actor_posts(
None => ap_repo.intern_remote_actor(&actor.url).await?, None => ap_repo.intern_remote_actor(&actor.url).await?,
}; };
let result = feed let result = feed
.query(&FeedQuery::user( .query(&FeedRequest {
author_id, query: FeedQuery::user(author_id, page.clone(), viewer_id.cloned()),
page.clone(), options: FeedOptions::default(),
viewer_id.cloned(), })
))
.await?; .await?;
if let Some(outbox_url) = actor.outbox_url { if let Some(outbox_url) = actor.outbox_url {
let _ = scheduler let _ = scheduler

View File

@@ -1,7 +1,7 @@
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::feed::{FeedEntry, PageParams, Paginated}, models::feed::{FeedEntry, PageParams, Paginated},
ports::{FeedQuery, FeedRepository, FollowRepository}, ports::{FeedOptions, FeedQuery, FeedRepository, FeedRequest, FollowRepository},
value_objects::UserId, value_objects::UserId,
}; };
@@ -10,9 +10,13 @@ pub async fn get_home_feed(
follows: &dyn FollowRepository, follows: &dyn FollowRepository,
user_id: &UserId, user_id: &UserId,
page: PageParams, page: PageParams,
opts: FeedOptions,
) -> Result<Paginated<FeedEntry>, DomainError> { ) -> Result<Paginated<FeedEntry>, DomainError> {
let mut following_ids = follows.get_accepted_following_ids(user_id).await?; let mut following_ids = follows.get_accepted_following_ids(user_id).await?;
following_ids.push(user_id.clone()); following_ids.push(user_id.clone());
feed.query(&FeedQuery::home(user_id.clone(), following_ids, page)) feed.query(&FeedRequest {
query: FeedQuery::home(user_id.clone(), following_ids, page),
options: opts,
})
.await .await
} }

View File

@@ -431,9 +431,38 @@ impl FeedQuery {
} }
} }
#[derive(Debug, Clone, Default)]
pub enum FeedSort {
#[default]
Newest,
Oldest,
MostLiked,
MostBoosted,
MostDiscussed,
}
#[derive(Debug, Clone, Default)]
pub struct FeedFilter {
pub originals_only: bool,
pub replies_only: bool,
pub local_only: bool,
pub hide_sensitive: bool,
}
#[derive(Debug, Clone, Default)]
pub struct FeedOptions {
pub sort: FeedSort,
pub filter: FeedFilter,
}
pub struct FeedRequest {
pub query: FeedQuery,
pub options: FeedOptions,
}
#[async_trait] #[async_trait]
pub trait FeedRepository: Send + Sync { pub trait FeedRepository: Send + Sync {
async fn query(&self, q: &FeedQuery) -> Result<Paginated<FeedEntry>, DomainError>; async fn query(&self, req: &FeedRequest) -> Result<Paginated<FeedEntry>, DomainError>;
} }
#[async_trait] #[async_trait]

View File

@@ -882,7 +882,7 @@ impl RemoteActorConnectionRepository for TestStore {
impl FeedRepository for TestStore { impl FeedRepository for TestStore {
async fn query( async fn query(
&self, &self,
_q: &crate::ports::FeedQuery, _req: &crate::ports::FeedRequest,
) -> Result<Paginated<FeedEntry>, DomainError> { ) -> Result<Paginated<FeedEntry>, DomainError> {
Ok(Paginated { Ok(Paginated {
items: vec![], items: vec![],

View File

@@ -45,6 +45,14 @@ impl FromAppState for FederationActorsDeps {
} }
} }
#[utoipa::path(
get, path = "/federation/actors/{handle}/posts",
params(
("handle" = String, Path, description = "Fediverse handle (@user@instance.tld)"),
PaginationQuery,
),
responses((status = 200, description = "Posts by this remote actor"))
)]
pub async fn remote_actor_posts_handler( pub async fn remote_actor_posts_handler(
Deps(d): Deps<FederationActorsDeps>, Deps(d): Deps<FederationActorsDeps>,
Path(handle): Path<String>, Path(handle): Path<String>,
@@ -73,6 +81,14 @@ pub async fn remote_actor_posts_handler(
}))) })))
} }
#[utoipa::path(
get, path = "/federation/actors/{handle}/followers-list",
params(
("handle" = String, Path, description = "Fediverse handle (@user@instance.tld)"),
PaginationQuery,
),
responses((status = 200, description = "Followers of this remote actor", body = ActorConnectionPageResponse)),
)]
pub async fn actor_followers_handler( pub async fn actor_followers_handler(
Deps(d): Deps<FederationActorsDeps>, Deps(d): Deps<FederationActorsDeps>,
Path(handle): Path<String>, Path(handle): Path<String>,
@@ -81,6 +97,14 @@ pub async fn actor_followers_handler(
actor_connections_handler(d, handle, "followers", q.page() as u32).await actor_connections_handler(d, handle, "followers", q.page() as u32).await
} }
#[utoipa::path(
get, path = "/federation/actors/{handle}/following-list",
params(
("handle" = String, Path, description = "Fediverse handle (@user@instance.tld)"),
PaginationQuery,
),
responses((status = 200, description = "Accounts this remote actor follows", body = ActorConnectionPageResponse)),
)]
pub async fn actor_following_handler( pub async fn actor_following_handler(
Deps(d): Deps<FederationActorsDeps>, Deps(d): Deps<FederationActorsDeps>,
Path(handle): Path<String>, Path(handle): Path<String>,

View File

@@ -3,7 +3,7 @@ use crate::{
errors::ApiError, errors::ApiError,
extractors::{AuthUser, Deps}, extractors::{AuthUser, Deps},
}; };
use api_types::responses::{ProfileField, RemoteActorResponse}; use api_types::responses::{ErrorResponse, ProfileField, RemoteActorResponse};
use application::use_cases::federation_management::{ use application::use_cases::federation_management::{
accept_follow_request, get_remote_friends, initiate_actor_move, list_pending_requests, accept_follow_request, get_remote_friends, initiate_actor_move, list_pending_requests,
list_remote_followers, list_remote_following, reject_follow_request, remove_remote_following, list_remote_followers, list_remote_following, reject_follow_request, remove_remote_following,
@@ -12,18 +12,21 @@ use axum::{http::StatusCode, Json};
use domain::ports::{EventPublisher, FederationActionPort, FollowRepository, UserRepository}; use domain::ports::{EventPublisher, FederationActionPort, FollowRepository, UserRepository};
use serde::Deserialize; use serde::Deserialize;
#[derive(Deserialize)] #[derive(Deserialize, utoipa::ToSchema)]
pub struct ActorUrlBody { pub struct ActorUrlBody {
/// Full ActivityPub actor URL
pub actor_url: String, pub actor_url: String,
} }
#[derive(Deserialize)] #[derive(Deserialize, utoipa::ToSchema)]
pub struct HandleBody { pub struct HandleBody {
/// Fediverse handle (`@user@instance.tld`)
pub handle: String, pub handle: String,
} }
#[derive(Deserialize)] #[derive(Deserialize, utoipa::ToSchema)]
pub struct MoveBody { pub struct MoveBody {
/// New actor URL to migrate to
pub new_actor_url: String, pub new_actor_url: String,
} }
@@ -54,6 +57,11 @@ fn to_response(a: domain::models::remote_actor::RemoteActor) -> RemoteActorRespo
} }
} }
#[utoipa::path(
get, path = "/federation/me/followers/pending",
responses((status = 200, description = "Pending inbound follow requests", body = Vec<RemoteActorResponse>)),
security(("bearer_auth" = []))
)]
pub async fn get_pending_requests( pub async fn get_pending_requests(
Deps(d): Deps<FederationManagementDeps>, Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
@@ -62,6 +70,15 @@ pub async fn get_pending_requests(
Ok(Json(actors.into_iter().map(to_response).collect())) Ok(Json(actors.into_iter().map(to_response).collect()))
} }
#[utoipa::path(
post, path = "/federation/me/followers/accept",
request_body = ActorUrlBody,
responses(
(status = 204, description = "Follow request accepted"),
(status = 400, description = "Invalid request", body = ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn post_accept_request( pub async fn post_accept_request(
Deps(d): Deps<FederationManagementDeps>, Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
@@ -71,6 +88,15 @@ pub async fn post_accept_request(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[utoipa::path(
delete, path = "/federation/me/followers",
request_body = ActorUrlBody,
responses(
(status = 204, description = "Follower removed / request rejected"),
(status = 400, description = "Invalid request", body = ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn delete_follower( pub async fn delete_follower(
Deps(d): Deps<FederationManagementDeps>, Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
@@ -80,6 +106,11 @@ pub async fn delete_follower(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[utoipa::path(
get, path = "/federation/me/followers",
responses((status = 200, description = "Accepted remote followers", body = Vec<RemoteActorResponse>)),
security(("bearer_auth" = []))
)]
pub async fn get_remote_followers( pub async fn get_remote_followers(
Deps(d): Deps<FederationManagementDeps>, Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
@@ -88,6 +119,11 @@ pub async fn get_remote_followers(
Ok(Json(actors.into_iter().map(to_response).collect())) Ok(Json(actors.into_iter().map(to_response).collect()))
} }
#[utoipa::path(
get, path = "/federation/me/following",
responses((status = 200, description = "Remote accounts I follow", body = Vec<RemoteActorResponse>)),
security(("bearer_auth" = []))
)]
pub async fn get_remote_following( pub async fn get_remote_following(
Deps(d): Deps<FederationManagementDeps>, Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
@@ -96,6 +132,11 @@ pub async fn get_remote_following(
Ok(Json(actors.into_iter().map(to_response).collect())) Ok(Json(actors.into_iter().map(to_response).collect()))
} }
#[utoipa::path(
get, path = "/federation/me/friends",
responses((status = 200, description = "Remote mutual follows (I follow them and they follow me)", body = Vec<RemoteActorResponse>)),
security(("bearer_auth" = []))
)]
pub async fn get_remote_friends_handler( pub async fn get_remote_friends_handler(
Deps(d): Deps<FederationManagementDeps>, Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
@@ -104,6 +145,15 @@ pub async fn get_remote_friends_handler(
Ok(Json(actors.into_iter().map(to_response).collect())) Ok(Json(actors.into_iter().map(to_response).collect()))
} }
#[utoipa::path(
delete, path = "/federation/me/following",
request_body = HandleBody,
responses(
(status = 204, description = "Unfollowed remote account"),
(status = 400, description = "Invalid handle", body = ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn delete_following( pub async fn delete_following(
Deps(d): Deps<FederationManagementDeps>, Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
@@ -121,6 +171,15 @@ pub async fn delete_following(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[utoipa::path(
post, path = "/federation/me/move",
request_body = MoveBody,
responses(
(status = 204, description = "Account move initiated"),
(status = 400, description = "Invalid URL", body = ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn post_move_account( pub async fn post_move_account(
Deps(d): Deps<FederationManagementDeps>, Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
@@ -132,11 +191,18 @@ pub async fn post_move_account(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[derive(Deserialize)] #[derive(Deserialize, utoipa::ToSchema)]
pub struct AlsoKnownAsBody { pub struct AlsoKnownAsBody {
/// Actor URL of the account this identity is also known as (for migration verification)
pub also_known_as: Option<String>, pub also_known_as: Option<String>,
} }
#[utoipa::path(
patch, path = "/federation/me/also-known-as",
request_body = AlsoKnownAsBody,
responses((status = 204, description = "Also-known-as updated")),
security(("bearer_auth" = []))
)]
pub async fn patch_also_known_as( pub async fn patch_also_known_as(
Deps(d): Deps<FederationManagementDeps>, Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,

View File

@@ -17,11 +17,59 @@ use axum::{
use domain::{ use domain::{
models::feed::PageParams, models::feed::PageParams,
ports::{ ports::{
FederationActionPort, FeedQuery, FeedRepository, FollowRepository, SearchPort, FederationActionPort, FeedFilter, FeedOptions, FeedQuery, FeedRepository, FeedRequest,
TagRepository, UserRepository, FeedSort, FollowRepository, SearchPort, TagRepository, UserRepository,
}, },
}; };
#[derive(serde::Deserialize, Default, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct FeedOptionsQuery {
/// Sort order: `newest` (default), `oldest`, `most_liked`, `most_boosted`, `most_discussed`
pub sort: Option<String>,
/// Show only original posts (mutually exclusive with `replies_only`)
pub originals_only: Option<bool>,
/// Show only replies (mutually exclusive with `originals_only`)
pub replies_only: Option<bool>,
/// Show only posts from this instance
pub local_only: Option<bool>,
/// Hide posts marked as sensitive
pub hide_sensitive: Option<bool>,
}
impl TryFrom<FeedOptionsQuery> for FeedOptions {
type Error = crate::errors::ApiError;
fn try_from(q: FeedOptionsQuery) -> Result<Self, Self::Error> {
if q.originals_only.unwrap_or(false) && q.replies_only.unwrap_or(false) {
return Err(crate::errors::ApiError::BadRequest(
"originals_only and replies_only are mutually exclusive".to_string(),
));
}
let sort = match q.sort.as_deref() {
None | Some("newest") => FeedSort::Newest,
Some("oldest") => FeedSort::Oldest,
Some("most_liked") => FeedSort::MostLiked,
Some("most_boosted") => FeedSort::MostBoosted,
Some("most_discussed") => FeedSort::MostDiscussed,
Some(other) => {
return Err(crate::errors::ApiError::BadRequest(format!(
"unknown sort value: {other}"
)))
}
};
Ok(FeedOptions {
sort,
filter: FeedFilter {
originals_only: q.originals_only.unwrap_or(false),
replies_only: q.replies_only.unwrap_or(false),
local_only: q.local_only.unwrap_or(false),
hide_sensitive: q.hide_sensitive.unwrap_or(false),
},
})
}
}
deps_struct!(FeedDeps { deps_struct!(FeedDeps {
feed: FeedRepository, feed: FeedRepository,
follows: FollowRepository, follows: FollowRepository,
@@ -54,7 +102,7 @@ pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtRespon
#[utoipa::path( #[utoipa::path(
get, path = "/feed", get, path = "/feed",
params(PaginationQuery), params(PaginationQuery, FeedOptionsQuery),
responses((status = 200, description = "Home feed")), responses((status = 200, description = "Home feed")),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
@@ -62,12 +110,14 @@ pub async fn home_feed(
Deps(d): Deps<FeedDeps>, Deps(d): Deps<FeedDeps>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
Query(q): Query<PaginationQuery>, Query(q): Query<PaginationQuery>,
Query(opts_q): Query<FeedOptionsQuery>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
let page = PageParams { let page = PageParams {
page: q.page(), page: q.page(),
per_page: q.per_page(), per_page: q.per_page(),
}; };
let result = get_home_feed(&*d.feed, &*d.follows, &uid, page).await?; let opts = FeedOptions::try_from(opts_q)?;
let result = get_home_feed(&*d.feed, &*d.follows, &uid, page, opts).await?;
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(), "items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
"total": result.total, "total": result.total,
@@ -78,19 +128,27 @@ pub async fn home_feed(
#[utoipa::path( #[utoipa::path(
get, path = "/feed/public", get, path = "/feed/public",
params(PaginationQuery), params(PaginationQuery, FeedOptionsQuery),
responses((status = 200, description = "Public feed")) responses((status = 200, description = "Public feed"))
)] )]
pub async fn public_feed( pub async fn public_feed(
Deps(d): Deps<FeedDeps>, Deps(d): Deps<FeedDeps>,
OptionalAuthUser(viewer): OptionalAuthUser, OptionalAuthUser(viewer): OptionalAuthUser,
Query(q): Query<PaginationQuery>, Query(q): Query<PaginationQuery>,
Query(opts_q): Query<FeedOptionsQuery>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
let page = PageParams { let page = PageParams {
page: q.page(), page: q.page(),
per_page: q.per_page(), per_page: q.per_page(),
}; };
let result = d.feed.query(&FeedQuery::public(page, viewer)).await?; let opts = FeedOptions::try_from(opts_q)?;
let result = d
.feed
.query(&FeedRequest {
query: FeedQuery::public(page, viewer),
options: opts,
})
.await?;
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(), "items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
"total": result.total, "total": result.total,
@@ -139,6 +197,14 @@ pub async fn search_handler(
}))) })))
} }
#[utoipa::path(
get, path = "/users/{username}/following",
params(
("username" = String, Path, description = "Username"),
PaginationQuery,
),
responses((status = 200, description = "Users this account follows"))
)]
pub async fn get_following_handler( pub async fn get_following_handler(
Deps(d): Deps<FeedDeps>, Deps(d): Deps<FeedDeps>,
Path(param): Path<String>, Path(param): Path<String>,
@@ -174,6 +240,14 @@ pub async fn get_following_handler(
.into_response()) .into_response())
} }
#[utoipa::path(
get, path = "/users/{username}/followers",
params(
("username" = String, Path, description = "Username"),
PaginationQuery,
),
responses((status = 200, description = "Accounts that follow this user"))
)]
pub async fn get_followers_handler( pub async fn get_followers_handler(
Deps(d): Deps<FeedDeps>, Deps(d): Deps<FeedDeps>,
Path(param): Path<String>, Path(param): Path<String>,
@@ -214,6 +288,7 @@ pub async fn get_followers_handler(
params( params(
("username" = String, Path, description = "Username"), ("username" = String, Path, description = "Username"),
PaginationQuery, PaginationQuery,
FeedOptionsQuery,
), ),
responses((status = 200, description = "User's public thoughts")) responses((status = 200, description = "User's public thoughts"))
)] )]
@@ -222,15 +297,20 @@ pub async fn user_thoughts_handler(
Path(username): Path<String>, Path(username): Path<String>,
OptionalAuthUser(viewer): OptionalAuthUser, OptionalAuthUser(viewer): OptionalAuthUser,
Query(q): Query<PaginationQuery>, Query(q): Query<PaginationQuery>,
Query(opts_q): Query<FeedOptionsQuery>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
let user = get_user_by_username(&*d.users, &username).await?; let user = get_user_by_username(&*d.users, &username).await?;
let page = PageParams { let page = PageParams {
page: q.page(), page: q.page(),
per_page: q.per_page(), per_page: q.per_page(),
}; };
let opts = FeedOptions::try_from(opts_q)?;
let result = d let result = d
.feed .feed
.query(&FeedQuery::user(user.id.clone(), page, viewer)) .query(&FeedRequest {
query: FeedQuery::user(user.id.clone(), page, viewer),
options: opts,
})
.await?; .await?;
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({
"total": result.total, "total": result.total,
@@ -240,6 +320,13 @@ pub async fn user_thoughts_handler(
}))) })))
} }
#[utoipa::path(
get, path = "/tags/popular",
params(
("limit" = Option<u64>, Query, description = "Max tags to return (default 20, max 100)"),
),
responses((status = 200, description = "Most-used tags"))
)]
pub async fn get_popular_tags( pub async fn get_popular_tags(
Deps(d): Deps<FeedDeps>, Deps(d): Deps<FeedDeps>,
Query(params): Query<std::collections::HashMap<String, String>>, Query(params): Query<std::collections::HashMap<String, String>>,
@@ -265,6 +352,7 @@ pub async fn get_popular_tags(
params( params(
("name" = String, Path, description = "Tag name"), ("name" = String, Path, description = "Tag name"),
PaginationQuery, PaginationQuery,
FeedOptionsQuery,
), ),
responses((status = 200, description = "Thoughts with this tag")) responses((status = 200, description = "Thoughts with this tag"))
)] )]
@@ -273,14 +361,19 @@ pub async fn tag_thoughts_handler(
Path(tag_name): Path<String>, Path(tag_name): Path<String>,
OptionalAuthUser(viewer): OptionalAuthUser, OptionalAuthUser(viewer): OptionalAuthUser,
Query(q): Query<PaginationQuery>, Query(q): Query<PaginationQuery>,
Query(opts_q): Query<FeedOptionsQuery>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
let page = PageParams { let page = PageParams {
page: q.page(), page: q.page(),
per_page: q.per_page(), per_page: q.per_page(),
}; };
let opts = FeedOptions::try_from(opts_q)?;
let result = d let result = d
.feed .feed
.query(&FeedQuery::tag(&tag_name, page, viewer)) .query(&FeedRequest {
query: FeedQuery::tag(&tag_name, page, viewer),
options: opts,
})
.await?; .await?;
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({
"tag": tag_name, "tag": tag_name,

View File

@@ -138,6 +138,12 @@ pub async fn get_me(
Ok(Json(to_user_response(&user))) Ok(Json(to_user_response(&user)))
} }
#[utoipa::path(
get, path = "/users/me/following",
params(PaginationQuery),
responses((status = 200, description = "Users I follow")),
security(("bearer_auth" = []))
)]
pub async fn get_me_following( pub async fn get_me_following(
Deps(d): Deps<UsersDeps>, Deps(d): Deps<UsersDeps>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
@@ -155,6 +161,15 @@ pub async fn get_me_following(
}))) })))
} }
#[utoipa::path(
get, path = "/users",
params(
("page" = Option<u64>, Query, description = "Page number (default 1)"),
("per_page" = Option<u64>, Query, description = "Items per page (default 20, max 100)"),
("q" = Option<String>, Query, description = "Search query to filter users"),
),
responses((status = 200, description = "Paginated user list"))
)]
pub async fn get_users( pub async fn get_users(
Deps(d): Deps<UsersDeps>, Deps(d): Deps<UsersDeps>,
Query(params): Query<std::collections::HashMap<String, String>>, Query(params): Query<std::collections::HashMap<String, String>>,
@@ -206,16 +221,30 @@ pub async fn get_users(
}))) })))
} }
#[utoipa::path(
get, path = "/users/count",
responses((status = 200, description = "Total number of local users"))
)]
pub async fn get_user_count(Deps(d): Deps<UsersDeps>) -> Result<Json<serde_json::Value>, ApiError> { pub async fn get_user_count(Deps(d): Deps<UsersDeps>) -> Result<Json<serde_json::Value>, ApiError> {
let count = d.users.count().await?; let count = d.users.count().await?;
Ok(Json(serde_json::json!({ "count": count }))) Ok(Json(serde_json::json!({ "count": count })))
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct LookupQuery { pub struct LookupQuery {
/// Fediverse handle in the format `@user@instance.tld`
pub handle: String, pub handle: String,
} }
#[utoipa::path(
get, path = "/users/lookup",
params(LookupQuery),
responses(
(status = 200, description = "Remote actor profile", body = RemoteActorResponse),
(status = 404, description = "Actor not found", body = ErrorResponse),
),
)]
pub async fn lookup_handler( pub async fn lookup_handler(
Deps(d): Deps<UsersDeps>, Deps(d): Deps<UsersDeps>,
Query(q): Query<LookupQuery>, Query(q): Query<LookupQuery>,
@@ -240,6 +269,15 @@ pub async fn lookup_handler(
})) }))
} }
#[utoipa::path(
put, path = "/users/me/avatar",
request_body(content = String, content_type = "multipart/form-data", description = "Image file (JPEG, PNG, WebP, AVIF, GIF)"),
responses(
(status = 200, description = "Updated user profile", body = UserResponse),
(status = 400, description = "Invalid or missing file", body = ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn upload_avatar( pub async fn upload_avatar(
Deps(d): Deps<UsersDeps>, Deps(d): Deps<UsersDeps>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
@@ -276,6 +314,15 @@ pub async fn upload_avatar(
Ok(Json(to_user_response(&user))) Ok(Json(to_user_response(&user)))
} }
#[utoipa::path(
put, path = "/users/me/banner",
request_body(content = String, content_type = "multipart/form-data", description = "Image file (JPEG, PNG, WebP, AVIF, GIF)"),
responses(
(status = 200, description = "Updated user profile", body = UserResponse),
(status = 400, description = "Invalid or missing file", body = ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn upload_banner( pub async fn upload_banner(
Deps(d): Deps<UsersDeps>, Deps(d): Deps<UsersDeps>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,

View File

@@ -0,0 +1,15 @@
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::federation_actors::remote_actor_posts_handler,
crate::handlers::federation_actors::actor_followers_handler,
crate::handlers::federation_actors::actor_following_handler,
),
components(schemas(
api_types::responses::ActorConnectionPageResponse,
api_types::responses::ActorConnectionResponse,
))
)]
pub struct FederationActorsDoc;

View File

@@ -0,0 +1,25 @@
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::federation_management::get_pending_requests,
crate::handlers::federation_management::post_accept_request,
crate::handlers::federation_management::delete_follower,
crate::handlers::federation_management::get_remote_followers,
crate::handlers::federation_management::get_remote_following,
crate::handlers::federation_management::get_remote_friends_handler,
crate::handlers::federation_management::delete_following,
crate::handlers::federation_management::post_move_account,
crate::handlers::federation_management::patch_also_known_as,
),
components(schemas(
api_types::responses::RemoteActorResponse,
api_types::responses::ProfileField,
crate::handlers::federation_management::ActorUrlBody,
crate::handlers::federation_management::HandleBody,
crate::handlers::federation_management::MoveBody,
crate::handlers::federation_management::AlsoKnownAsBody,
))
)]
pub struct FederationManagementDoc;

View File

@@ -1,5 +1,7 @@
mod api_keys; mod api_keys;
mod auth; mod auth;
mod federation_actors;
mod federation_management;
mod feed; mod feed;
mod health; mod health;
mod notifications; mod notifications;
@@ -48,6 +50,8 @@ fn build() -> utoipa::openapi::OpenApi {
api.merge(notifications::NotificationsDoc::openapi()); api.merge(notifications::NotificationsDoc::openapi());
api.merge(api_keys::ApiKeysDoc::openapi()); api.merge(api_keys::ApiKeysDoc::openapi());
api.merge(health::HealthDoc::openapi()); api.merge(health::HealthDoc::openapi());
api.merge(federation_management::FederationManagementDoc::openapi());
api.merge(federation_actors::FederationActorsDoc::openapi());
SecurityAddon.modify(&mut api); SecurityAddon.modify(&mut api);
api api
} }

View File

@@ -0,0 +1,31 @@
"use client";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="min-h-[80vh] flex items-center justify-center px-4">
<div className="glass-effect glossy-effect bottom rounded-2xl shadow-fa-lg p-10 text-center max-w-sm w-full">
<p className="text-6xl font-black text-muted-foreground/30 mb-4">Oops</p>
<h1 className="text-xl font-bold mb-2">Something went wrong</h1>
<p className="text-sm text-muted-foreground mb-8">
An unexpected error occurred. Try again or come back later.
</p>
<Button onClick={reset} className="rounded-full">
Try again
</Button>
</div>
</div>
);
}

View File

@@ -189,6 +189,11 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
/* Prevent iOS Safari auto-zoom on input focus (triggered when font-size < 16px) */
input, select, textarea {
font-size: max(16px, 1em);
}
.glossy-effect::before { .glossy-effect::before {
content: ""; content: "";
position: absolute; position: absolute;
@@ -473,3 +478,98 @@
background: rgba(96, 165, 250, 0.18); background: rgba(96, 165, 250, 0.18);
} }
} }
/* ── Landing page animations ── */
@keyframes landing-bg-shift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
@keyframes landing-orb-float {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-22px) scale(1.05); }
}
@keyframes landing-gloss {
0% { transform: translateX(-120%) skewX(-15deg); opacity: 0; }
8% { opacity: 1; }
28% { transform: translateX(220%) skewX(-15deg); opacity: 0; }
100% { transform: translateX(220%) skewX(-15deg); opacity: 0; }
}
@keyframes landing-badge-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 4px #34d399; }
50% { opacity: 0.5; box-shadow: 0 0 10px #34d399; }
}
@keyframes landing-card-in {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
@layer components {
.landing-bg {
background: linear-gradient(135deg, #e0f2fe, #f0fdf4, #ede9fe, #e0f2fe);
background-size: 400% 400%;
animation: landing-bg-shift 30s ease infinite;
}
.landing-orb {
position: absolute;
border-radius: 50%;
pointer-events: none;
filter: blur(40px);
animation: landing-orb-float var(--orb-duration, 16s) ease-in-out infinite;
animation-delay: var(--orb-delay, 0s);
}
.landing-hero-card {
position: relative;
overflow: hidden;
}
.landing-hero-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
105deg,
transparent 35%,
rgba(255, 255, 255, 0.55) 50%,
transparent 65%
);
animation: landing-gloss 6s ease-in-out infinite;
pointer-events: none;
z-index: 1;
}
.landing-badge-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #34d399;
animation: landing-badge-pulse 2s ease-in-out infinite;
}
.landing-cta {
background: linear-gradient(135deg, #3b82f6, #06b6d4);
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.45);
transition: box-shadow 0.2s ease;
}
.landing-cta:hover {
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.6);
}
.landing-card-animate {
opacity: 0;
transform: translateY(24px);
}
.landing-card-animate.visible {
animation: landing-card-in 400ms ease-out forwards;
}
}

View File

@@ -0,0 +1,19 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function NotFound() {
return (
<div className="min-h-[80vh] flex items-center justify-center px-4">
<div className="glass-effect glossy-effect bottom rounded-2xl shadow-fa-lg p-10 text-center max-w-sm w-full">
<p className="text-6xl font-black text-muted-foreground/30 mb-4">404</p>
<h1 className="text-xl font-bold mb-2">Page not found</h1>
<p className="text-sm text-muted-foreground mb-8">
This page doesn&apos;t exist or was moved.
</p>
<Button asChild className="rounded-full">
<Link href="/">Go home</Link>
</Button>
</div>
</div>
);
}

View File

@@ -1,16 +1,17 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { getFeed, getMe, Me } from "@/lib/api"; import { getFeed, getMe, Me, FeedOptions, FeedSortOption } from "@/lib/api";
import { FiltersSortingPanel } from "@/components/filters-sorting-panel";
import { ThoughtForm } from "@/components/thought-form"; import { ThoughtForm } from "@/components/thought-form";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { Button } from "@/components/ui/button"; import { LandingPage } from "@/components/landing-page";
import Link from "next/link";
import { PopularTags } from "@/components/popular-tags"; import { PopularTags } from "@/components/popular-tags";
import { ThoughtThread } from "@/components/thought-thread"; import { ThoughtThread } from "@/components/thought-thread";
import { buildThoughtThreads } from "@/lib/utils"; import { buildThoughtThreads } from "@/lib/utils";
import { TopFriends } from "@/components/top-friends"; import { TopFriends } from "@/components/top-friends";
import { UsersCount } from "@/components/users-count"; import { UsersCount } from "@/components/users-count";
import { PaginationNav } from "@/components/pagination-nav"; import { PaginationNav } from "@/components/pagination-nav";
import { FediverseHandleWidget } from "@/components/fediverse-handle-widget";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Suspense } from "react"; import { Suspense } from "react";
import { import {
@@ -27,7 +28,14 @@ export const metadata: Metadata = {
export default async function Home({ export default async function Home({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ page?: string }>; searchParams: Promise<{
page?: string;
sort?: string;
originals_only?: string;
replies_only?: string;
local_only?: string;
hide_sensitive?: string;
}>;
}) { }) {
const token = (await cookies()).get("auth_token")?.value ?? null; const token = (await cookies()).get("auth_token")?.value ?? null;
const resolvedSearchParams = await searchParams; const resolvedSearchParams = await searchParams;
@@ -44,12 +52,27 @@ async function FeedPage({
searchParams, searchParams,
}: { }: {
token: string; token: string;
searchParams: { page?: string }; searchParams: {
page?: string;
sort?: string;
originals_only?: string;
replies_only?: string;
local_only?: string;
hide_sensitive?: string;
};
}) { }) {
const page = parseInt(searchParams.page ?? "1", 10); const page = parseInt(searchParams.page ?? "1", 10);
const feedOpts: FeedOptions = {
sort: searchParams.sort as FeedSortOption | undefined,
originals_only: searchParams.originals_only === "true",
replies_only: searchParams.replies_only === "true",
local_only: searchParams.local_only === "true",
hide_sensitive: searchParams.hide_sensitive === "true",
};
const [feedData, me] = await Promise.all([ const [feedData, me] = await Promise.all([
getFeed(token, page).catch(() => null), getFeed(token, page, 20, feedOpts).catch(() => null),
getMe(token).catch(() => null) as Promise<Me | null>, getMe(token).catch(() => null) as Promise<Me | null>,
]); ]);
@@ -62,6 +85,7 @@ async function FeedPage({
const sidebar = ( const sidebar = (
<> <>
<FediverseHandleWidget username={me.username} />
<Suspense fallback={<ProfileSkeleton />}> <Suspense fallback={<ProfileSkeleton />}>
<TopFriends username={me.username} /> <TopFriends username={me.username} />
</Suspense> </Suspense>
@@ -80,7 +104,9 @@ async function FeedPage({
<aside className="hidden lg:block lg:col-span-1"> <aside className="hidden lg:block lg:col-span-1">
<div className="sticky top-20 space-y-6 glass-effect glossy-effect bottom rounded-md p-4"> <div className="sticky top-20 space-y-6 glass-effect glossy-effect bottom rounded-md p-4">
<h2 className="text-lg font-semibold">Filters &amp; Sorting</h2> <h2 className="text-lg font-semibold">Filters &amp; Sorting</h2>
<p className="text-sm text-muted-foreground">Coming soon...</p> <Suspense>
<FiltersSortingPanel />
</Suspense>
</div> </div>
</aside> </aside>
@@ -125,114 +151,3 @@ async function FeedPage({
); );
} }
function LandingPage() {
return (
<div className="font-sans min-h-screen flex items-center justify-center relative overflow-hidden">
{/* Ambient orbs */}
<div
className="orb"
style={{
width: 280,
height: 280,
background:
"radial-gradient(circle, #ffffff 0%, #87ceeb 60%, transparent 100%)",
top: "-80px",
left: "-60px",
}}
/>
<div
className="orb"
style={{
width: 220,
height: 220,
background:
"radial-gradient(circle, #b2f5ea 0%, #48bb78 60%, transparent 100%)",
bottom: "-40px",
right: "5%",
}}
/>
<div
className="orb"
style={{
width: 160,
height: 160,
background:
"radial-gradient(circle, #e0f2fe 0%, #38bdf8 60%, transparent 100%)",
top: "35%",
left: "65%",
}}
/>
{/* Hero card */}
<div
className="container mx-auto max-w-lg p-4 sm:p-6 text-center relative z-10"
style={{
background: "rgba(255,255,255,0.28)",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
border: "1px solid rgba(255,255,255,0.55)",
borderRadius: "20px",
boxShadow:
"0 8px 32px rgba(0,0,0,0.10), inset 0 1px 0 rgba(255,255,255,0.6)",
}}
>
{/* Gloss sweep */}
<div
aria-hidden
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "55%",
background:
"linear-gradient(180deg, rgba(255,255,255,0.38) 0%, transparent 100%)",
borderRadius: "20px 20px 0 0",
pointerEvents: "none",
}}
/>
<h1
className="text-5xl font-bold relative"
style={{
textShadow:
"0 2px 4px rgba(255,255,255,0.6), 0 1px 2px rgba(0,0,0,0.1)",
}}
>
Welcome to Thoughts
</h1>
<p className="text-muted-foreground mt-3 relative">
A federated social network for short-form thoughts.
<br />
Connect with the Fediverse.
</p>
<div className="mt-8 flex justify-center gap-4 relative">
<Button asChild className="px-7">
<Link href="/login">Login</Link>
</Button>
<Button asChild variant="secondary" className="px-7">
<Link href="/register">Register</Link>
</Button>
</div>
{/* Fediverse badge */}
<div className="mt-5 relative flex justify-center">
<span
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full text-xs text-muted-foreground"
style={{
background: "rgba(255,255,255,0.3)",
border: "1px solid rgba(255,255,255,0.5)",
}}
>
<span
className="w-2 h-2 rounded-full bg-emerald-400 inline-block"
style={{ boxShadow: "0 0 4px #34d399" }}
/>
Works with Mastodon, Pixelfed &amp; more
</span>
</div>
</div>
</div>
);
}

View File

@@ -40,11 +40,15 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Find users and thoughts across the platform. Find users and thoughts across the platform.
</p> </p>
<p className="text-xs text-muted-foreground mt-1">
To find someone on Mastodon, type their full handle: @alice@mastodon.social
</p>
</div> </div>
); );
} }
const isHandle = HANDLE_RE.test(query); const isHandle = HANDLE_RE.test(query);
const isPartialHandle = !isHandle && query.includes("@");
const [results, remoteActor, me] = await Promise.all([ const [results, remoteActor, me] = await Promise.all([
isHandle ? null : search(query, token).catch(() => null), isHandle ? null : search(query, token).catch(() => null),
@@ -61,6 +65,11 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
</p> </p>
</header> </header>
<main> <main>
{isPartialHandle && (
<p className="text-xs text-muted-foreground mb-4">
Looks like a fediverse handle. Use the full format: @alice@mastodon.social
</p>
)}
{isHandle ? ( {isHandle ? (
remoteActor ? ( remoteActor ? (
<div className="space-y-4"> <div className="space-y-4">

View File

@@ -51,7 +51,7 @@ import { Card } from "@/components/ui/card";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { FollowButton } from "@/components/follow-button"; import { FollowButton } from "@/components/follow-button";
import { TopFriends } from "@/components/top-friends"; import { ProfileFriendsWidget } from "@/components/profile-friends-widget";
import { Suspense } from "react"; import { Suspense } from "react";
import { ProfileSkeleton } from "@/components/loading-skeleton"; import { ProfileSkeleton } from "@/components/loading-skeleton";
import { UserThoughtsList } from "@/components/user-thoughts-list"; import { UserThoughtsList } from "@/components/user-thoughts-list";
@@ -262,7 +262,11 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
</Card> </Card>
<Suspense fallback={<ProfileSkeleton />}> <Suspense fallback={<ProfileSkeleton />}>
<TopFriends username={user.username} /> <ProfileFriendsWidget
username={user.username}
isOwnProfile={isOwnProfile}
token={token}
/>
</Suspense> </Suspense>
</div> </div>
</aside> </aside>

View File

@@ -0,0 +1,82 @@
import Link from "next/link";
import { User, RemoteActor } from "@/lib/api";
import { UserAvatar } from "./user-avatar";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface AllFriendsCardProps {
localFriends: User[];
remoteFriends: RemoteActor[];
}
export function AllFriendsCard({ localFriends, remoteFriends }: AllFriendsCardProps) {
const total = localFriends.length + remoteFriends.length;
if (total === 0) return null;
const localSlice = localFriends.slice(0, 6);
const remoteSlice = remoteFriends.slice(0, Math.max(0, 6 - localSlice.length));
return (
<Card id="all-friends" className="p-4">
<CardHeader className="p-0 pb-3">
<CardTitle className="text-lg">
Friends
</CardTitle>
</CardHeader>
<CardContent className="p-0 space-y-1">
{localSlice.map((friend) => (
<Link
key={friend.id}
href={`/users/${friend.username}`}
className="flex items-center gap-3 py-2 px-2 -mx-2 rounded-lg hover:bg-accent/50 transition-colors"
>
<UserAvatar
src={friend.avatarUrl}
alt={friend.displayName || friend.username}
/>
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold truncate text-shadow-sm">
{friend.displayName || friend.username}
</span>
<span className="text-[10px] text-muted-foreground truncate">
@{friend.username}
</span>
</div>
</Link>
))}
{remoteSlice.map((actor) => (
<a
key={actor.url}
href={actor.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 py-2 px-2 -mx-2 rounded-lg hover:bg-accent/50 transition-colors"
>
<UserAvatar
src={actor.avatarUrl}
alt={actor.displayName ?? actor.handle}
/>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-xs font-semibold truncate">
{actor.displayName || actor.handle}
</span>
<span className="text-[10px] text-muted-foreground truncate">
@{actor.handle}
</span>
</div>
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-500 shrink-0">
federated
</span>
</a>
))}
{total > 6 && (
<Link
href="/friends"
className="block text-xs text-muted-foreground hover:text-foreground pt-2 transition-colors"
>
See all {total} friends
</Link>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,32 @@
import Link from "next/link";
import { CopyButton } from "./copy-button";
interface FediverseHandleWidgetProps {
username: string;
}
export function FediverseHandleWidget({ username }: FediverseHandleWidgetProps) {
const domain = process.env.NEXT_PUBLIC_FEDIVERSE_DOMAIN;
if (!domain) return null;
const handle = `@${username}@${domain}`;
return (
<div className="glass-effect rounded-md shadow-fa-lg p-4">
<h2 className="text-sm font-semibold mb-2">Your fediverse handle</h2>
<div className="flex items-center gap-1 mb-2">
<p className="text-xs font-mono text-muted-foreground break-all">{handle}</p>
<CopyButton text={handle} />
</div>
<p className="text-xs text-muted-foreground mb-2">
Anyone on Mastodon or Pixelfed can follow you with this.
</p>
<Link
href="/about/fediverse"
className="text-xs text-primary hover:underline"
>
Learn more
</Link>
</div>
);
}

View File

@@ -0,0 +1,168 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { FeedSortOption } from "@/lib/api";
const SORT_OPTIONS: { value: FeedSortOption; label: string }[] = [
{ value: "newest", label: "Newest first" },
{ value: "oldest", label: "Oldest first" },
{ value: "most_liked", label: "Most liked" },
{ value: "most_boosted", label: "Most boosted" },
{ value: "most_discussed", label: "Most discussed" },
];
export function FiltersSortingPanel() {
const router = useRouter();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const [sort, setSort] = useState<FeedSortOption>(
(searchParams.get("sort") as FeedSortOption | null) ?? "newest"
);
const [originalsOnly, setOriginalsOnly] = useState(
searchParams.get("originals_only") === "true"
);
const [repliesOnly, setRepliesOnly] = useState(
searchParams.get("replies_only") === "true"
);
const [localOnly, setLocalOnly] = useState(
searchParams.get("local_only") === "true"
);
const [hideSensitive, setHideSensitive] = useState(
searchParams.get("hide_sensitive") === "true"
);
function pushParams(updates: Record<string, string | null>) {
const params = new URLSearchParams(searchParams.toString());
params.delete("page");
for (const [key, value] of Object.entries(updates)) {
if (value === null) {
params.delete(key);
} else {
params.set(key, value);
}
}
startTransition(() => router.replace(`/?${params.toString()}`));
}
function handleSort(value: FeedSortOption) {
setSort(value);
pushParams({ sort: value === "newest" ? null : value });
}
function handleOriginalsOnly(checked: boolean) {
setOriginalsOnly(checked);
if (checked) setRepliesOnly(false);
const updates: Record<string, string | null> = {
originals_only: checked ? "true" : null,
};
if (checked) updates.replies_only = null;
pushParams(updates);
}
function handleRepliesOnly(checked: boolean) {
setRepliesOnly(checked);
if (checked) setOriginalsOnly(false);
const updates: Record<string, string | null> = {
replies_only: checked ? "true" : null,
};
if (checked) updates.originals_only = null;
pushParams(updates);
}
function handleLocalOnly(checked: boolean) {
setLocalOnly(checked);
pushParams({ local_only: checked ? "true" : null });
}
function handleHideSensitive(checked: boolean) {
setHideSensitive(checked);
pushParams({ hide_sensitive: checked ? "true" : null });
}
return (
<div
className={`space-y-3 transition-opacity duration-150 ${
isPending ? "opacity-50 pointer-events-none" : ""
}`}
>
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
Sort by
</p>
<RadioGroup
value={sort}
onValueChange={(v) => handleSort(v as FeedSortOption)}
className="space-y-1"
>
{SORT_OPTIONS.map((opt) => (
<div key={opt.value} className="flex items-center gap-2">
<RadioGroupItem value={opt.value} id={`sort-${opt.value}`} />
<Label
htmlFor={`sort-${opt.value}`}
className="text-xs font-normal cursor-pointer"
>
{opt.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<Separator />
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
Filter
</p>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Checkbox
id="originals-only"
checked={originalsOnly}
onCheckedChange={(c) => handleOriginalsOnly(c === true)}
/>
<Label htmlFor="originals-only" className="text-xs font-normal cursor-pointer">
Originals only
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="replies-only"
checked={repliesOnly}
onCheckedChange={(c) => handleRepliesOnly(c === true)}
/>
<Label htmlFor="replies-only" className="text-xs font-normal cursor-pointer">
Replies only
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="local-only"
checked={localOnly}
onCheckedChange={(c) => handleLocalOnly(c === true)}
/>
<Label htmlFor="local-only" className="text-xs font-normal cursor-pointer">
Local only
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="hide-sensitive"
checked={hideSensitive}
onCheckedChange={(c) => handleHideSensitive(c === true)}
/>
<Label htmlFor="hide-sensitive" className="text-xs font-normal cursor-pointer">
Hide sensitive
</Label>
</div>
</div>
</div>
</div>
);
}

View File

@@ -27,7 +27,7 @@ export function Header() {
</span> </span>
</Link> </Link>
<MainNav /> <MainNav isLoggedIn={!!token} />
<div className="flex flex-1 items-center justify-end space-x-2"> <div className="flex flex-1 items-center justify-end space-x-2">
{token ? ( {token ? (

View File

@@ -0,0 +1,61 @@
"use client";
import { useEffect, useRef } from "react";
const FEATURES = [
{
title: "Say it in 128",
body: "Short, focused thoughts. No bloat, no essays.",
},
{
title: "Make it yours",
body: "Customize your profile with CSS. Full creative control.",
},
{
title: "Your audience, your rules",
body: "Public, followers-only, unlisted, or direct. You pick for each post.",
},
{
title: "Movies Diary",
body: "Your Movies Diary posts show up as rich cards with ratings and posters. Feels native.",
},
];
export function LandingFeatures() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const cards = Array.from(
ref.current?.querySelectorAll("[data-animate]") ?? []
);
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("visible");
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.15 }
);
cards.forEach((card) => observer.observe(card));
return () => observer.disconnect();
}, []);
return (
<div ref={ref} className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-8">
{FEATURES.map((f, i) => (
<div
key={f.title}
data-animate
className="landing-card-animate glass-effect rounded-xl p-6 shadow-fa-md"
style={{ animationDelay: `${i * 100}ms` }}
>
<h3 className="font-bold text-base mb-1">{f.title}</h3>
<p className="text-sm text-muted-foreground">{f.body}</p>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,179 @@
import Link from "next/link";
import { ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { LandingFeatures } from "./landing-features";
export function LandingPage() {
return (
<div className="min-h-screen relative overflow-hidden font-sans">
{/* Background blur overlay */}
<div className="fixed inset-0 bg-white/30 backdrop-blur-sm -z-[5]" />
{/* Ambient orbs */}
<div
className="landing-orb"
style={{
width: 280, height: 280,
background: "radial-gradient(circle, rgba(147,210,255,0.55) 0%, transparent 70%)",
top: -80, left: -60,
"--orb-duration": "16s",
"--orb-delay": "0s",
} as React.CSSProperties}
/>
<div
className="landing-orb"
style={{
width: 220, height: 220,
background: "radial-gradient(circle, rgba(134,239,172,0.5) 0%, transparent 70%)",
bottom: "10%", right: "5%",
"--orb-duration": "20s",
"--orb-delay": "-5s",
animationDirection: "reverse",
} as React.CSSProperties}
/>
<div
className="landing-orb"
style={{
width: 160, height: 160,
background: "radial-gradient(circle, rgba(196,181,253,0.5) 0%, transparent 70%)",
top: "35%", left: "65%",
"--orb-duration": "14s",
"--orb-delay": "-8s",
} as React.CSSProperties}
/>
<div
className="landing-orb hidden sm:block"
style={{
width: 200, height: 200,
background: "radial-gradient(circle, rgba(253,186,116,0.3) 0%, transparent 70%)",
top: "60%", left: "10%",
"--orb-duration": "18s",
"--orb-delay": "-3s",
animationDirection: "reverse",
} as React.CSSProperties}
/>
<div
className="landing-orb hidden lg:block"
style={{
width: 140, height: 140,
background: "radial-gradient(circle, rgba(167,243,208,0.45) 0%, transparent 70%)",
top: "20%", right: "20%",
"--orb-duration": "12s",
"--orb-delay": "-10s",
} as React.CSSProperties}
/>
{/* ── Section 1: Hero ── */}
<section className="relative z-10 flex items-center justify-center min-h-screen px-4 py-16">
<div className="flex flex-col items-center gap-6 w-full max-w-md mx-auto">
<div className="landing-hero-card glass-effect rounded-2xl shadow-fa-lg p-8 sm:p-12 text-center w-full">
<h1 className="text-4xl sm:text-5xl font-black tracking-tight mb-3 text-shadow-sm">
Thoughts
</h1>
<p className="text-muted-foreground text-base sm:text-lg mb-8">
128 characters. No algorithms. Your web.
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center mb-6 relative z-10">
<Button
asChild
size="lg"
className="landing-cta rounded-full border-0 text-white"
>
<Link href="/register">Register</Link>
</Button>
<Button asChild size="lg" variant="outline" className="rounded-full">
<Link href="/login">Sign in</Link>
</Button>
</div>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground relative z-10">
<span className="landing-badge-dot" />
Works with Mastodon, Pixelfed &amp; more
</div>
</div>
<ChevronDown
className="animate-float-bob text-muted-foreground/60"
size={28}
strokeWidth={1.5}
/>
</div>
</section>
{/* ── Section 2: Features ── */}
<section className="relative z-10 container mx-auto max-w-3xl px-4 py-16">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground text-center mb-2">
What you can do
</p>
<LandingFeatures />
</section>
{/* ── Section 3: Fediverse ── */}
<section className="relative z-10 container mx-auto max-w-2xl px-4 py-16">
<div className="glass-effect rounded-2xl p-8 shadow-fa-md border border-sky-200/30">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-2">
Part of something bigger
</p>
<h2 className="text-2xl font-bold mb-4">
Thoughts speaks ActivityPub
</h2>
<p className="text-muted-foreground text-sm leading-relaxed mb-6">
Follow and be followed by anyone on Mastodon, Pixelfed, or any
ActivityPub-compatible platform. Your thoughts travel across the
open web.
</p>
<div className="flex flex-wrap gap-2 mb-6">
{["Mastodon", "Pixelfed"].map((label) => (
<span
key={label}
className="px-3 py-1 rounded-full text-xs font-medium bg-white/40 border border-white/60 hover:shadow-fa-sm transition-shadow cursor-default"
>
{label}
</span>
))}
</div>
<Link
href="/about/fediverse"
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
>
What is the Fediverse?
</Link>
</div>
</section>
{/* ── Section 4: Vision ── */}
<section className="relative z-10 container mx-auto max-w-xl px-4 py-16 text-center">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">
Why we built this
</p>
<h2 className="text-2xl sm:text-3xl font-bold mb-6">
The web used to feel human
</h2>
<p className="text-muted-foreground leading-relaxed mb-6">
No algorithm feeds. No ads. Just a timeline of people you actually
follow, on a profile you can make look however you want.
</p>
<p className="text-sm italic text-muted-foreground">
That version of the web still exists.
</p>
</section>
{/* ── Section 5: Footer CTA ── */}
<section className="relative z-10 container mx-auto max-w-md px-4 py-16 text-center">
<div className="landing-hero-card glass-effect rounded-2xl p-8 shadow-fa-lg">
<h2 className="text-xl font-bold mb-6">Ready to join?</h2>
<Button
asChild
size="lg"
className="landing-cta rounded-full border-0 text-white w-full mb-3"
>
<Link href="/register">Register</Link>
</Button>
<p className="text-sm text-muted-foreground relative z-10">
Already have an account?{" "}
<Link href="/login" className="text-primary hover:underline">
Sign in
</Link>
</p>
</div>
</section>
</div>
);
}

View File

@@ -1,33 +1,89 @@
"use client"; "use client";
import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Menu } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SearchInput } from "./search-input"; import { SearchInput } from "./search-input";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
export function MainNav() { interface MainNavProps {
isLoggedIn?: boolean;
}
const NAV_LINKS = (isLoggedIn: boolean) => [
{ href: "/users/all", label: "Discover" },
{ href: "/about/fediverse", label: "Fediverse" },
...(isLoggedIn ? [{ href: "/friends", label: "Friends" }] : []),
];
export function MainNav({ isLoggedIn }: MainNavProps) {
const pathname = usePathname(); const pathname = usePathname();
const [open, setOpen] = useState(false);
const links = NAV_LINKS(!!isLoggedIn);
return ( return (
<nav className="inline-flex md:flex items-center space-x-6 text-sm font-medium"> <>
{/* Mobile: hamburger + search fills center */}
<div className="flex md:hidden items-center gap-2 flex-1 mx-2">
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Open menu" className="shrink-0">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-72 glass-effect">
<SheetHeader>
<SheetTitle className="text-left">Menu</SheetTitle>
</SheetHeader>
<nav className="flex flex-col gap-1 mt-6">
{links.map(({ href, label }) => (
<Link <Link
href="/users/all" key={href}
href={href}
onClick={() => setOpen(false)}
className={cn( className={cn(
"transition-colors hover:text-foreground/80", "px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-accent",
pathname === "/users/all" ? "text-foreground" : "text-foreground/60" pathname === href
? "bg-accent text-foreground"
: "text-foreground/70"
)} )}
> >
Discover {label}
</Link> </Link>
))}
</nav>
</SheetContent>
</Sheet>
<div className="flex-1">
<SearchInput />
</div>
</div>
{/* Desktop nav */}
<nav className="hidden md:flex items-center space-x-6 text-sm font-medium">
{links.map(({ href, label }) => (
<Link <Link
href="/about/fediverse" key={href}
href={href}
className={cn( className={cn(
"transition-colors hover:text-foreground/80", "transition-colors hover:text-foreground/80",
pathname === "/about/fediverse" ? "text-foreground" : "text-foreground/60" pathname === href ? "text-foreground" : "text-foreground/60"
)} )}
> >
Fediverse {label}
</Link> </Link>
))}
<SearchInput /> <SearchInput />
</nav> </nav>
</>
); );
} }

View File

@@ -59,15 +59,20 @@ export function MovieCard({ meta, author, createdAt }: MovieCardProps) {
<CardHeader className="flex flex-row items-center space-y-0 pb-3"> <CardHeader className="flex flex-row items-center space-y-0 pb-3">
<Link <Link
href={profileHref(author.username, author.local)} href={profileHref(author.username, author.local)}
className="flex items-center gap-3 hover:opacity-80" className="flex items-center gap-3 hover:opacity-80 min-w-0 w-full"
> >
<UserAvatar src={author.avatarUrl} alt={author.displayName ?? author.username} /> <UserAvatar src={author.avatarUrl} alt={author.displayName ?? author.username} />
<div className="flex flex-col min-w-0"> <div className="flex flex-col min-w-0">
<span className="font-bold truncate">{author.displayName ?? author.username}</span> <span className="font-bold truncate">{author.displayName ?? author.username}</span>
{!author.local && ( {!author.local && (
<span className="text-xs text-muted-foreground/70 truncate"> <div className="flex items-center gap-1.5 min-w-0">
<span className="text-xs text-muted-foreground/70 truncate flex-1">
{author.username.startsWith("@") ? author.username : `@${author.username}`} {author.username.startsWith("@") ? author.username : `@${author.username}`}
</span> </span>
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-500 shrink-0 max-w-[8rem] truncate">
{author.username.split("@").filter(Boolean).at(-1)}
</span>
</div>
)} )}
<time <time
dateTime={createdAt.toISOString()} dateTime={createdAt.toISOString()}

View File

@@ -0,0 +1,37 @@
import { getTopFriends, getMyFriends, getMyRemoteFriends } from "@/lib/api";
import { TopFriends } from "./top-friends";
import { AllFriendsCard } from "./all-friends-card";
interface ProfileFriendsWidgetProps {
username: string;
isOwnProfile: boolean;
token: string | null;
}
export async function ProfileFriendsWidget({
username,
isOwnProfile,
token,
}: ProfileFriendsWidgetProps) {
const topFriendsData = await getTopFriends(username, token).catch(() => ({
topFriends: [],
}));
if (topFriendsData.topFriends.length > 0) {
return <TopFriends username={username} />;
}
if (!isOwnProfile || !token) return null;
const [localData, remoteData] = await Promise.all([
getMyFriends(token).catch(() => ({ items: [], total: 0, page: 1, perPage: 50 })),
getMyRemoteFriends(token).catch(() => []),
]);
return (
<AllFriendsCard
localFriends={localData.items}
remoteFriends={remoteData}
/>
);
}

View File

@@ -53,6 +53,7 @@ export function RemoteUserCard({ actor }: RemoteUserCardProps) {
<p className="text-sm text-muted-foreground truncate">{actor.handle}</p> <p className="text-sm text-muted-foreground truncate">{actor.handle}</p>
</div> </div>
</Link> </Link>
<div className="flex flex-col items-end gap-1">
<Button <Button
onClick={handleFollow} onClick={handleFollow}
disabled={loading || followed} disabled={loading || followed}
@@ -62,6 +63,12 @@ export function RemoteUserCard({ actor }: RemoteUserCardProps) {
<UserPlus className="mr-2 h-4 w-4" /> <UserPlus className="mr-2 h-4 w-4" />
{followed ? "Requested" : "Follow"} {followed ? "Requested" : "Follow"}
</Button> </Button>
{followed && (
<p className="text-xs text-muted-foreground text-right">
They&apos;ll be notified and can accept from their app.
</p>
)}
</div>
</div> </div>
); );
} }

View File

@@ -17,12 +17,12 @@ export function SearchInput() {
}; };
return ( return (
<form onSubmit={handleSearch} className="relative w-full max-w-sm"> <form onSubmit={handleSearch} className="relative w-full md:max-w-sm lg:max-w-md xl:max-w-lg">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
name="q" name="q"
placeholder="Search for users or thoughts..." placeholder="Search for users or thoughts..."
className="pl-9 md:min-w-[250px]" className="pl-9 md:min-w-[250px] lg:min-w-[320px] xl:min-w-[400px]"
/> />
</form> </form>
); );

View File

@@ -160,14 +160,19 @@ export function ThoughtCard({
src={author.avatarUrl} src={author.avatarUrl}
alt={author.displayName || author.username} alt={author.displayName || author.username}
/> />
<div className="flex flex-col"> <div className="flex flex-col min-w-0">
<span className="font-bold"> <span className="font-bold">
{author.displayName || author.username} {author.displayName || author.username}
</span> </span>
{!author.local && ( {!author.local && (
<span className="text-xs text-muted-foreground/70 truncate"> <div className="flex items-center gap-1.5 min-w-0">
<span className="text-xs text-muted-foreground/70 truncate flex-1">
{author.username.startsWith("@") ? author.username : `@${author.username}`} {author.username.startsWith("@") ? author.username : `@${author.username}`}
</span> </span>
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-500 shrink-0 max-w-[8rem] truncate">
{author.username.split("@").filter(Boolean).at(-1)}
</span>
</div>
)} )}
<time <time
dateTime={new Date(thought.createdAt).toISOString()} dateTime={new Date(thought.createdAt).toISOString()}

View File

@@ -356,14 +356,36 @@ export const getAllUsersCount = () =>
// ── Thoughts ────────────────────────────────────────────────────────────── // ── Thoughts ──────────────────────────────────────────────────────────────
export const getFeed = (token: string, page: number = 1, pageSize: number = 20) => export type FeedSortOption =
apiFetch( | "newest"
`/feed?page=${page}&per_page=${pageSize}`, | "oldest"
{ next: { tags: ['feed'] } }, | "most_liked"
| "most_boosted"
| "most_discussed";
export type FeedOptions = {
sort?: FeedSortOption;
originals_only?: boolean;
replies_only?: boolean;
local_only?: boolean;
hide_sensitive?: boolean;
};
export const getFeed = (token: string, page = 1, pageSize = 20, opts: FeedOptions = {}) => {
const params = new URLSearchParams({ page: String(page), per_page: String(pageSize) });
if (opts.sort) params.set("sort", opts.sort);
if (opts.originals_only) params.set("originals_only", "true");
if (opts.replies_only) params.set("replies_only", "true");
if (opts.local_only) params.set("local_only", "true");
if (opts.hide_sensitive) params.set("hide_sensitive", "true");
return apiFetch(
`/feed?${params.toString()}`,
{ next: { tags: ["feed"] } },
z.object({ items: z.array(ThoughtSchema), total: z.number(), page: z.number(), per_page: z.number() }) z.object({ items: z.array(ThoughtSchema), total: z.number(), page: z.number(), per_page: z.number() })
.transform((d) => ({ ...d, totalPages: Math.ceil(d.total / d.per_page) })), .transform((d) => ({ ...d, totalPages: Math.ceil(d.total / d.per_page) })),
token token
); );
};
export const getUserThoughts = (username: string, token: string | null, page = 1) => export const getUserThoughts = (username: string, token: string | null, page = 1) =>
apiFetch( apiFetch(