Compare commits

...

16 Commits

Author SHA1 Message Date
4ec0725ff8 fmt
Some checks failed
lint / lint (push) Failing after 5m3s
test / integration (push) Has been cancelled
test / unit (push) Has been cancelled
2026-05-17 12:04:51 +02:00
31e0f2958c fix: make ThoughtNote sensitive field optional (default false) 2026-05-17 12:02:58 +02:00
555121ea75 fix: promote worker event logs from debug to info 2026-05-17 12:02:13 +02:00
9e795eefdc fix: make ThoughtNote url field optional for AP compat
Some checks failed
lint / lint (push) Failing after 5m1s
test / integration (push) Has been cancelled
test / unit (push) Has been cancelled
2026-05-17 11:57:10 +02:00
18cf2c9f54 feat: implement verify() for all stub activity handlers
Undo: inner activity actor must match Undo actor
Announce/Like/Block: verify_domains_match(activity_id, actor_url)
Add: attributedTo must match actor (same as Create/Update)
2026-05-17 11:55:17 +02:00
b58c96b843 feat: implement federation post/connections backfill schedulers
Some checks failed
lint / lint (push) Failing after 5m12s
test / integration (push) Has been cancelled
test / unit (push) Has been cancelled
schedule_actor_posts_fetch now spawns backfill_outbox in background,
fetching all pages of a remote outbox and persisting via accept_note.
schedule_connections_fetch follows AP collection next-links, resolves
profiles, and caches them in the DB. Both were no-ops ("deferred").

Add connections_repo field to ActivityPubService; wire both factories.
2026-05-17 11:49:53 +02:00
8ea24461ba feat: load more pagination for user profile thoughts 2026-05-16 15:21:18 +02:00
e14a9f90c8 fix: route local users to /users/{username} in remote connection lists 2026-05-16 15:17:58 +02:00
28756ef4cd feat: load more pagination for remote user posts 2026-05-16 15:14:53 +02:00
7f27ae49c3 fix: overflow-y scroll on html to prevent layout shift on dropdown open 2026-05-16 15:12:41 +02:00
59f3423c00 fix: break-all on fediverse handle to prevent overflow 2026-05-16 15:07:30 +02:00
c48aa33592 fix: scrollbar-gutter stable to prevent bg flicker on dropdown open 2026-05-16 15:05:28 +02:00
8f3aa4b891 fix: wrap background image in fixed div so it stays put on scroll 2026-05-16 15:03:41 +02:00
32bfb00970 feat: Frutiger Aero redesign — glass panels, Aero shimmer, interaction moments
Some checks failed
lint / lint (push) Failing after 5m7s
test / unit (push) Successful in 16m24s
test / integration (push) Failing after 18m14s
2026-05-16 14:55:51 +02:00
7ce2901c2a docs: add Frutiger Aero redesign implementation plan 2026-05-16 13:53:44 +02:00
8bbc713093 docs: add Frutiger Aero redesign spec 2026-05-16 13:46:25 +02:00
70 changed files with 1842 additions and 1010 deletions

View File

@@ -4,6 +4,7 @@ use activitypub_federation::{
kinds::activity::{
AcceptType, CreateType, DeleteType, FollowType, RejectType, UndoType, UpdateType,
},
protocol::verification::verify_domains_match,
traits::Activity,
};
use serde::{Deserialize, Serialize};
@@ -239,6 +240,14 @@ impl Activity for UndoActivity {
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
// The actor undoing must be the same as the actor in the wrapped activity.
if let Some(inner_actor) = self.object.get("actor").and_then(|v| v.as_str()) {
if inner_actor != self.actor.inner().as_str() {
return Err(Error::bad_request(anyhow::anyhow!(
"Undo actor does not match inner activity actor"
)));
}
}
Ok(())
}
@@ -570,6 +579,7 @@ impl Activity for AnnounceActivity {
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
verify_domains_match(&self.id, self.actor.inner())?;
Ok(())
}
@@ -633,6 +643,7 @@ impl Activity for LikeActivity {
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
verify_domains_match(&self.id, self.actor.inner())?;
Ok(())
}
@@ -692,6 +703,14 @@ impl Activity for AddActivity {
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if let Some(attributed_to) = self.object.get("attributedTo").and_then(|v| v.as_str())
&& let Ok(attributed_url) = Url::parse(attributed_to)
&& &attributed_url != self.actor.inner()
{
return Err(Error::bad_request(anyhow::anyhow!(
"Add actor does not match object attributedTo"
)));
}
Ok(())
}
@@ -742,6 +761,7 @@ impl Activity for BlockActivity {
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
verify_domains_match(&self.id, self.actor.inner())?;
Ok(())
}

View File

@@ -18,7 +18,7 @@ pub mod user;
pub mod webfinger;
pub use activitypub_federation::kinds::object::NoteType;
pub use ap_ports::{ActorApUrls, ActivityPubRepository, OutboxEntry, OutboundFederationPort};
pub use ap_ports::{ActivityPubRepository, ActorApUrls, OutboundFederationPort, OutboxEntry};
pub use content::ApObjectHandler;
pub use data::FederationData;
pub use error::Error;

View File

@@ -1,5 +1,7 @@
use std::sync::Arc;
use domain::ports::FederationFetchPort;
use activitypub_federation::{
activity_sending::SendActivityTask, fetch::object_id::ObjectId, protocol::context::WithContext,
traits::Actor,
@@ -154,9 +156,11 @@ pub(crate) async fn send_with_retry(
failures
}
#[derive(Clone)]
pub struct ActivityPubService {
federation_config: ApFederationConfig,
base_url: String,
connections_repo: Arc<dyn domain::ports::RemoteActorConnectionRepository>,
}
impl ActivityPubService {
@@ -170,6 +174,7 @@ impl ActivityPubService {
software_name: String,
debug: bool,
event_publisher: Option<Arc<dyn domain::ports::EventPublisher>>,
connections_repo: Arc<dyn domain::ports::RemoteActorConnectionRepository>,
) -> anyhow::Result<Self> {
let data = FederationData::new(
repo,
@@ -184,6 +189,7 @@ impl ActivityPubService {
Ok(Self {
federation_config,
base_url,
connections_repo,
})
}
@@ -1586,11 +1592,14 @@ impl domain::ports::FederationSchedulerPort for ActivityPubService {
actor_ap_url: &str,
outbox_url: &str,
) -> Result<(), domain::errors::DomainError> {
tracing::debug!(
actor = actor_ap_url,
outbox = outbox_url,
"schedule_actor_posts_fetch: deferred"
);
let service = self.clone();
let actor = actor_ap_url.to_string();
let outbox = outbox_url.to_string();
tokio::spawn(async move {
if let Err(e) = service.backfill_outbox(&outbox, &actor).await {
tracing::warn!(actor = %actor, error = %e, "posts backfill failed");
}
});
Ok(())
}
@@ -1601,13 +1610,104 @@ impl domain::ports::FederationSchedulerPort for ActivityPubService {
connection_type: &str,
page: u32,
) -> Result<(), domain::errors::DomainError> {
// Only trigger a full fetch on page 1 to avoid redundant network traffic.
if page != 1 {
return Ok(());
}
let service = self.clone();
let actor = actor_ap_url.to_string();
let collection = collection_url.to_string();
let conn_type = connection_type.to_string();
let connections_repo = self.connections_repo.clone();
tokio::spawn(async move {
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(HTTP_FETCH_TIMEOUT_SECS))
.build()
{
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, "connections fetch: failed to build client");
return;
}
};
// Walk the AP collection, following first/next links.
let mut all_urls: Vec<String> = Vec::new();
let mut current_url: Option<String> = Some(collection.clone());
const MAX_ACTORS: usize = 500;
while let Some(url) = current_url.take() {
let val: serde_json::Value = match client
.get(&url)
.header("Accept", "application/activity+json, application/ld+json")
.send()
.await
{
Ok(r) => match r.json().await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, url = %url, "connections: parse error");
break;
}
},
Err(e) => {
tracing::warn!(error = %e, url = %url, "connections: HTTP error");
break;
}
};
// OrderedCollection root — follow its `first` page.
if val["type"].as_str() == Some("OrderedCollection") {
current_url = val["first"].as_str().map(|s| s.to_string());
continue;
}
// Collect actor URLs from orderedItems (string or {id: ...}).
let empty = vec![];
let items = val["orderedItems"].as_array().unwrap_or(&empty);
for item in items {
let actor_url = item.as_str().or_else(|| item["id"].as_str()).unwrap_or("");
if !actor_url.is_empty() {
all_urls.push(actor_url.to_string());
}
}
if all_urls.len() >= MAX_ACTORS {
break;
}
current_url = val["next"].as_str().map(|s| s.to_string());
if current_url.is_some() {
tokio::time::sleep(std::time::Duration::from_millis(BATCH_FETCH_SLEEP_MS))
.await;
}
}
if all_urls.is_empty() {
tracing::debug!(actor = %actor, connection_type = %conn_type, "connections: empty collection");
return;
}
// Resolve profiles and cache in pages of PAGE_SIZE.
const PAGE_SIZE: usize = 20;
for (idx, chunk) in all_urls.chunks(PAGE_SIZE).enumerate() {
let page_num = (idx + 1) as u32;
let chunk_urls: Vec<String> = chunk.to_vec();
let resolved = service.resolve_actor_profiles(chunk_urls).await;
if let Err(e) = connections_repo
.upsert_connections(&actor, &conn_type, page_num, &resolved)
.await
{
tracing::warn!(error = %e, "connections: upsert failed");
}
}
tracing::debug!(
actor = actor_ap_url,
collection = collection_url,
connection_type,
page,
"schedule_connections_fetch: deferred"
actor = %actor,
connection_type = %conn_type,
count = all_urls.len(),
"connections fetch complete"
);
});
Ok(())
}
}

View File

@@ -141,7 +141,8 @@ impl ApObjectHandler for ThoughtsObjectHandler {
"direct"
};
let thought_id = self.repo
let thought_id = self
.repo
.accept_note(
ap_id.as_str(),
&author_id,

View File

@@ -11,7 +11,8 @@ pub struct ThoughtNote {
#[serde(rename = "type")]
pub kind: NoteType,
pub id: Url,
pub url: Url, // Mastodon uses this as the clickable link
#[serde(skip_serializing_if = "Option::is_none", default)]
pub url: Option<Url>,
pub attributed_to: Url,
pub content: String,
pub published: DateTime<Utc>,
@@ -21,6 +22,7 @@ pub struct ThoughtNote {
pub cc: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub in_reply_to: Option<Url>,
#[serde(default)]
pub sensitive: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
@@ -42,7 +44,7 @@ impl ThoughtNote {
) -> Self {
Self {
kind: Default::default(),
url: id.clone(),
url: Some(id.clone()),
id,
attributed_to: actor_url,
content,

View File

@@ -18,7 +18,13 @@ impl ApiKeyRepository for FakeApiKeyRepo {
Ok(())
}
async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> {
Ok(self.0.lock().unwrap().iter().find(|k| k.key_hash == hash).cloned())
Ok(self
.0
.lock()
.unwrap()
.iter()
.find(|k| k.key_hash == hash)
.cloned())
}
async fn list_for_user(&self, _uid: &UserId) -> Result<Vec<ApiKey>, DomainError> {
Ok(vec![])

View File

@@ -356,6 +356,5 @@ impl TryFrom<EventPayload> for DomainEvent {
}
}
#[cfg(test)]
mod tests;

View File

@@ -109,6 +109,5 @@ impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
}
}
#[cfg(test)]
mod tests;

View File

@@ -239,6 +239,5 @@ impl MessageSource for NatsMessageSource {
}
}
#[cfg(test)]
mod tests;

View File

@@ -254,8 +254,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
.into_domain()?;
// SELECT the id — works whether the INSERT was a no-op or not (idempotent).
let row: (uuid::Uuid,) =
sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
.bind(ap_id)
.fetch_one(&self.pool)
.await

View File

@@ -1,3 +1,4 @@
use super::*;
use activitypub_base::ActivityPubRepository;

View File

@@ -1,3 +1,4 @@
use super::*;
use crate::user::PgUserRepository;
use chrono::Utc;

View File

@@ -1,3 +1,4 @@
use super::*;
use crate::test_helpers::seed_user;
use chrono::Utc;

View File

@@ -1,3 +1,4 @@
use super::*;
use crate::test_helpers::seed_user_and_thought;
use chrono::Utc;

View File

@@ -1,3 +1,4 @@
use super::*;
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::{
@@ -39,7 +40,10 @@
let repo = PgFeedRepository::new(pool);
let result = repo
.query(&FeedQuery::public(
PageParams { page: 1, per_page: 20 },
PageParams {
page: 1,
per_page: 20,
},
None,
))
.await
@@ -56,7 +60,10 @@
let result = repo
.query(&FeedQuery::search(
"hello world",
PageParams { page: 1, per_page: 20 },
PageParams {
page: 1,
per_page: 20,
},
None,
))
.await

View File

@@ -1,3 +1,4 @@
use super::*;
use crate::test_helpers::seed_user;
use chrono::Utc;

View File

@@ -1,15 +1,15 @@
pub mod activitypub;
pub mod engagement;
pub mod api_key;
pub mod block;
pub mod boost;
mod db_error;
pub mod engagement;
pub mod failed_event;
pub mod outbox;
pub mod feed;
pub mod follow;
pub mod like;
pub mod notification;
pub mod outbox;
pub mod remote_actor;
pub mod remote_actor_connections;
pub mod tag;

View File

@@ -1,3 +1,4 @@
use super::*;
use crate::test_helpers::seed_user_and_thought;
use chrono::Utc;

View File

@@ -1,3 +1,4 @@
use super::*;
use crate::test_helpers;
use chrono::Utc;

View File

@@ -1,3 +1,4 @@
use super::*;
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::ports::{ThoughtRepository, UserWriter};

View File

@@ -1,3 +1,4 @@
use super::*;
use crate::test_helpers::seed_user;
use domain::{

View File

@@ -1,3 +1,4 @@
use super::*;
use crate::user::PgUserRepository;
use domain::ports::UserWriter;

View File

@@ -139,7 +139,10 @@ impl UserReader for PgUserRepository {
.into_domain()
}
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> {
async fn list_paginated(
&self,
page: PageParams,
) -> Result<Paginated<UserSummary>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: uuid::Uuid,
@@ -187,7 +190,12 @@ impl UserReader for PgUserRepository {
following_count: r.following_count,
})
.collect();
Ok(Paginated { items, total, page: page.page, per_page: page.per_page })
Ok(Paginated {
items,
total,
page: page.page,
per_page: page.per_page,
})
}
async fn find_by_ids(&self, ids: &[UserId]) -> Result<HashMap<UserId, User>, DomainError> {
@@ -195,18 +203,19 @@ impl UserReader for PgUserRepository {
return Ok(HashMap::new());
}
let uuids: Vec<uuid::Uuid> = ids.iter().map(|id| id.as_uuid()).collect();
let rows = sqlx::query_as::<_, UserRow>(
&format!("{USER_SELECT} WHERE id = ANY($1)")
)
let rows = sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE id = ANY($1)"))
.bind(&uuids[..])
.fetch_all(&self.pool)
.await
.into_domain()?;
Ok(rows.into_iter().map(|r| {
Ok(rows
.into_iter()
.map(|r| {
let user = User::from(r);
(user.id.clone(), user)
}).collect())
})
.collect())
}
}

View File

@@ -1,3 +1,4 @@
use super::*;
use domain::{models::user::User, value_objects::*};

View File

@@ -1,7 +1,7 @@
use super::*;
use crate::testing::TestApRepo;
use activitypub_base::{ActorApUrls, OutboundFederationPort};
use async_trait::async_trait;
use crate::testing::TestApRepo;
use domain::{
errors::DomainError,
events::DomainEvent,
@@ -56,21 +56,12 @@ impl OutboundFederationPort for SpyPort {
self.announced.lock().unwrap().push(ap_id.to_string());
Ok(())
}
async fn broadcast_undo_announce(
&self,
_: &UserId,
ap_id: &str,
) -> Result<(), DomainError> {
async fn broadcast_undo_announce(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> {
self.undo_announced.lock().unwrap().push(ap_id.to_string());
Ok(())
}
async fn broadcast_like(
&self,
_: &UserId,
ap_id: &str,
_: &str,
) -> Result<(), DomainError> {
async fn broadcast_like(&self, _: &UserId, ap_id: &str, _: &str) -> Result<(), DomainError> {
self.liked.lock().unwrap().push(ap_id.to_string());
Ok(())
}
@@ -123,7 +114,11 @@ fn svc(store: &TestStore, spy: Arc<SpyPort>) -> FederationEventService {
}
}
fn svc_with_ap(store: &TestStore, ap_repo: TestApRepo, spy: Arc<SpyPort>) -> FederationEventService {
fn svc_with_ap(
store: &TestStore,
ap_repo: TestApRepo,
spy: Arc<SpyPort>,
) -> FederationEventService {
FederationEventService {
thoughts: Arc::new(store.clone()),
users: Arc::new(store.clone()),

View File

@@ -106,11 +106,7 @@ impl ActivityPubRepository for TestApRepo {
) -> Result<ThoughtId, DomainError> {
Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4()))
}
async fn apply_note_update(
&self,
_ap_id: &str,
_new_content: &str,
) -> Result<(), DomainError> {
async fn apply_note_update(&self, _ap_id: &str, _new_content: &str) -> Result<(), DomainError> {
Ok(())
}
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {

View File

@@ -34,19 +34,14 @@ pub async fn register(
}
let hash = hasher.hash(&input.password).await?;
let user = User::new_local(UserId::new(), username, email, hash);
users
.save(&user)
.await
.map_err(|e| match e {
users.save(&user).await.map_err(|e| match e {
DomainError::UniqueViolation { field: "username" } => {
DomainError::Conflict("username taken".into())
}
DomainError::UniqueViolation { field: "email" } => {
DomainError::Conflict("email taken".into())
}
DomainError::UniqueViolation { .. } => {
DomainError::Conflict("already exists".into())
}
DomainError::UniqueViolation { .. } => DomainError::Conflict("already exists".into()),
other => other,
})?;
events

View File

@@ -3,7 +3,10 @@ use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{feed::{PageParams, Paginated, UserSummary}, user::User},
models::{
feed::{PageParams, Paginated, UserSummary},
user::User,
},
ports::{AuthService, GeneratedToken, PasswordHasher, UserReader, UserWriter},
testing::{NoOpEventPublisher, TestStore},
value_objects::{Email, PasswordHash, UserId, Username},
@@ -19,10 +22,7 @@ impl UserReader for ConflictOnSaveStore {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
self.0.find_by_id(id).await
}
async fn find_by_username(
&self,
username: &Username,
) -> Result<Option<User>, DomainError> {
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
self.0.find_by_username(username).await
}
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
@@ -34,10 +34,16 @@ impl UserReader for ConflictOnSaveStore {
async fn count(&self) -> Result<i64, DomainError> {
self.0.count().await
}
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> {
async fn list_paginated(
&self,
page: PageParams,
) -> Result<Paginated<UserSummary>, DomainError> {
self.0.list_paginated(page).await
}
async fn find_by_ids(&self, ids: &[UserId]) -> Result<std::collections::HashMap<UserId, User>, DomainError> {
async fn find_by_ids(
&self,
ids: &[UserId],
) -> Result<std::collections::HashMap<UserId, User>, DomainError> {
self.0.find_by_ids(ids).await
}
}
@@ -57,7 +63,14 @@ impl UserWriter for ConflictOnSaveStore {
custom_css: Option<String>,
) -> Result<(), DomainError> {
self.0
.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css)
.update_profile(
user_id,
display_name,
bio,
avatar_url,
header_url,
custom_css,
)
.await
}
}
@@ -67,10 +80,7 @@ impl UserReader for EmailConflictOnSaveStore {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
self.0.find_by_id(id).await
}
async fn find_by_username(
&self,
username: &Username,
) -> Result<Option<User>, DomainError> {
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
self.0.find_by_username(username).await
}
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
@@ -82,10 +92,16 @@ impl UserReader for EmailConflictOnSaveStore {
async fn count(&self) -> Result<i64, DomainError> {
self.0.count().await
}
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> {
async fn list_paginated(
&self,
page: PageParams,
) -> Result<Paginated<UserSummary>, DomainError> {
self.0.list_paginated(page).await
}
async fn find_by_ids(&self, ids: &[UserId]) -> Result<std::collections::HashMap<UserId, User>, DomainError> {
async fn find_by_ids(
&self,
ids: &[UserId],
) -> Result<std::collections::HashMap<UserId, User>, DomainError> {
self.0.find_by_ids(ids).await
}
}
@@ -105,7 +121,14 @@ impl UserWriter for EmailConflictOnSaveStore {
custom_css: Option<String>,
) -> Result<(), DomainError> {
self.0
.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css)
.update_profile(
user_id,
display_name,
bio,
avatar_url,
header_url,
custom_css,
)
.await
}
}

View File

@@ -7,9 +7,9 @@ use domain::{
remote_actor::RemoteActor,
},
ports::{
EventPublisher, FederationActionPort, FederationFollowPort,
FederationFollowRequestPort, FederationSchedulerPort, FeedQuery, FeedRepository,
FollowRepository, RemoteActorConnectionRepository, UserReader,
EventPublisher, FederationActionPort, FederationFollowPort, FederationFollowRequestPort,
FederationSchedulerPort, FeedQuery, FeedRepository, FollowRepository,
RemoteActorConnectionRepository, UserReader,
},
value_objects::UserId,
};
@@ -86,7 +86,13 @@ pub async fn get_remote_actor_posts(
Some(id) => id,
None => ap_repo.intern_remote_actor(&actor.url).await?,
};
let result = feed.query(&FeedQuery::user(author_id, page.clone(), viewer_id.cloned())).await?;
let result = feed
.query(&FeedQuery::user(
author_id,
page.clone(),
viewer_id.cloned(),
))
.await?;
if let Some(outbox_url) = actor.outbox_url {
let _ = scheduler
.schedule_actor_posts_fetch(&actor.url, &outbox_url)

View File

@@ -13,5 +13,6 @@ pub async fn get_home_feed(
) -> Result<Paginated<FeedEntry>, DomainError> {
let mut following_ids = follows.get_accepted_following_ids(user_id).await?;
following_ids.push(user_id.clone());
feed.query(&FeedQuery::home(user_id.clone(), following_ids, page)).await
feed.query(&FeedQuery::home(user_id.clone(), following_ids, page))
.await
}

View File

@@ -5,7 +5,10 @@ use domain::{
feed::{EngagementStats, FeedEntry},
thought::{Thought, Visibility},
},
ports::{EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserReader},
ports::{
EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository,
UserReader,
},
value_objects::{Content, ThoughtId, UserId},
};
@@ -133,10 +136,20 @@ pub async fn get_thought_view(
.await?
.ok_or(DomainError::NotFound)?;
let mut map = engagement.get_for_thoughts(&[id.clone()], viewer).await?;
let (stats, viewer_ctx) = map.remove(id).unwrap_or(
(EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None)
);
Ok(FeedEntry { thought, author, stats, viewer: viewer_ctx })
let (stats, viewer_ctx) = map.remove(id).unwrap_or((
EngagementStats {
like_count: 0,
boost_count: 0,
reply_count: 0,
},
None,
));
Ok(FeedEntry {
thought,
author,
stats,
viewer: viewer_ctx,
})
}
/// Fetches a thread (root + replies) enriched with authors + real engagement stats.
@@ -169,10 +182,20 @@ pub async fn get_thread_views(
.get(&thought.user_id)
.cloned()
.ok_or(DomainError::NotFound)?;
let (stats, viewer_ctx) = engagement_map.remove(&thought.id).unwrap_or(
(EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None)
);
entries.push(FeedEntry { thought, author, stats, viewer: viewer_ctx });
let (stats, viewer_ctx) = engagement_map.remove(&thought.id).unwrap_or((
EngagementStats {
like_count: 0,
boost_count: 0,
reply_count: 0,
},
None,
));
entries.push(FeedEntry {
thought,
author,
stats,
viewer: viewer_ctx,
});
}
Ok(entries)
}

View File

@@ -31,7 +31,14 @@ async fn create_thought_saves_and_stages_outbox_event() {
let outbox = TestOutbox::default();
let u = user();
store.users.lock().unwrap().push(u.clone());
let out = create_thought(&store, &store, &store, &NoOpEventPublisher, &outbox, input(u.id.clone()))
let out = create_thought(
&store,
&store,
&store,
&NoOpEventPublisher,
&outbox,
input(u.id.clone()),
)
.await
.unwrap();
assert_eq!(out.thought.content.as_str(), "hello");
@@ -64,7 +71,9 @@ async fn delete_thought_stages_outbox_event() {
let staged = outbox.staged();
assert_eq!(staged.len(), 1);
assert!(matches!(&staged[0], DomainEvent::ThoughtDeleted { thought_id, .. } if *thought_id == tid));
assert!(
matches!(&staged[0], DomainEvent::ThoughtDeleted { thought_id, .. } if *thought_id == tid)
);
}
#[tokio::test]
@@ -82,7 +91,13 @@ async fn delete_own_thought_succeeds() {
)
.await
.unwrap();
delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &out.thought.id, &u.id)
delete_thought(
&store,
&NoOpEventPublisher,
&NoOpOutboxWriter,
&out.thought.id,
&u.id,
)
.await
.unwrap();
assert!(store.thoughts.lock().unwrap().is_empty());
@@ -113,7 +128,13 @@ async fn delete_other_thought_returns_not_found() {
)
.await
.unwrap();
let err = delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &out.thought.id, &bob.id)
let err = delete_thought(
&store,
&NoOpEventPublisher,
&NoOpOutboxWriter,
&out.thought.id,
&bob.id,
)
.await
.unwrap_err();
assert!(matches!(err, DomainError::NotFound));
@@ -124,7 +145,14 @@ async fn edit_thought_changes_content_and_emits_event() {
let store = TestStore::default();
let alice = user();
store.users.lock().unwrap().push(alice.clone());
let out = create_thought(&store, &store, &store, &NoOpEventPublisher, &NoOpOutboxWriter, input(alice.id.clone()))
let out = create_thought(
&store,
&store,
&store,
&NoOpEventPublisher,
&NoOpOutboxWriter,
input(alice.id.clone()),
)
.await
.unwrap();
let tid = out.thought.id.clone();
@@ -222,9 +250,13 @@ fn make_thought(user_id: UserId) -> Thought {
async fn get_thought_view_returns_feed_entry() {
let store = TestStore::default();
let user = make_user();
<TestStore as UserWriter>::save(&store, &user).await.unwrap();
<TestStore as UserWriter>::save(&store, &user)
.await
.unwrap();
let thought = make_thought(user.id.clone());
<TestStore as ThoughtRepository>::save(&store, &thought).await.unwrap();
<TestStore as ThoughtRepository>::save(&store, &thought)
.await
.unwrap();
let entry = get_thought_view(&store, &store, &store, &thought.id, None)
.await
@@ -248,9 +280,13 @@ async fn get_thought_view_returns_not_found_for_missing_thought() {
async fn get_thread_views_batches_correctly() {
let store = TestStore::default();
let user = make_user();
<TestStore as UserWriter>::save(&store, &user).await.unwrap();
<TestStore as UserWriter>::save(&store, &user)
.await
.unwrap();
let root = make_thought(user.id.clone());
<TestStore as ThoughtRepository>::save(&store, &root).await.unwrap();
<TestStore as ThoughtRepository>::save(&store, &root)
.await
.unwrap();
let reply = Thought::new_local(
ThoughtId::new(),
user.id.clone(),
@@ -260,7 +296,9 @@ async fn get_thread_views_batches_correctly() {
None,
false,
);
<TestStore as ThoughtRepository>::save(&store, &reply).await.unwrap();
<TestStore as ThoughtRepository>::save(&store, &reply)
.await
.unwrap();
let entries = get_thread_views(&store, &store, &store, &root.id, None)
.await

View File

@@ -8,7 +8,11 @@ use std::sync::Arc;
use activitypub::ThoughtsObjectHandler;
use activitypub_base::service::ActivityPubService;
use auth::ApiKeyServiceImpl;
use domain::{errors::DomainError, events::DomainEvent, ports::{EventPublisher, OutboxWriter}};
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{EventPublisher, OutboxWriter},
};
use event_transport::EventPublisherAdapter;
use nats::NatsTransport;
use postgres::activitypub::PgActivityPubRepository;
@@ -86,6 +90,7 @@ pub async fn build(cfg: &Config) -> Infrastructure {
"thoughts".to_string(),
cfg.debug,
None,
Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())),
)
.await
.expect("Failed to build ActivityPubService"),
@@ -129,9 +134,9 @@ pub async fn build(cfg: &Config) -> Infrastructure {
ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),
remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())),
federation_scheduler: ap_service.clone() as Arc<dyn domain::ports::FederationSchedulerPort>,
api_key_auth: Arc::new(ApiKeyServiceImpl::new(
Arc::new(postgres::api_key::PgApiKeyRepository::new(pool.clone())),
)),
api_key_auth: Arc::new(ApiKeyServiceImpl::new(Arc::new(
postgres::api_key::PgApiKeyRepository::new(pool.clone()),
))),
engagement: Arc::new(PgEngagementRepository::new(pool.clone())),
};

View File

@@ -58,7 +58,8 @@ pub trait UserReader: Send + Sync {
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError>;
async fn count(&self) -> Result<i64, DomainError>;
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError>;
async fn list_paginated(&self, page: PageParams)
-> Result<Paginated<UserSummary>, DomainError>;
async fn find_by_ids(&self, ids: &[UserId]) -> Result<HashMap<UserId, User>, DomainError>;
}
@@ -353,19 +354,43 @@ pub struct FeedQuery {
impl FeedQuery {
pub fn home(viewer_id: UserId, following_ids: Vec<UserId>, page: PageParams) -> Self {
Self { scope: FeedScope::Home { following_ids }, page, viewer_id: Some(viewer_id) }
Self {
scope: FeedScope::Home { following_ids },
page,
viewer_id: Some(viewer_id),
}
}
pub fn public(page: PageParams, viewer_id: Option<UserId>) -> Self {
Self { scope: FeedScope::Public, page, viewer_id }
Self {
scope: FeedScope::Public,
page,
viewer_id,
}
}
pub fn tag(tag_name: impl Into<String>, page: PageParams, viewer_id: Option<UserId>) -> Self {
Self { scope: FeedScope::Tag { tag_name: tag_name.into() }, page, viewer_id }
Self {
scope: FeedScope::Tag {
tag_name: tag_name.into(),
},
page,
viewer_id,
}
}
pub fn user(user_id: UserId, page: PageParams, viewer_id: Option<UserId>) -> Self {
Self { scope: FeedScope::User { user_id }, page, viewer_id }
Self {
scope: FeedScope::User { user_id },
page,
viewer_id,
}
}
pub fn search(query: impl Into<String>, page: PageParams, viewer_id: Option<UserId>) -> Self {
Self { scope: FeedScope::Search { query: query.into() }, page, viewer_id }
Self {
scope: FeedScope::Search {
query: query.into(),
},
page,
viewer_id,
}
}
}
@@ -392,7 +417,6 @@ pub trait SearchPort: Send + Sync {
) -> Result<Paginated<User>, DomainError>;
}
#[async_trait]
pub trait FederationSchedulerPort: Send + Sync {
async fn schedule_actor_posts_fetch(

View File

@@ -83,17 +83,30 @@ impl UserReader for TestStore {
.count() as i64)
}
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> {
async fn list_paginated(
&self,
page: PageParams,
) -> Result<Paginated<UserSummary>, DomainError> {
let all = self.list_with_stats().await?;
let total = all.len() as i64;
let start = page.offset() as usize;
let items: Vec<UserSummary> = all.into_iter().skip(start).take(page.limit() as usize).collect();
Ok(Paginated { items, total, page: page.page, per_page: page.per_page })
let items: Vec<UserSummary> = all
.into_iter()
.skip(start)
.take(page.limit() as usize)
.collect();
Ok(Paginated {
items,
total,
page: page.page,
per_page: page.per_page,
})
}
async fn find_by_ids(&self, ids: &[UserId]) -> Result<HashMap<UserId, User>, DomainError> {
let g = self.users.lock().unwrap();
let map = g.iter()
let map = g
.iter()
.filter(|u| ids.contains(&u.id))
.map(|u| (u.id.clone(), u.clone()))
.collect();
@@ -294,7 +307,16 @@ impl EngagementRepository for TestStore {
&self,
thought_ids: &[ThoughtId],
viewer_id: Option<&UserId>,
) -> Result<HashMap<ThoughtId, (crate::models::feed::EngagementStats, Option<crate::models::feed::ViewerContext>)>, DomainError> {
) -> Result<
HashMap<
ThoughtId,
(
crate::models::feed::EngagementStats,
Option<crate::models::feed::ViewerContext>,
),
>,
DomainError,
> {
use crate::models::feed::{EngagementStats, ViewerContext};
let likes = self.likes.lock().unwrap();
let boosts = self.boosts.lock().unwrap();
@@ -304,12 +326,29 @@ impl EngagementRepository for TestStore {
for tid in thought_ids {
let like_count = likes.iter().filter(|l| &l.thought_id == tid).count() as i64;
let boost_count = boosts.iter().filter(|b| &b.thought_id == tid).count() as i64;
let reply_count = thoughts.iter().filter(|t| t.in_reply_to_id.as_ref() == Some(tid)).count() as i64;
let reply_count = thoughts
.iter()
.filter(|t| t.in_reply_to_id.as_ref() == Some(tid))
.count() as i64;
let viewer = viewer_id.map(|vid| ViewerContext {
liked: likes.iter().any(|l| &l.thought_id == tid && &l.user_id == vid),
boosted: boosts.iter().any(|b| &b.thought_id == tid && &b.user_id == vid),
liked: likes
.iter()
.any(|l| &l.thought_id == tid && &l.user_id == vid),
boosted: boosts
.iter()
.any(|b| &b.thought_id == tid && &b.user_id == vid),
});
result.insert(tid.clone(), (EngagementStats { like_count, boost_count, reply_count }, viewer));
result.insert(
tid.clone(),
(
EngagementStats {
like_count,
boost_count,
reply_count,
},
viewer,
),
);
}
Ok(result)
}
@@ -763,7 +802,10 @@ impl RemoteActorConnectionRepository for TestStore {
#[async_trait]
impl FeedRepository for TestStore {
async fn query(&self, _q: &crate::ports::FeedQuery) -> Result<Paginated<FeedEntry>, DomainError> {
async fn query(
&self,
_q: &crate::ports::FeedQuery,
) -> Result<Paginated<FeedEntry>, DomainError> {
Ok(Paginated {
items: vec![],
total: 0,

View File

@@ -8,11 +8,7 @@ use api_types::{
responses::{ApiKeyResponse, CreatedApiKeyResponse},
};
use application::use_cases::api_keys::{create_api_key, delete_api_key, list_api_keys};
use axum::{
extract::Path,
http::StatusCode,
Json,
};
use axum::{extract::Path, http::StatusCode, Json};
use domain::{ports::ApiKeyRepository, value_objects::ApiKeyId};
use uuid::Uuid;

View File

@@ -1,8 +1,4 @@
use crate::{
deps_struct,
errors::ApiError,
extractors::Deps,
};
use crate::{deps_struct, errors::ApiError, extractors::Deps};
use api_types::{
requests::{LoginRequest, RegisterRequest},
responses::{AuthResponse, ErrorResponse, UserResponse},

View File

@@ -4,6 +4,7 @@ use crate::{
handlers::feed::to_thought_response,
state::AppState,
};
use activitypub_base::ActivityPubRepository;
use api_types::{
requests::PaginationQuery,
responses::{ActorConnectionPageResponse, ActorConnectionResponse},
@@ -15,7 +16,6 @@ use axum::{
extract::{Path, Query},
Json,
};
use activitypub_base::ActivityPubRepository;
use domain::{
models::feed::PageParams,
ports::{

View File

@@ -16,7 +16,10 @@ use axum::{
};
use domain::{
models::feed::PageParams,
ports::{FederationActionPort, FeedQuery, FeedRepository, FollowRepository, SearchPort, TagRepository, UserRepository},
ports::{
FederationActionPort, FeedQuery, FeedRepository, FollowRepository, SearchPort,
TagRepository, UserRepository,
},
};
deps_struct!(FeedDeps {
@@ -224,7 +227,10 @@ pub async fn user_thoughts_handler(
page: q.page(),
per_page: q.per_page(),
};
let result = d.feed.query(&FeedQuery::user(user.id.clone(), page, viewer)).await?;
let result = d
.feed
.query(&FeedQuery::user(user.id.clone(), page, viewer))
.await?;
Ok(Json(serde_json::json!({
"total": result.total,
"page": result.page,
@@ -241,7 +247,10 @@ pub async fn get_popular_tags(
.get("limit")
.and_then(|v| v.parse().ok())
.unwrap_or(api_types::requests::DEFAULT_PER_PAGE as usize);
let tags = d.tags.popular_tags(limit.min(api_types::requests::MAX_PER_PAGE as usize)).await?;
let tags = d
.tags
.popular_tags(limit.min(api_types::requests::MAX_PER_PAGE as usize))
.await?;
Ok(Json(serde_json::json!({
"tags": tags.iter().map(|(name, count)| serde_json::json!({
"name": name,
@@ -268,7 +277,10 @@ pub async fn tag_thoughts_handler(
page: q.page(),
per_page: q.per_page(),
};
let result = d.feed.query(&FeedQuery::tag(&tag_name, page, viewer)).await?;
let result = d
.feed
.query(&FeedQuery::tag(&tag_name, page, viewer))
.await?;
Ok(Json(serde_json::json!({
"tag": tag_name,
"total": result.total,

View File

@@ -8,11 +8,7 @@ use application::use_cases::notifications::{
count_unread_notifications, list_notifications as uc_list_notifications,
mark_all_notifications_read, mark_notification_read as uc_mark_notification_read,
};
use axum::{
extract::Path,
http::StatusCode,
Json,
};
use axum::{extract::Path, http::StatusCode, Json};
use domain::{
models::feed::PageParams, ports::NotificationRepository, value_objects::NotificationId,
};

View File

@@ -1,3 +1,4 @@
use crate::handlers::auth::to_user_response;
use crate::{
deps_struct,
errors::ApiError,
@@ -5,14 +6,9 @@ use crate::{
};
use api_types::requests::SetTopFriendsRequest;
use api_types::responses::TopFriendsResponse;
use crate::handlers::auth::to_user_response;
use application::use_cases::profile::{get_top_friends, get_user_by_username, set_top_friends};
use application::use_cases::social::*;
use axum::{
extract::Path,
http::StatusCode,
Json,
};
use axum::{extract::Path, http::StatusCode, Json};
use domain::{
ports::{
BlockRepository, BoostRepository, EventPublisher, FederationActionPort, FollowRepository,

View File

@@ -9,18 +9,16 @@ use api_types::{
responses::ErrorResponse,
};
use application::use_cases::thoughts::{
create_thought, delete_thought, edit_thought, get_thread_views, get_thought_view,
create_thought, delete_thought, edit_thought, get_thought_view, get_thread_views,
CreateThoughtInput,
};
use axum::{
extract::Path,
http::StatusCode,
response::IntoResponse,
Json,
};
use axum::{extract::Path, http::StatusCode, response::IntoResponse, Json};
use domain::{
models::feed::{EngagementStats, FeedEntry, ViewerContext},
ports::{EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserRepository},
ports::{
EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository,
UserRepository,
},
value_objects::ThoughtId,
};
use uuid::Uuid;
@@ -74,8 +72,15 @@ pub async fn post_thought(
let entry = FeedEntry {
thought: out.thought,
author,
stats: EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 },
viewer: Some(ViewerContext { liked: false, boosted: false }),
stats: EngagementStats {
like_count: 0,
boost_count: 0,
reply_count: 0,
},
viewer: Some(ViewerContext {
liked: false,
boosted: false,
}),
};
Ok((StatusCode::CREATED, Json(to_thought_response(&entry))))
}
@@ -101,7 +106,9 @@ pub async fn get_thought_handler(
viewer.as_ref(),
)
.await?;
Ok(Json(serde_json::to_value(to_thought_response(&entry)).unwrap()))
Ok(Json(
serde_json::to_value(to_thought_response(&entry)).unwrap(),
))
}
#[utoipa::path(
@@ -119,7 +126,14 @@ pub async fn delete_thought_handler(
AuthUser(uid): AuthUser,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
delete_thought(&*d.thoughts, &*d.events, &*d.outbox, &ThoughtId::from_uuid(id), &uid).await?;
delete_thought(
&*d.thoughts,
&*d.events,
&*d.outbox,
&ThoughtId::from_uuid(id),
&uid,
)
.await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -191,9 +191,7 @@ pub async fn get_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?;
Ok(Json(serde_json::json!({ "count": count })))
}

View File

@@ -79,11 +79,7 @@ impl ActivityPubRepository for NoOpApRepo {
) -> Result<ThoughtId, DomainError> {
Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4()))
}
async fn apply_note_update(
&self,
_ap_id: &str,
_new_content: &str,
) -> Result<(), DomainError> {
async fn apply_note_update(&self, _ap_id: &str, _new_content: &str) -> Result<(), DomainError> {
Ok(())
}
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {

View File

@@ -1,11 +1,12 @@
use postgres::failed_event::PgFailedEventStore;
use postgres::remote_actor_connections::PgRemoteActorConnectionRepository;
use sqlx::PgPool;
use std::sync::Arc;
use activitypub::ThoughtsObjectHandler;
use activitypub_base::ActivityPubService;
use application::services::{FederationEventService, NotificationEventService};
use activitypub_base::{ActivityPubRepository, OutboundFederationPort};
use application::services::{FederationEventService, NotificationEventService};
use domain::ports::EventPublisher;
use postgres::activitypub::PgActivityPubRepository;
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};
@@ -56,6 +57,7 @@ pub async fn build(database_url: &str, base_url: &str, nats_url: &str) -> Worker
"thoughts".to_string(),
false,
None,
Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())),
)
.await
.expect("ActivityPubService build failed"),

View File

@@ -43,13 +43,19 @@ async fn main() {
match result {
Ok(envelope) => {
let event = &envelope.event;
tracing::debug!(?event, "received event");
let event_type = event_payload::EventPayload::from(event).subject();
tracing::info!(
event_type,
delivery = envelope.delivery_count,
"received event"
);
let n = infra.handlers.notification.handle(event).await;
let f = infra.handlers.federation.handle(event).await;
if n.is_ok() && f.is_ok() {
(envelope.ack)();
tracing::info!(event_type, "event handled ok");
} else {
if let Err(e) = &n {
tracing::error!("notification handler: {e}");

View File

@@ -57,7 +57,11 @@ impl OutboxRelay {
let payload: EventPayload = match serde_json::from_value(row.payload.clone()) {
Ok(p) => p,
Err(e) => {
tracing::error!(seq = row.seq, event_type = row.event_type, "outbox: failed to deserialize payload: {e}");
tracing::error!(
seq = row.seq,
event_type = row.event_type,
"outbox: failed to deserialize payload: {e}"
);
// Mark delivered to avoid blocking; investigate manually.
sqlx::query(
"UPDATE outbox_events \
@@ -75,7 +79,10 @@ impl OutboxRelay {
let domain_event = match DomainEvent::try_from(payload) {
Ok(ev) => ev,
Err(e) => {
tracing::error!(seq = row.seq, "outbox: failed to convert to DomainEvent: {e}");
tracing::error!(
seq = row.seq,
"outbox: failed to convert to DomainEvent: {e}"
);
sqlx::query(
"UPDATE outbox_events \
SET delivered = true, delivered_at = now() \
@@ -100,7 +107,11 @@ impl OutboxRelay {
.execute(&mut *tx)
.await?;
tx.commit().await?;
tracing::debug!(seq = row.seq, event_type = row.event_type, "outbox: delivered");
tracing::info!(
seq = row.seq,
event_type = row.event_type,
"outbox: delivered"
);
}
Err(e) => {
tracing::warn!(seq = row.seq, "outbox: publish failed (will retry): {e}");

View File

@@ -48,10 +48,10 @@
/* Frutiger Aero Gradients */
--gradient-fa-blue: 135deg, hsl(217 91% 60%) 0%, hsl(200 90% 70%) 100%;
--gradient-fa-green: 135deg, hsl(155 70% 55%) 0%, hsl(170 80% 65%) 100%;
--gradient-fa-card: 180deg, hsl(var(--card)) 0%, hsl(var(--card)) 90%,
hsl(var(--card)) 100%;
--gradient-fa-gloss: 135deg, rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0) 100%;
--gradient-fa-card:
180deg, hsl(var(--card)) 0%, hsl(var(--card)) 90%, hsl(var(--card)) 100%;
--gradient-fa-gloss:
135deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%;
--shadow-fa-sm: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-fa-md: 0 4px 6px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06);
@@ -177,17 +177,16 @@
}
@layer base {
html {
overflow-y: scroll;
}
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
background-image: url("/background.avif");
background-size: cover;
background-position: center;
background-attachment: fixed;
background-repeat: no-repeat;
}
.glossy-effect::before {
@@ -312,3 +311,165 @@
z-index: 1;
}
}
/* ── Frutiger Aero interaction keyframes ── */
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-8px);
max-height: 0;
}
to {
opacity: 1;
transform: translateY(0);
max-height: 300px;
}
}
@keyframes shake {
0%,
100% {
transform: translateX(0) rotate(0deg);
}
15% {
transform: translateX(-4px) rotate(-1.5deg);
}
30% {
transform: translateX(4px) rotate(1.5deg);
}
45% {
transform: translateX(-3px) rotate(-1deg);
}
60% {
transform: translateX(3px) rotate(1deg);
}
75% {
transform: translateX(-1px) rotate(-0.5deg);
}
}
@keyframes fadeOut {
from {
opacity: 1;
transform: scale(1) translateY(0);
}
to {
opacity: 0;
transform: scale(0.9) translateY(8px);
}
}
@keyframes floatBob {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-6px);
}
}
@keyframes shimmerAero {
0% {
background-position: -400px 0;
}
100% {
background-position: 400px 0;
}
}
@layer components {
.animate-slide-down {
overflow: hidden;
animation: slideDown 0.22s ease-out forwards;
}
.animate-shake {
animation: shake 0.45s ease-out;
}
.animate-fade-out {
animation: fadeOut 0.3s ease-out forwards;
}
.animate-float-bob {
animation: floatBob 2.8s ease-in-out infinite;
}
/* Aero-tinted shimmer for skeleton loaders */
.shimmer-aero {
background: linear-gradient(
90deg,
rgba(96, 165, 250, 0.12) 25%,
rgba(96, 165, 250, 0.3) 50%,
rgba(96, 165, 250, 0.12) 75%
);
background-size: 800px 100%;
background-repeat: no-repeat;
animation: shimmerAero 1.5s infinite linear;
}
/* Widget title icon badges */
.widget-icon {
width: 22px;
height: 22px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
flex-shrink: 0;
}
.widget-icon-blue {
background: linear-gradient(135deg, #60a5fa, #2563eb);
box-shadow:
0 2px 4px rgba(37, 99, 235, 0.3),
inset 0 1px 1px rgba(255, 255, 255, 0.3);
}
.widget-icon-green {
background: linear-gradient(135deg, #6ee7b7, #10b981);
box-shadow:
0 2px 4px rgba(16, 185, 129, 0.3),
inset 0 1px 1px rgba(255, 255, 255, 0.3);
}
.widget-icon-purple {
background: linear-gradient(135deg, #c4b5fd, #7c3aed);
box-shadow:
0 2px 4px rgba(124, 58, 237, 0.3),
inset 0 1px 1px rgba(255, 255, 255, 0.3);
}
/* Landing page ambient orbs */
.orb {
position: absolute;
border-radius: 50%;
filter: blur(40px);
opacity: 0.45;
pointer-events: none;
}
/* Gradient avatar fallback */
.avatar-gradient {
background: linear-gradient(135deg, #60a5fa, #34d399);
box-shadow:
0 0 0 2px white,
0 0 0 3.5px rgba(59, 130, 246, 0.45);
}
}
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
.animate-slide-down {
animation: none;
}
.animate-shake {
animation: none;
}
.animate-fade-out {
animation: none;
}
.animate-float-bob {
animation: none;
}
.shimmer-aero {
animation: none;
background: rgba(96, 165, 250, 0.18);
}
}

View File

@@ -4,6 +4,7 @@ import { AuthProvider } from "@/hooks/use-auth";
import { Toaster } from "@/components/ui/sonner";
import { Header } from "@/components/header";
import localFont from "next/font/local";
import Image from "next/image";
import InstallPrompt from "@/components/install-prompt";
export const metadata: Metadata = {
@@ -52,6 +53,16 @@ export default function RootLayout({
return (
<html lang="en">
<body className={`${frutiger.className} antialiased`}>
<div className="fixed inset-0 -z-10">
<Image
src="/bg1.avif"
alt=""
fill
priority
quality={85}
className="object-cover object-center"
/>
</div>
<AuthProvider>
<Header />
<main className="flex-1">{children}</main>

View File

@@ -13,7 +13,11 @@ import { UsersCount } from "@/components/users-count";
import { PaginationNav } from "@/components/pagination-nav";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { ProfileSkeleton, TagsSkeleton, CountSkeleton } from "@/components/loading-skeleton";
import {
ProfileSkeleton,
TagsSkeleton,
CountSkeleton,
} from "@/components/loading-skeleton";
export const metadata: Metadata = {
title: "Home",
@@ -86,9 +90,7 @@ async function FeedPage({
</header>
<ThoughtForm />
<div className="block lg:hidden space-y-6">
{sidebar}
</div>
<div className="block lg:hidden space-y-6">{sidebar}</div>
<div className="space-y-6">
{thoughtThreads.map((thought) => (
@@ -99,7 +101,13 @@ async function FeedPage({
/>
))}
{thoughtThreads.length === 0 && (
<EmptyState message="Your feed is empty. Follow some users to see their thoughts!" />
<EmptyState
emoji="💭"
title="Your feed is quiet"
message="Your feed is empty. Follow some users to see their thoughts!"
ctaLabel="Discover people ✨"
ctaHref="/users/all"
/>
)}
</div>
<PaginationNav
@@ -110,9 +118,7 @@ async function FeedPage({
</main>
<aside className="hidden lg:block lg:col-span-1">
<div className="sticky top-20 space-y-6">
{sidebar}
</div>
<div className="sticky top-20 space-y-6">{sidebar}</div>
</aside>
</div>
</div>
@@ -121,28 +127,112 @@ async function FeedPage({
function LandingPage() {
return (
<>
<div className="font-sans min-h-screen text-gray-800 flex items-center justify-center">
<div className="container mx-auto max-w-2xl p-4 sm:p-6 text-center glass-effect glossy-effect bottom rounded-md shadow-fa-lg">
<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"
style={{ textShadow: "2px 2px 4px rgba(0,0,0,0.1)" }}
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-2">
Throwback to the golden age of microblogging.
<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">
<Button asChild>
<div className="mt-8 flex justify-center gap-4 relative">
<Button asChild className="px-7">
<Link href="/login">Login</Link>
</Button>
<Button variant="secondary" asChild>
<Button asChild variant="secondary" className="px-7">
<Link href="/register">Register</Link>
</Button>
</div>
{/* Fediverse badge */}
<div className="mt-5 relative flex justify-center">
<span
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full text-xs text-muted-foreground"
style={{
background: "rgba(255,255,255,0.3)",
border: "1px solid rgba(255,255,255,0.5)",
}}
>
<span
className="w-2 h-2 rounded-full bg-emerald-400 inline-block"
style={{ boxShadow: "0 0 4px #34d399" }}
/>
Works with Mastodon, Pixelfed &amp; more
</span>
</div>
</div>
</div>
</>
);
}

View File

@@ -65,8 +65,11 @@ export default async function RemoteActorPage({
}
const actor = actorResult.value;
const posts =
postsResult.status === "fulfilled" ? postsResult.value.items : [];
const postsData = postsResult.status === "fulfilled" ? postsResult.value : null;
const posts = postsData?.items ?? [];
const totalPages = postsData
? Math.ceil(postsData.total / postsData.per_page)
: 1;
const me =
meResult.status === "fulfilled" ? (meResult.value as Me | null) : null;
const following =
@@ -77,7 +80,9 @@ export default async function RemoteActorPage({
<RemoteUserProfile
key={actor.url}
actor={actor}
handle={handle}
initialPosts={posts}
initialTotalPages={totalPages}
me={me}
initialFollowed={initialFollowed}
/>

View File

@@ -68,7 +68,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
<RemoteUserCard actor={remoteActor} />
</div>
) : (
<EmptyState message={`No user found at ${query}`} />
<EmptyState emoji="🔍" title="No results" message={`No user found at ${query}`} />
)
) : results ? (
<Tabs defaultValue="thoughts" className="w-full">
@@ -91,7 +91,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
</TabsContent>
</Tabs>
) : (
<EmptyState message="No results found or an error occurred." />
<EmptyState emoji="🔍" title="No results" message="No results found or an error occurred." />
)}
</main>
</div>

View File

@@ -67,7 +67,7 @@ export default async function TagPage({ params }: TagPageProps) {
/>
))}
{thoughtThreads.length === 0 && (
<EmptyState message="No thoughts found for this tag." />
<EmptyState emoji="🏷" title="No thoughts here yet" message="No thoughts found for this tag." />
)}
</main>
</div>

View File

@@ -53,8 +53,7 @@ import { FollowButton } from "@/components/follow-button";
import { TopFriends } from "@/components/top-friends";
import { Suspense } from "react";
import { ProfileSkeleton } from "@/components/loading-skeleton";
import { buildThoughtThreads } from "@/lib/utils";
import { ThoughtThread } from "@/components/thought-thread";
import { UserThoughtsList } from "@/components/user-thoughts-list";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -95,9 +94,11 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
const user = userResult.value;
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
const thoughts =
thoughtsResult.status === "fulfilled" ? thoughtsResult.value.items : [];
const thoughtThreads = buildThoughtThreads(thoughts);
const thoughtsData = thoughtsResult.status === "fulfilled" ? thoughtsResult.value : null;
const thoughts = thoughtsData?.items ?? [];
const totalPages = thoughtsData
? Math.ceil(thoughtsData.total / thoughtsData.per_page)
: 1;
const localFollowersCount =
followersResult.status === "fulfilled"
@@ -194,7 +195,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
@{user.username}
</p>
{fediverseHandle && (
<p className="text-xs text-muted-foreground/70 mt-0.5 font-mono select-all">
<p className="text-xs text-muted-foreground/70 mt-0.5 font-mono select-all break-all">
{fediverseHandle}
</p>
)}
@@ -262,16 +263,12 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
)}
</TabsList>
<TabsContent value="thoughts" className="space-y-4">
{thoughtThreads.map((thought) => (
<ThoughtThread
key={thought.id}
thought={thought}
currentUser={me}
<UserThoughtsList
username={username}
initialThoughts={thoughts}
totalPages={totalPages}
me={me}
/>
))}
{thoughtThreads.length === 0 && (
<EmptyState message="This user hasn't posted any public thoughts yet." />
)}
</TabsContent>
{isOwnProfile && (
<TabsContent value="federation">

View File

@@ -1,12 +1,39 @@
import Link from "next/link";
interface EmptyStateProps {
message: string
className?: string
emoji?: string;
title?: string;
message: string;
ctaLabel?: string;
ctaHref?: string;
className?: string;
}
export function EmptyState({ message, className }: EmptyStateProps) {
export function EmptyState({
emoji = "💭",
title,
message,
ctaLabel,
ctaHref,
className = "",
}: EmptyStateProps) {
return (
<p className={`text-center text-muted-foreground pt-8 ${className ?? ""}`}>
{message}
</p>
)
<div className={`flex flex-col items-center text-center py-10 gap-2 ${className}`}>
<span className="text-4xl animate-float-bob select-none" aria-hidden="true">
{emoji}
</span>
{title && (
<p className="font-bold text-base text-foreground text-shadow-sm">{title}</p>
)}
<p className="text-sm text-muted-foreground max-w-xs leading-relaxed">{message}</p>
{ctaLabel && ctaHref && (
<Link
href={ctaHref}
className="mt-2 inline-flex items-center gap-1.5 px-5 py-2 rounded-full text-sm font-bold text-white fa-gradient-blue shadow-fa-md glossy-effect relative overflow-hidden"
>
{ctaLabel}
</Link>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client"
import { useOptimistic } from "react"
import { useOptimistic, useRef } from "react"
import { followUser, unfollowUser } from "@/app/actions/social"
import { Button } from "@/components/ui/button"
import { toast } from "sonner"
@@ -11,24 +11,93 @@ interface FollowButtonProps {
isInitiallyFollowing: boolean
}
const BURST_COLORS = ["#2563eb", "#06b6d4", "#10b981", "#f59e0b", "#a855f7", "#ef4444"]
function burstParticles(canvas: HTMLCanvasElement) {
const ctx = canvas.getContext("2d")
if (!ctx) return
const cx = canvas.width / 2
const cy = canvas.height / 2
const particles = Array.from({ length: 14 }, (_, i) => {
const angle = (i / 14) * Math.PI * 2
const speed = 2.5 + Math.random() * 2
return {
x: cx,
y: cy,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
r: 3 + Math.random() * 3,
color: BURST_COLORS[i % BURST_COLORS.length],
life: 1,
}
})
let rafId: number
function frame() {
if (!canvas.isConnected) {
cancelAnimationFrame(rafId)
return
}
ctx!.clearRect(0, 0, canvas.width, canvas.height)
let alive = false
for (const p of particles) {
p.x += p.vx
p.y += p.vy
p.vy += 0.08
p.life -= 0.03
if (p.life > 0) {
alive = true
ctx!.globalAlpha = p.life
ctx!.fillStyle = p.color
ctx!.beginPath()
ctx!.arc(p.x, p.y, p.r, 0, Math.PI * 2)
ctx!.fill()
}
}
ctx!.globalAlpha = 1
if (alive) {
rafId = requestAnimationFrame(frame)
}
}
rafId = requestAnimationFrame(frame)
}
export function FollowButton({ username, isInitiallyFollowing }: FollowButtonProps) {
const [optimisticFollowing, setOptimisticFollowing] = useOptimistic(isInitiallyFollowing)
const canvasRef = useRef<HTMLCanvasElement>(null)
async function handleClick() {
const next = !optimisticFollowing
setOptimisticFollowing(next)
if (next && canvasRef.current) {
burstParticles(canvasRef.current)
}
try {
await (next ? followUser(username) : unfollowUser(username))
} catch {
setOptimisticFollowing(!next) // revert
setOptimisticFollowing(!next)
toast.error(`Failed to ${next ? "follow" : "unfollow"} user.`)
}
}
return (
<div className="relative inline-block">
<canvas
ref={canvasRef}
width={160}
height={80}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
aria-hidden
/>
<Button
onClick={handleClick}
variant={optimisticFollowing ? "secondary" : "default"}
className="relative rounded-full"
data-following={optimisticFollowing}
>
{optimisticFollowing ? (
@@ -37,5 +106,6 @@ export function FollowButton({ username, isInitiallyFollowing }: FollowButtonPro
<><UserPlus className="mr-2 h-4 w-4" /> Follow</>
)}
</Button>
</div>
)
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useAuth } from "@/hooks/use-auth";
import Image from "next/image";
import Link from "next/link";
import { Button } from "./ui/button";
import { UserNav } from "./user-nav";
@@ -10,25 +11,33 @@ export function Header() {
const { token } = useAuth();
return (
<header className="sticky top-0 z-50 flex justify-center w-full border-b border-primary/20 bg-background/80 glass-effect glossy-effect bottom rounded-none">
<header className="sticky top-0 z-50 flex justify-center w-full border-b border-white/20 bg-background/80 glass-effect glossy-effect bottom rounded-none shadow-fa-md">
<div className="container flex h-14 items-center px-2">
<div className="flex gap-2">
<Link href="/" className="flex items-center gap-1">
<span className="hidden font-bold text-primary sm:inline-block">
{/* Logo */}
<Link href="/" className="flex items-center gap-2 mr-4 shrink-0">
<Image
src="/icon.avif"
alt="Thoughts"
width={32}
height={32}
className="rounded-lg shadow-fa-sm"
/>
<span className="hidden sm:inline-block font-bold text-primary text-shadow-sm">
Thoughts
</span>
</Link>
<MainNav />
</div>
<div className="flex flex-1 items-center justify-end space-x-2">
{token ? (
<UserNav />
) : (
<>
<Button asChild size="sm">
<Button asChild size="sm" variant="outline" className="rounded-full">
<Link href="/login">Login</Link>
</Button>
<Button asChild size="sm">
<Button asChild size="sm" className="rounded-full">
<Link href="/register">Register</Link>
</Button>
</>

View File

@@ -2,21 +2,21 @@ import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { getPopularTags } from "@/lib/api";
import { Hash } from "lucide-react";
export async function PopularTags() {
const tags = await getPopularTags().catch(() => []);
if (tags.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Popular Tags</CardTitle>
<Card className="p-4">
<CardHeader className="p-0 pb-2">
<CardTitle className="text-lg flex items-center gap-2">
<span className="widget-icon widget-icon-blue">🏷</span>
Popular Tags
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-center text-muted-foreground">
No popular tags to display.
</p>
<CardContent className="p-0">
<p className="text-center text-sm text-muted-foreground py-4">No tags yet.</p>
</CardContent>
</Card>
);
@@ -24,24 +24,20 @@ export async function PopularTags() {
return (
<Card className="p-4">
<CardHeader className="p-0 pb-2">
<CardTitle className="text-lg">Popular Tags</CardTitle>
<CardHeader className="p-0 pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<span className="widget-icon widget-icon-blue">🏷</span>
Popular Tags
</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2 p-0">
{tags.map((tag) => (
{tags.map((tag, i) => (
<Link href={`/tags/${tag}`} key={tag}>
<Badge
variant="secondary"
className="hover:shadow-lg transition-shadow text-shadow-sm cursor-pointer"
>
<Hash className="mr-1 h-3 w-3" />
{tag}
<Badge variant={i < 2 ? "trending" : "branded"}>
{i < 2 ? "🔥 " : "#"}{tag}
</Badge>
</Link>
))}
{tags.length === 0 && (
<p className="text-sm text-muted-foreground">No popular tags yet.</p>
)}
</CardContent>
</Card>
);

View File

@@ -18,6 +18,19 @@ interface RemoteUserCardProps {
};
}
function resolveProfileHref(handle: string): string {
const apiDomain = process.env.NEXT_PUBLIC_API_URL
? new URL(process.env.NEXT_PUBLIC_API_URL).hostname
: null;
const clean = handle.startsWith("@") ? handle.slice(1) : handle;
const atIdx = clean.indexOf("@");
const domain = atIdx !== -1 ? clean.slice(atIdx + 1) : null;
const username = atIdx !== -1 ? clean.slice(0, atIdx) : clean;
return apiDomain && domain === apiDomain
? `/users/${username}`
: `/remote-actor?handle=@${clean}`;
}
export function RemoteUserCard({ actor }: RemoteUserCardProps) {
const [followed, setFollowed] = useState(false);
const [loading, setLoading] = useState(false);
@@ -43,7 +56,7 @@ export function RemoteUserCard({ actor }: RemoteUserCardProps) {
return (
<div className="flex items-center justify-between p-4 border rounded-lg">
<Link
href={`/users/@${actor.handle}`}
href={resolveProfileHref(actor.handle)}
className="flex items-center gap-3 hover:opacity-80"
>
<UserAvatar src={actor.avatarUrl} alt={actor.displayName ?? actor.handle} />

View File

@@ -2,7 +2,7 @@
import { useState } from "react";
import { UserMinus, UserPlus } from "lucide-react";
import { followUser, unfollowUser, RemoteActor, Thought, Me } from "@/lib/api";
import { followUser, unfollowUser, getRemoteActorPosts, RemoteActor, Thought, Me } from "@/lib/api";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -14,14 +14,18 @@ import { Connections } from "./connections";
interface RemoteUserProfileProps {
actor: RemoteActor;
handle: string;
initialPosts: Thought[];
initialTotalPages: number;
me: Me | null;
initialFollowed?: boolean;
}
export function RemoteUserProfile({
actor,
handle,
initialPosts,
initialTotalPages,
me,
initialFollowed = false,
}: RemoteUserProfileProps) {
@@ -29,6 +33,24 @@ export function RemoteUserProfile({
const [followLoading, setFollowLoading] = useState(false);
const { token } = useAuth();
const [posts, setPosts] = useState<Thought[]>(initialPosts);
const [page, setPage] = useState(1);
const [totalPages] = useState(initialTotalPages);
const [loadingMore, setLoadingMore] = useState(false);
const loadMore = async () => {
setLoadingMore(true);
try {
const result = await getRemoteActorPosts(handle, page + 1, token);
setPosts((prev) => [...prev, ...result.items]);
setPage((p) => p + 1);
} catch {
toast.error("Failed to load more posts.");
} finally {
setLoadingMore(false);
}
};
const [followersActive, setFollowersActive] = useState(false);
const [followingActive, setFollowingActive] = useState(false);
@@ -108,8 +130,20 @@ export function RemoteUserProfile({
</TabsList>
<TabsContent value="posts" className="space-y-4 mt-4">
{initialPosts.length > 0 ? (
<ThoughtList thoughts={initialPosts} currentUser={me} />
{posts.length > 0 ? (
<>
<ThoughtList thoughts={posts} currentUser={me} />
{page < totalPages && (
<Button
onClick={loadMore}
disabled={loadingMore}
variant="outline"
className="w-full rounded-full"
>
{loadingMore ? "Loading…" : "Load more"}
</Button>
)}
</>
) : (
<Card className="flex items-center justify-center h-48">
<p className="text-center text-muted-foreground">

View File

@@ -46,6 +46,18 @@ interface ThoughtCardProps {
isReply?: boolean;
}
function renderWithHashtags(content: string) {
return content.split(/(#\w+)/g).map((part, i) =>
/^#\w+$/.test(part) ? (
<span key={i} className="text-primary font-medium">
{part}
</span>
) : (
part
)
);
}
export function ThoughtCard({
thought,
currentUser,
@@ -54,6 +66,7 @@ export function ThoughtCard({
const { author } = thought;
const [isAlertOpen, setIsAlertOpen] = useState(false);
const [isReplyOpen, setIsReplyOpen] = useState(false);
const [deletingState, setDeletingState] = useState<"idle" | "shaking" | "fading">("idle");
const { token } = useAuth();
const timeAgo = formatDistanceToNow(new Date(thought.createdAt), {
addSuffix: true,
@@ -62,14 +75,18 @@ export function ThoughtCard({
const isAuthor = currentUser?.username === thought.author.username;
const handleDelete = async () => {
setIsAlertOpen(false);
setDeletingState("shaking");
await new Promise((r) => setTimeout(r, 450));
setDeletingState("fading");
await new Promise((r) => setTimeout(r, 300));
try {
await deleteThought(thought.id);
toast.success("Thought deleted successfully.");
toast.success("Thought deleted.");
} catch (error) {
console.error("Failed to delete thought:", error);
setDeletingState("idle");
toast.error("Failed to delete thought.");
} finally {
setIsAlertOpen(false);
}
};
@@ -115,7 +132,13 @@ export function ThoughtCard({
</div>
)}
</div>
<Card className="mt-2">
<Card
className={cn(
"mt-2 transition-transform duration-200 hover:-translate-y-0.5 hover:shadow-fa-lg",
deletingState === "shaking" && "animate-shake",
deletingState === "fading" && "animate-fade-out pointer-events-none"
)}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<Link
href={`/users/${author.username}`}
@@ -166,7 +189,7 @@ export function ThoughtCard({
<CardContent>
{thought.author.local ? (
<p className="whitespace-pre-wrap break-words text-shadow-sm">
{thought.content}
{renderWithHashtags(thought.content)}
</p>
) : (
<div
@@ -185,6 +208,7 @@ export function ThoughtCard({
<Button
variant="ghost"
size="sm"
className="rounded-full bg-primary/8 border border-primary/15 text-primary hover:bg-primary/15"
onClick={() => setIsReplyOpen(!isReplyOpen)}
>
<MessageSquare className="mr-2 h-4 w-4" />
@@ -194,7 +218,7 @@ export function ThoughtCard({
)}
{isReplyOpen && (
<div className="border-t m-4 rounded-2xl border-border/50 bg-secondary/20 ">
<div className="animate-slide-down border-t m-4 rounded-2xl border-border/50 bg-secondary/20">
<ThoughtForm
replyToId={thought.id}
onSuccess={() => setIsReplyOpen(false)}

View File

@@ -17,12 +17,13 @@ export async function TopFriends({ username }: TopFriendsProps) {
return (
<Card id="top-friends" className="p-4">
<CardHeader id="top-friends__header" className="p-0 pb-2">
<CardTitle id="top-friends__title" className="text-lg text-shadow-md">
<CardHeader id="top-friends__header" className="p-0 pb-3">
<CardTitle id="top-friends__title" className="text-lg flex items-center gap-2">
<span className="widget-icon widget-icon-green">👥</span>
Top Friends
</CardTitle>
</CardHeader>
<CardContent id="top-friends__content" className="p-0">
<CardContent id="top-friends__content" className="p-0 space-y-1">
{friends.map((friend) => (
<Link
id={`top-friends__link-${friend.id}`}
@@ -30,13 +31,18 @@ export async function TopFriends({ username }: TopFriendsProps) {
key={friend.id}
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.username} />
<span
id={`top-friends__name-${friend.id}`}
className="text-xs truncate w-full font-medium text-shadow-sm"
>
<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>
<span className="ml-auto shrink-0 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-600">
following
</span>
</Link>
))}
</CardContent>

View File

@@ -12,10 +12,14 @@ const badgeVariants = cva(
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80 glossy-effect bottom text-shadow-sm",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80 glossy-effect bottom text-shadow-sm", // Use green for secondary
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80 glossy-effect bottom text-shadow-sm",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80 glossy-effect bottom text-shadow-sm",
outline: "text-foreground glossy-effect bottom text-shadow-sm",
branded:
"border border-primary/20 bg-primary/8 text-primary font-semibold hover:bg-primary/15 hover:scale-105 transition-transform cursor-pointer",
trending:
"border border-red-300/30 bg-gradient-to-r from-orange-500/10 to-red-500/8 text-red-600 font-semibold hover:from-orange-500/18 hover:to-red-500/14 hover:scale-105 transition-transform cursor-pointer",
},
},
defaultVariants: {

View File

@@ -1,13 +1,13 @@
import { cn } from "@/lib/utils";
import * as React from "react"
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-muted/50 animate-pulse rounded-md", className)}
className={cn("rounded-md shimmer-aero", className)}
{...props}
/>
);
)
}
export { Skeleton };
export { Skeleton }

View File

@@ -1,6 +1,5 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import { User } from "lucide-react";
interface UserAvatarProps {
src?: string | null;
@@ -9,8 +8,10 @@ interface UserAvatarProps {
}
export function UserAvatar({ src, alt, className }: UserAvatarProps) {
const initial = alt?.trim()[0]?.toUpperCase() ?? "?";
return (
<Avatar className={cn("border-2 border-primary/50 shadow-md", className)}>
<Avatar className={cn("avatar-gradient", className)}>
{src && (
<AvatarImage
className="object-cover object-center"
@@ -18,8 +19,8 @@ export function UserAvatar({ src, alt, className }: UserAvatarProps) {
alt={alt ?? "User avatar"}
/>
)}
<AvatarFallback>
<User className="h-5 w-5" />
<AvatarFallback className="avatar-gradient text-white font-bold text-sm">
{initial}
</AvatarFallback>
</Avatar>
);

View File

@@ -0,0 +1,72 @@
"use client";
import { useState } from "react";
import { getUserThoughts, Me, Thought } from "@/lib/api";
import { ThoughtThread } from "@/components/thought-thread";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/empty-state";
import { buildThoughtThreads } from "@/lib/utils";
import { toast } from "sonner";
import { useAuth } from "@/hooks/use-auth";
interface UserThoughtsListProps {
username: string;
initialThoughts: Thought[];
totalPages: number;
me: Me | null;
}
export function UserThoughtsList({
username,
initialThoughts,
totalPages,
me,
}: UserThoughtsListProps) {
const [thoughts, setThoughts] = useState<Thought[]>(initialThoughts);
const [page, setPage] = useState(1);
const [loadingMore, setLoadingMore] = useState(false);
const { token } = useAuth();
const thoughtThreads = buildThoughtThreads(thoughts);
const loadMore = async () => {
setLoadingMore(true);
try {
const result = await getUserThoughts(username, token, page + 1);
setThoughts((prev) => [...prev, ...result.items]);
setPage((p) => p + 1);
} catch {
toast.error("Failed to load more thoughts.");
} finally {
setLoadingMore(false);
}
};
if (thoughtThreads.length === 0) {
return (
<EmptyState
emoji="💭"
title="Nothing here yet"
message="This user hasn't posted any public thoughts yet."
/>
);
}
return (
<div className="space-y-4">
{thoughtThreads.map((thought) => (
<ThoughtThread key={thought.id} thought={thought} currentUser={me} />
))}
{page < totalPages && (
<Button
onClick={loadMore}
disabled={loadingMore}
variant="outline"
className="w-full rounded-full"
>
{loadingMore ? "Loading…" : "Load more"}
</Button>
)}
</div>
);
}

View File

@@ -1,68 +1,57 @@
import { Link } from "lucide-react";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardDescription,
} from "@/components/ui/card";
import { getAllUsersCount } from "@/lib/api";
export async function UsersCount() {
const usersCount = await getAllUsersCount().catch(() => null);
if (usersCount === null) {
return (
<Card className="p-4">
<CardHeader className="p-0 pb-2">
<CardTitle className="text-lg text-shadow-md">Users Count</CardTitle>
<CardDescription>
Total number of registered users on Thoughts.
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-muted-foreground text-sm text-center py-4">
Could not load users count.
</div>
</CardContent>
</Card>
);
}
if (usersCount.count === 0) {
return (
<Card className="p-4">
<CardHeader className="p-0 pb-2">
<CardTitle className="text-lg text-shadow-md">Users Count</CardTitle>
<CardDescription>
Total number of registered users on Thoughts.
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-muted-foreground text-sm text-center py-4">
No registered users yet. Be the first to{" "}
<Link href="/signup" className="text-primary hover:underline">
sign up
</Link>
!
</div>
</CardContent>
</Card>
);
}
const count = usersCount?.count ?? null;
return (
<Card className="p-4">
<CardHeader className="p-0 pb-2">
<CardTitle className="text-lg text-shadow-md">Users Count</CardTitle>
<CardDescription>
Total number of registered users on Thoughts.
</CardDescription>
<CardHeader className="p-0 pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<span className="widget-icon widget-icon-purple"></span>
Community
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-muted-foreground text-sm text-center py-4">
{usersCount.count} registered users.
<CardContent className="p-0">
{count === null ? (
<p className="text-sm text-muted-foreground text-center py-2">
Could not load member count.
</p>
) : count === 0 ? (
<p className="text-sm text-muted-foreground text-center py-2">
Be the first to join!
</p>
) : (
<div
className="rounded-xl p-3 text-center glossy-effect relative overflow-hidden"
style={{
background: "rgba(255,255,255,0.4)",
border: "1px solid rgba(255,255,255,0.6)",
}}
>
<div
className="text-3xl font-extrabold leading-none"
style={{
background: "linear-gradient(135deg, #2563eb, #06b6d4)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
backgroundClip: "text",
}}
>
{count}
</div>
<div className="text-[10px] uppercase tracking-widest text-muted-foreground mt-1 font-semibold">
members
</div>
</div>
)}
</CardContent>
</Card>
);

View File

@@ -343,9 +343,9 @@ export const getFeed = (token: string, page: number = 1, pageSize: number = 20)
token
);
export const getUserThoughts = (username: string, token: string | null) =>
export const getUserThoughts = (username: string, token: string | null, page = 1) =>
apiFetch(
`/users/${username}/thoughts`,
`/users/${username}/thoughts?page=${page}`,
{ next: { tags: [`profile:${username}`] } },
z.object({ items: z.array(ThoughtSchema), total: z.number(), page: z.number(), per_page: z.number() }),
token

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB