Compare commits
42 Commits
a73e7deeff
...
37d03a06dd
| Author | SHA1 | Date | |
|---|---|---|---|
| 37d03a06dd | |||
| 55e5bcc2bb | |||
| ac26eaca6b | |||
| 86d0497509 | |||
| 989004dd74 | |||
| 64cc11c2a1 | |||
| 01ef118b0a | |||
| 4ab6da67c7 | |||
| dc75ac5f6c | |||
| b14b8592a2 | |||
| 4db7194838 | |||
| c94b42cba8 | |||
| 1ad6f8ae8f | |||
| d76ff9dafb | |||
| 522ee9c1b1 | |||
| 00996327fb | |||
| 7ed639c9ea | |||
| 3ad609a793 | |||
| 9849bb4991 | |||
| 2199e5c66d | |||
| 6e7bf05942 | |||
| 037217960e | |||
| 44b3a6de60 | |||
| 1fd46f3f2a | |||
| 9c5d5518bb | |||
| 95ea633e78 | |||
| a97507cc15 | |||
| 858faddda9 | |||
| ea3a32ccaf | |||
| 8fad8eefa0 | |||
| 5a05968ae9 | |||
| 8229285a2f | |||
| 145b07d636 | |||
| 7991aef47b | |||
| ed6a4f9f72 | |||
| f815d71c32 | |||
| 0688ffe0ae | |||
| 95728302b7 | |||
| 4d00d856c1 | |||
| a279988d39 | |||
| 2f56839938 | |||
| 2ffdd5e269 |
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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![],
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
15
crates/presentation/src/openapi/federation_actors.rs
Normal file
15
crates/presentation/src/openapi/federation_actors.rs
Normal 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;
|
||||||
25
crates/presentation/src/openapi/federation_management.rs
Normal file
25
crates/presentation/src/openapi/federation_management.rs
Normal 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;
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
31
thoughts-frontend/app/error.tsx
Normal file
31
thoughts-frontend/app/error.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
19
thoughts-frontend/app/not-found.tsx
Normal file
19
thoughts-frontend/app/not-found.tsx
Normal 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't exist or was moved.
|
||||||
|
</p>
|
||||||
|
<Button asChild className="rounded-full">
|
||||||
|
<Link href="/">Go home</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 & Sorting</h2>
|
<h2 className="text-lg font-semibold">Filters & 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 & more
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
82
thoughts-frontend/components/all-friends-card.tsx
Normal file
82
thoughts-frontend/components/all-friends-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
thoughts-frontend/components/fediverse-handle-widget.tsx
Normal file
32
thoughts-frontend/components/fediverse-handle-widget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
thoughts-frontend/components/filters-sorting-panel.tsx
Normal file
168
thoughts-frontend/components/filters-sorting-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 ? (
|
||||||
|
|||||||
61
thoughts-frontend/components/landing-features.tsx
Normal file
61
thoughts-frontend/components/landing-features.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
179
thoughts-frontend/components/landing-page.tsx
Normal file
179
thoughts-frontend/components/landing-page.tsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
37
thoughts-frontend/components/profile-friends-widget.tsx
Normal file
37
thoughts-frontend/components/profile-friends-widget.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'll be notified and can accept from their app.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user