Compare commits
16 Commits
master
...
4ec0725ff8
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ec0725ff8 | |||
| 31e0f2958c | |||
| 555121ea75 | |||
| 9e795eefdc | |||
| 18cf2c9f54 | |||
| b58c96b843 | |||
| 8ea24461ba | |||
| e14a9f90c8 | |||
| 28756ef4cd | |||
| 7f27ae49c3 | |||
| 59f3423c00 | |||
| c48aa33592 | |||
| 8f3aa4b891 | |||
| 32bfb00970 | |||
| 7ce2901c2a | |||
| 8bbc713093 |
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ pub trait ActivityPubRepository: Send + Sync {
|
||||
|
||||
/// Find the local UserId for a remote actor by its AP URL.
|
||||
async fn find_remote_actor_id(&self, actor_ap_url: &str)
|
||||
-> Result<Option<UserId>, DomainError>;
|
||||
-> Result<Option<UserId>, DomainError>;
|
||||
|
||||
/// Ensure a remote actor placeholder exists; create one if absent.
|
||||
/// Idempotent — safe to call multiple times with the same URL.
|
||||
@@ -99,7 +99,7 @@ pub trait ActivityPubRepository: Send + Sync {
|
||||
/// Return the AP actor URL and inbox URL for a user, if stored.
|
||||
/// Returns None for users that have not been federated.
|
||||
async fn get_actor_ap_urls(&self, user_id: &UserId)
|
||||
-> Result<Option<ActorApUrls>, DomainError>;
|
||||
-> Result<Option<ActorApUrls>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
tracing::debug!(
|
||||
actor = actor_ap_url,
|
||||
collection = collection_url,
|
||||
connection_type,
|
||||
page,
|
||||
"schedule_connections_fetch: deferred"
|
||||
);
|
||||
// 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,
|
||||
connection_type = %conn_type,
|
||||
count = all_urls.len(),
|
||||
"connections fetch complete"
|
||||
);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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![])
|
||||
|
||||
@@ -356,6 +356,5 @@ impl TryFrom<EventPayload> for DomainEvent {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -109,6 +109,5 @@ impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -239,6 +239,5 @@ impl MessageSource for NatsMessageSource {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -254,12 +254,11 @@ 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")
|
||||
.bind(ap_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
|
||||
.bind(ap_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
Ok(ThoughtId::from_uuid(row.0))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,56 @@
|
||||
use super::*;
|
||||
use activitypub_base::ActivityPubRepository;
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) {
|
||||
let repo = PgActivityPubRepository::new(pool);
|
||||
let url = "https://mastodon.social/users/alice";
|
||||
let id1 = repo.intern_remote_actor(url).await.unwrap();
|
||||
let id2 = repo.intern_remote_actor(url).await.unwrap();
|
||||
assert_eq!(id1, id2);
|
||||
}
|
||||
use super::*;
|
||||
use activitypub_base::ActivityPubRepository;
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn accept_and_retract_note(pool: sqlx::PgPool) {
|
||||
let repo = PgActivityPubRepository::new(pool);
|
||||
let actor_url = "https://remote.example/users/bob";
|
||||
let ap_id = "https://remote.example/notes/1";
|
||||
let author = repo.intern_remote_actor(actor_url).await.unwrap();
|
||||
repo.accept_note(
|
||||
ap_id,
|
||||
&author,
|
||||
"hello from remote",
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) {
|
||||
let repo = PgActivityPubRepository::new(pool);
|
||||
let url = "https://mastodon.social/users/alice";
|
||||
let id1 = repo.intern_remote_actor(url).await.unwrap();
|
||||
let id2 = repo.intern_remote_actor(url).await.unwrap();
|
||||
assert_eq!(id1, id2);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn accept_and_retract_note(pool: sqlx::PgPool) {
|
||||
let repo = PgActivityPubRepository::new(pool);
|
||||
let actor_url = "https://remote.example/users/bob";
|
||||
let ap_id = "https://remote.example/notes/1";
|
||||
let author = repo.intern_remote_actor(actor_url).await.unwrap();
|
||||
repo.accept_note(
|
||||
ap_id,
|
||||
&author,
|
||||
"hello from remote",
|
||||
chrono::Utc::now(),
|
||||
false,
|
||||
None,
|
||||
"public",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.retract_note(ap_id).await.unwrap();
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn count_local_notes_excludes_remote(pool: sqlx::PgPool) {
|
||||
let repo = PgActivityPubRepository::new(pool);
|
||||
assert_eq!(repo.count_local_notes().await.unwrap(), 0);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn accept_note_returns_thought_id(pool: sqlx::PgPool) {
|
||||
let repo = PgActivityPubRepository::new(pool.clone());
|
||||
let actor_user_id = repo
|
||||
.intern_remote_actor("https://remote.example/users/alice")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let thought_id = repo
|
||||
.accept_note(
|
||||
"https://remote.example/notes/1",
|
||||
&actor_user_id,
|
||||
"Hello #rust world",
|
||||
chrono::Utc::now(),
|
||||
false,
|
||||
None,
|
||||
@@ -28,41 +59,11 @@
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.retract_note(ap_id).await.unwrap();
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn count_local_notes_excludes_remote(pool: sqlx::PgPool) {
|
||||
let repo = PgActivityPubRepository::new(pool);
|
||||
assert_eq!(repo.count_local_notes().await.unwrap(), 0);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn accept_note_returns_thought_id(pool: sqlx::PgPool) {
|
||||
let repo = PgActivityPubRepository::new(pool.clone());
|
||||
let actor_user_id = repo
|
||||
.intern_remote_actor("https://remote.example/users/alice")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let thought_id = repo
|
||||
.accept_note(
|
||||
"https://remote.example/notes/1",
|
||||
&actor_user_id,
|
||||
"Hello #rust world",
|
||||
chrono::Utc::now(),
|
||||
false,
|
||||
None,
|
||||
"public",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
|
||||
.bind("https://remote.example/notes/1")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(thought_id.as_uuid(), row.0);
|
||||
}
|
||||
let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
|
||||
.bind("https://remote.example/notes/1")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(thought_id.as_uuid(), row.0);
|
||||
}
|
||||
|
||||
@@ -1,49 +1,50 @@
|
||||
use super::*;
|
||||
use crate::user::PgUserRepository;
|
||||
use chrono::Utc;
|
||||
use domain::ports::UserWriter;
|
||||
use domain::{models::user::User, value_objects::*};
|
||||
|
||||
async fn seed_user(pool: &sqlx::PgPool) -> User {
|
||||
let repo = PgUserRepository::new(pool.clone());
|
||||
let u = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("alice").unwrap(),
|
||||
Email::new("alice@ex.com").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
repo.save(&u).await.unwrap();
|
||||
u
|
||||
}
|
||||
use super::*;
|
||||
use crate::user::PgUserRepository;
|
||||
use chrono::Utc;
|
||||
use domain::ports::UserWriter;
|
||||
use domain::{models::user::User, value_objects::*};
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn save_and_find_by_hash(pool: sqlx::PgPool) {
|
||||
let user = seed_user(&pool).await;
|
||||
let repo = PgApiKeyRepository::new(pool);
|
||||
let key = ApiKey {
|
||||
id: ApiKeyId::new(),
|
||||
user_id: user.id.clone(),
|
||||
key_hash: "abc123".into(),
|
||||
name: "test".into(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&key).await.unwrap();
|
||||
let found = repo.find_by_hash("abc123").await.unwrap().unwrap();
|
||||
assert_eq!(found.name, "test");
|
||||
}
|
||||
async fn seed_user(pool: &sqlx::PgPool) -> User {
|
||||
let repo = PgUserRepository::new(pool.clone());
|
||||
let u = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("alice").unwrap(),
|
||||
Email::new("alice@ex.com").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
repo.save(&u).await.unwrap();
|
||||
u
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn delete_key(pool: sqlx::PgPool) {
|
||||
let user = seed_user(&pool).await;
|
||||
let repo = PgApiKeyRepository::new(pool);
|
||||
let key = ApiKey {
|
||||
id: ApiKeyId::new(),
|
||||
user_id: user.id.clone(),
|
||||
key_hash: "def456".into(),
|
||||
name: "key2".into(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&key).await.unwrap();
|
||||
repo.delete(&key.id, &user.id).await.unwrap();
|
||||
assert!(repo.find_by_hash("def456").await.unwrap().is_none());
|
||||
}
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn save_and_find_by_hash(pool: sqlx::PgPool) {
|
||||
let user = seed_user(&pool).await;
|
||||
let repo = PgApiKeyRepository::new(pool);
|
||||
let key = ApiKey {
|
||||
id: ApiKeyId::new(),
|
||||
user_id: user.id.clone(),
|
||||
key_hash: "abc123".into(),
|
||||
name: "test".into(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&key).await.unwrap();
|
||||
let found = repo.find_by_hash("abc123").await.unwrap().unwrap();
|
||||
assert_eq!(found.name, "test");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn delete_key(pool: sqlx::PgPool) {
|
||||
let user = seed_user(&pool).await;
|
||||
let repo = PgApiKeyRepository::new(pool);
|
||||
let key = ApiKey {
|
||||
id: ApiKeyId::new(),
|
||||
user_id: user.id.clone(),
|
||||
key_hash: "def456".into(),
|
||||
name: "key2".into(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&key).await.unwrap();
|
||||
repo.delete(&key.id, &user.id).await.unwrap();
|
||||
assert!(repo.find_by_hash("def456").await.unwrap().is_none());
|
||||
}
|
||||
|
||||
@@ -1,34 +1,35 @@
|
||||
use super::*;
|
||||
use crate::test_helpers::seed_user;
|
||||
use chrono::Utc;
|
||||
use domain::value_objects::*;
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn block_exists(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgBlockRepository::new(pool);
|
||||
let block = Block {
|
||||
blocker_id: alice.id.clone(),
|
||||
blocked_id: bob.id.clone(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&block).await.unwrap();
|
||||
assert!(repo.exists(&alice.id, &bob.id).await.unwrap());
|
||||
assert!(!repo.exists(&bob.id, &alice.id).await.unwrap());
|
||||
}
|
||||
use super::*;
|
||||
use crate::test_helpers::seed_user;
|
||||
use chrono::Utc;
|
||||
use domain::value_objects::*;
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn unblock(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgBlockRepository::new(pool);
|
||||
let block = Block {
|
||||
blocker_id: alice.id.clone(),
|
||||
blocked_id: bob.id.clone(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&block).await.unwrap();
|
||||
repo.delete(&alice.id, &bob.id).await.unwrap();
|
||||
assert!(!repo.exists(&alice.id, &bob.id).await.unwrap());
|
||||
}
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn block_exists(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgBlockRepository::new(pool);
|
||||
let block = Block {
|
||||
blocker_id: alice.id.clone(),
|
||||
blocked_id: bob.id.clone(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&block).await.unwrap();
|
||||
assert!(repo.exists(&alice.id, &bob.id).await.unwrap());
|
||||
assert!(!repo.exists(&bob.id, &alice.id).await.unwrap());
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn unblock(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgBlockRepository::new(pool);
|
||||
let block = Block {
|
||||
blocker_id: alice.id.clone(),
|
||||
blocked_id: bob.id.clone(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&block).await.unwrap();
|
||||
repo.delete(&alice.id, &bob.id).await.unwrap();
|
||||
assert!(!repo.exists(&alice.id, &bob.id).await.unwrap());
|
||||
}
|
||||
|
||||
@@ -1,35 +1,36 @@
|
||||
use super::*;
|
||||
use crate::test_helpers::seed_user_and_thought;
|
||||
use chrono::Utc;
|
||||
use domain::value_objects::*;
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn boost_and_count(pool: sqlx::PgPool) {
|
||||
let (user, thought) = seed_user_and_thought(&pool).await;
|
||||
let repo = PgBoostRepository::new(pool);
|
||||
let boost = Boost {
|
||||
id: BoostId::new(),
|
||||
user_id: user.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&boost).await.unwrap();
|
||||
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
|
||||
}
|
||||
use super::*;
|
||||
use crate::test_helpers::seed_user_and_thought;
|
||||
use chrono::Utc;
|
||||
use domain::value_objects::*;
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn unboost(pool: sqlx::PgPool) {
|
||||
let (user, thought) = seed_user_and_thought(&pool).await;
|
||||
let repo = PgBoostRepository::new(pool);
|
||||
let boost = Boost {
|
||||
id: BoostId::new(),
|
||||
user_id: user.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&boost).await.unwrap();
|
||||
repo.delete(&user.id, &thought.id).await.unwrap();
|
||||
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
|
||||
}
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn boost_and_count(pool: sqlx::PgPool) {
|
||||
let (user, thought) = seed_user_and_thought(&pool).await;
|
||||
let repo = PgBoostRepository::new(pool);
|
||||
let boost = Boost {
|
||||
id: BoostId::new(),
|
||||
user_id: user.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&boost).await.unwrap();
|
||||
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn unboost(pool: sqlx::PgPool) {
|
||||
let (user, thought) = seed_user_and_thought(&pool).await;
|
||||
let repo = PgBoostRepository::new(pool);
|
||||
let boost = Boost {
|
||||
id: BoostId::new(),
|
||||
user_id: user.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&boost).await.unwrap();
|
||||
repo.delete(&user.id, &thought.id).await.unwrap();
|
||||
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
|
||||
}
|
||||
|
||||
@@ -1,69 +1,76 @@
|
||||
use super::*;
|
||||
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||
use domain::{
|
||||
models::{
|
||||
feed::PageParams,
|
||||
thought::{Thought, Visibility},
|
||||
user::User,
|
||||
},
|
||||
ports::{FeedQuery, ThoughtRepository, UserWriter},
|
||||
value_objects::*,
|
||||
};
|
||||
|
||||
async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
|
||||
let urepo = PgUserRepository::new(pool.clone());
|
||||
let trepo = PgThoughtRepository::new(pool.clone());
|
||||
let u = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new(username).unwrap(),
|
||||
Email::new(format!("{username}@ex.com")).unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
urepo.save(&u).await.unwrap();
|
||||
let t = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
u.id.clone(),
|
||||
Content::new_local(content).unwrap(),
|
||||
use super::*;
|
||||
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||
use domain::{
|
||||
models::{
|
||||
feed::PageParams,
|
||||
thought::{Thought, Visibility},
|
||||
user::User,
|
||||
},
|
||||
ports::{FeedQuery, ThoughtRepository, UserWriter},
|
||||
value_objects::*,
|
||||
};
|
||||
|
||||
async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
|
||||
let urepo = PgUserRepository::new(pool.clone());
|
||||
let trepo = PgThoughtRepository::new(pool.clone());
|
||||
let u = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new(username).unwrap(),
|
||||
Email::new(format!("{username}@ex.com")).unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
urepo.save(&u).await.unwrap();
|
||||
let t = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
u.id.clone(),
|
||||
Content::new_local(content).unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
trepo.save(&t).await.unwrap();
|
||||
(u, t)
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) {
|
||||
let (_, _) = seed(&pool, "alice", "hello").await;
|
||||
let repo = PgFeedRepository::new(pool);
|
||||
let result = repo
|
||||
.query(&FeedQuery::public(
|
||||
PageParams {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
},
|
||||
None,
|
||||
Visibility::Public,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.total, 1);
|
||||
assert_eq!(result.items[0].thought.content.as_str(), "hello");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn search_returns_matching_thoughts(pool: sqlx::PgPool) {
|
||||
let (_, _) = seed(&pool, "alice", "hello world").await;
|
||||
let (_, _) = seed(&pool, "bob", "goodbye world").await;
|
||||
let repo = PgFeedRepository::new(pool);
|
||||
let result = repo
|
||||
.query(&FeedQuery::search(
|
||||
"hello world",
|
||||
PageParams {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
},
|
||||
None,
|
||||
false,
|
||||
);
|
||||
trepo.save(&t).await.unwrap();
|
||||
(u, t)
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) {
|
||||
let (_, _) = seed(&pool, "alice", "hello").await;
|
||||
let repo = PgFeedRepository::new(pool);
|
||||
let result = repo
|
||||
.query(&FeedQuery::public(
|
||||
PageParams { page: 1, per_page: 20 },
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.total, 1);
|
||||
assert_eq!(result.items[0].thought.content.as_str(), "hello");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn search_returns_matching_thoughts(pool: sqlx::PgPool) {
|
||||
let (_, _) = seed(&pool, "alice", "hello world").await;
|
||||
let (_, _) = seed(&pool, "bob", "goodbye world").await;
|
||||
let repo = PgFeedRepository::new(pool);
|
||||
let result = repo
|
||||
.query(&FeedQuery::search(
|
||||
"hello world",
|
||||
PageParams { page: 1, per_page: 20 },
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.total >= 1);
|
||||
assert!(result
|
||||
.items
|
||||
.iter()
|
||||
.any(|e| e.thought.content.as_str() == "hello world"));
|
||||
}
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.total >= 1);
|
||||
assert!(result
|
||||
.items
|
||||
.iter()
|
||||
.any(|e| e.thought.content.as_str() == "hello world"));
|
||||
}
|
||||
|
||||
@@ -1,58 +1,59 @@
|
||||
use super::*;
|
||||
use crate::test_helpers::seed_user;
|
||||
use chrono::Utc;
|
||||
use domain::value_objects::*;
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn save_and_find_follow(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgFollowRepository::new(pool);
|
||||
let follow = Follow {
|
||||
follower_id: alice.id.clone(),
|
||||
following_id: bob.id.clone(),
|
||||
state: FollowState::Accepted,
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&follow).await.unwrap();
|
||||
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
|
||||
assert_eq!(found.state, FollowState::Accepted);
|
||||
}
|
||||
use super::*;
|
||||
use crate::test_helpers::seed_user;
|
||||
use chrono::Utc;
|
||||
use domain::value_objects::*;
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn update_state(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgFollowRepository::new(pool);
|
||||
let follow = Follow {
|
||||
follower_id: alice.id.clone(),
|
||||
following_id: bob.id.clone(),
|
||||
state: FollowState::Pending,
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&follow).await.unwrap();
|
||||
repo.update_state(&alice.id, &bob.id, &FollowState::Accepted)
|
||||
.await
|
||||
.unwrap();
|
||||
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
|
||||
assert_eq!(found.state, FollowState::Accepted);
|
||||
}
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn save_and_find_follow(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgFollowRepository::new(pool);
|
||||
let follow = Follow {
|
||||
follower_id: alice.id.clone(),
|
||||
following_id: bob.id.clone(),
|
||||
state: FollowState::Accepted,
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&follow).await.unwrap();
|
||||
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
|
||||
assert_eq!(found.state, FollowState::Accepted);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn get_accepted_following_ids(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgFollowRepository::new(pool);
|
||||
let follow = Follow {
|
||||
follower_id: alice.id.clone(),
|
||||
following_id: bob.id.clone(),
|
||||
state: FollowState::Accepted,
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&follow).await.unwrap();
|
||||
let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap();
|
||||
assert_eq!(ids, vec![bob.id]);
|
||||
}
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn update_state(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgFollowRepository::new(pool);
|
||||
let follow = Follow {
|
||||
follower_id: alice.id.clone(),
|
||||
following_id: bob.id.clone(),
|
||||
state: FollowState::Pending,
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&follow).await.unwrap();
|
||||
repo.update_state(&alice.id, &bob.id, &FollowState::Accepted)
|
||||
.await
|
||||
.unwrap();
|
||||
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
|
||||
assert_eq!(found.state, FollowState::Accepted);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn get_accepted_following_ids(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgFollowRepository::new(pool);
|
||||
let follow = Follow {
|
||||
follower_id: alice.id.clone(),
|
||||
following_id: bob.id.clone(),
|
||||
state: FollowState::Accepted,
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&follow).await.unwrap();
|
||||
let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap();
|
||||
assert_eq!(ids, vec![bob.id]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,35 +1,36 @@
|
||||
use super::*;
|
||||
use crate::test_helpers::seed_user_and_thought;
|
||||
use chrono::Utc;
|
||||
use domain::value_objects::*;
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn like_and_count(pool: sqlx::PgPool) {
|
||||
let (user, thought) = seed_user_and_thought(&pool).await;
|
||||
let repo = PgLikeRepository::new(pool);
|
||||
let like = Like {
|
||||
id: LikeId::new(),
|
||||
user_id: user.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&like).await.unwrap();
|
||||
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
|
||||
}
|
||||
use super::*;
|
||||
use crate::test_helpers::seed_user_and_thought;
|
||||
use chrono::Utc;
|
||||
use domain::value_objects::*;
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn unlike(pool: sqlx::PgPool) {
|
||||
let (user, thought) = seed_user_and_thought(&pool).await;
|
||||
let repo = PgLikeRepository::new(pool);
|
||||
let like = Like {
|
||||
id: LikeId::new(),
|
||||
user_id: user.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&like).await.unwrap();
|
||||
repo.delete(&user.id, &thought.id).await.unwrap();
|
||||
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
|
||||
}
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn like_and_count(pool: sqlx::PgPool) {
|
||||
let (user, thought) = seed_user_and_thought(&pool).await;
|
||||
let repo = PgLikeRepository::new(pool);
|
||||
let like = Like {
|
||||
id: LikeId::new(),
|
||||
user_id: user.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&like).await.unwrap();
|
||||
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn unlike(pool: sqlx::PgPool) {
|
||||
let (user, thought) = seed_user_and_thought(&pool).await;
|
||||
let repo = PgLikeRepository::new(pool);
|
||||
let like = Like {
|
||||
id: LikeId::new(),
|
||||
user_id: user.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&like).await.unwrap();
|
||||
repo.delete(&user.id, &thought.id).await.unwrap();
|
||||
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
|
||||
}
|
||||
|
||||
@@ -1,67 +1,68 @@
|
||||
use super::*;
|
||||
use crate::test_helpers;
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
models::{notification::NotificationKind, user::User},
|
||||
value_objects::*,
|
||||
|
||||
use super::*;
|
||||
use crate::test_helpers;
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
models::{notification::NotificationKind, user::User},
|
||||
value_objects::*,
|
||||
};
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn save_and_list(pool: sqlx::PgPool) {
|
||||
let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgNotificationRepository::new(pool);
|
||||
use domain::models::feed::PageParams;
|
||||
let n = Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: user.id.clone(),
|
||||
kind: NotificationKind::Follow {
|
||||
from_user_id: from_user.id.clone(),
|
||||
},
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn save_and_list(pool: sqlx::PgPool) {
|
||||
let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgNotificationRepository::new(pool);
|
||||
use domain::models::feed::PageParams;
|
||||
let n = Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: user.id.clone(),
|
||||
kind: NotificationKind::Follow {
|
||||
from_user_id: from_user.id.clone(),
|
||||
repo.save(&n).await.unwrap();
|
||||
let page = repo
|
||||
.list_for_user(
|
||||
&user.id,
|
||||
&PageParams {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
},
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&n).await.unwrap();
|
||||
let page = repo
|
||||
.list_for_user(
|
||||
&user.id,
|
||||
&PageParams {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(page.total, 1);
|
||||
assert!(!page.items[0].read);
|
||||
}
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(page.total, 1);
|
||||
assert!(!page.items[0].read);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn mark_all_read(pool: sqlx::PgPool) {
|
||||
let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgNotificationRepository::new(pool);
|
||||
use domain::models::feed::PageParams;
|
||||
let n = Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: user.id.clone(),
|
||||
kind: NotificationKind::Follow {
|
||||
from_user_id: from_user.id.clone(),
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn mark_all_read(pool: sqlx::PgPool) {
|
||||
let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgNotificationRepository::new(pool);
|
||||
use domain::models::feed::PageParams;
|
||||
let n = Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: user.id.clone(),
|
||||
kind: NotificationKind::Follow {
|
||||
from_user_id: from_user.id.clone(),
|
||||
},
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&n).await.unwrap();
|
||||
repo.mark_all_read(&user.id).await.unwrap();
|
||||
let page = repo
|
||||
.list_for_user(
|
||||
&user.id,
|
||||
&PageParams {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
},
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&n).await.unwrap();
|
||||
repo.mark_all_read(&user.id).await.unwrap();
|
||||
let page = repo
|
||||
.list_for_user(
|
||||
&user.id,
|
||||
&PageParams {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(page.items[0].read);
|
||||
}
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(page.items[0].read);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,49 @@
|
||||
use super::*;
|
||||
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||
use domain::ports::{ThoughtRepository, UserWriter};
|
||||
use domain::{
|
||||
models::{
|
||||
thought::{Thought, Visibility},
|
||||
user::User,
|
||||
},
|
||||
value_objects::*,
|
||||
};
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn find_or_create_tag(pool: sqlx::PgPool) {
|
||||
let repo = PgTagRepository::new(pool);
|
||||
let t1 = repo.find_or_create("rust").await.unwrap();
|
||||
let t2 = repo.find_or_create("rust").await.unwrap();
|
||||
assert_eq!(t1.id, t2.id);
|
||||
assert_eq!(t1.name, "rust");
|
||||
}
|
||||
use super::*;
|
||||
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||
use domain::ports::{ThoughtRepository, UserWriter};
|
||||
use domain::{
|
||||
models::{
|
||||
thought::{Thought, Visibility},
|
||||
user::User,
|
||||
},
|
||||
value_objects::*,
|
||||
};
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn attach_and_list(pool: sqlx::PgPool) {
|
||||
let urepo = PgUserRepository::new(pool.clone());
|
||||
let trepo = PgThoughtRepository::new(pool.clone());
|
||||
let u = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("alice").unwrap(),
|
||||
Email::new("alice@ex.com").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
urepo.save(&u).await.unwrap();
|
||||
let t = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
u.id.clone(),
|
||||
Content::new_local("hi").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
trepo.save(&t).await.unwrap();
|
||||
let repo = PgTagRepository::new(pool);
|
||||
let tag = repo.find_or_create("greetings").await.unwrap();
|
||||
repo.attach_to_thought(&t.id, tag.id).await.unwrap();
|
||||
let tags = repo.list_for_thought(&t.id).await.unwrap();
|
||||
assert_eq!(tags.len(), 1);
|
||||
assert_eq!(tags[0].name, "greetings");
|
||||
}
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn find_or_create_tag(pool: sqlx::PgPool) {
|
||||
let repo = PgTagRepository::new(pool);
|
||||
let t1 = repo.find_or_create("rust").await.unwrap();
|
||||
let t2 = repo.find_or_create("rust").await.unwrap();
|
||||
assert_eq!(t1.id, t2.id);
|
||||
assert_eq!(t1.name, "rust");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn attach_and_list(pool: sqlx::PgPool) {
|
||||
let urepo = PgUserRepository::new(pool.clone());
|
||||
let trepo = PgThoughtRepository::new(pool.clone());
|
||||
let u = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("alice").unwrap(),
|
||||
Email::new("alice@ex.com").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
urepo.save(&u).await.unwrap();
|
||||
let t = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
u.id.clone(),
|
||||
Content::new_local("hi").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
trepo.save(&t).await.unwrap();
|
||||
let repo = PgTagRepository::new(pool);
|
||||
let tag = repo.find_or_create("greetings").await.unwrap();
|
||||
repo.attach_to_thought(&t.id, tag.id).await.unwrap();
|
||||
let tags = repo.list_for_thought(&t.id).await.unwrap();
|
||||
assert_eq!(tags.len(), 1);
|
||||
assert_eq!(tags[0].name, "greetings");
|
||||
}
|
||||
|
||||
@@ -1,90 +1,91 @@
|
||||
use super::*;
|
||||
use crate::test_helpers::seed_user;
|
||||
use domain::{
|
||||
models::thought::{Thought, Visibility},
|
||||
value_objects::*,
|
||||
};
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn save_and_find_thought(pool: sqlx::PgPool) {
|
||||
let user = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let repo = PgThoughtRepository::new(pool);
|
||||
let t = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
user.id.clone(),
|
||||
Content::new_local("hello world").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
repo.save(&t).await.unwrap();
|
||||
let found = repo.find_by_id(&t.id).await.unwrap().unwrap();
|
||||
assert_eq!(found.content.as_str(), "hello world");
|
||||
assert!(found.local);
|
||||
}
|
||||
use super::*;
|
||||
use crate::test_helpers::seed_user;
|
||||
use domain::{
|
||||
models::thought::{Thought, Visibility},
|
||||
value_objects::*,
|
||||
};
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn delete_thought(pool: sqlx::PgPool) {
|
||||
let user = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgThoughtRepository::new(pool);
|
||||
let t = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
user.id.clone(),
|
||||
Content::new_local("bye").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
repo.save(&t).await.unwrap();
|
||||
repo.delete(&t.id, &user.id).await.unwrap();
|
||||
assert!(repo.find_by_id(&t.id).await.unwrap().is_none());
|
||||
}
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn save_and_find_thought(pool: sqlx::PgPool) {
|
||||
let user = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let repo = PgThoughtRepository::new(pool);
|
||||
let t = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
user.id.clone(),
|
||||
Content::new_local("hello world").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
repo.save(&t).await.unwrap();
|
||||
let found = repo.find_by_id(&t.id).await.unwrap().unwrap();
|
||||
assert_eq!(found.content.as_str(), "hello world");
|
||||
assert!(found.local);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn delete_wrong_owner_returns_not_found(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgThoughtRepository::new(pool);
|
||||
let t = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("secret").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
repo.save(&t).await.unwrap();
|
||||
let err = repo.delete(&t.id, &bob.id).await.unwrap_err();
|
||||
assert!(matches!(err, DomainError::NotFound));
|
||||
}
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn delete_thought(pool: sqlx::PgPool) {
|
||||
let user = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgThoughtRepository::new(pool);
|
||||
let t = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
user.id.clone(),
|
||||
Content::new_local("bye").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
repo.save(&t).await.unwrap();
|
||||
repo.delete(&t.id, &user.id).await.unwrap();
|
||||
assert!(repo.find_by_id(&t.id).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
|
||||
let user = seed_user(&pool, "charlie", "charlie@ex.com").await;
|
||||
let repo = PgThoughtRepository::new(pool);
|
||||
let root = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
user.id.clone(),
|
||||
Content::new_local("root").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
let reply = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
user.id.clone(),
|
||||
Content::new_local("reply").unwrap(),
|
||||
Some(root.id.clone()),
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
repo.save(&root).await.unwrap();
|
||||
repo.save(&reply).await.unwrap();
|
||||
let thread = repo.get_thread(&root.id).await.unwrap();
|
||||
assert_eq!(thread.len(), 2);
|
||||
}
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn delete_wrong_owner_returns_not_found(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgThoughtRepository::new(pool);
|
||||
let t = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("secret").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
repo.save(&t).await.unwrap();
|
||||
let err = repo.delete(&t.id, &bob.id).await.unwrap_err();
|
||||
assert!(matches!(err, DomainError::NotFound));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
|
||||
let user = seed_user(&pool, "charlie", "charlie@ex.com").await;
|
||||
let repo = PgThoughtRepository::new(pool);
|
||||
let root = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
user.id.clone(),
|
||||
Content::new_local("root").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
let reply = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
user.id.clone(),
|
||||
Content::new_local("reply").unwrap(),
|
||||
Some(root.id.clone()),
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
repo.save(&root).await.unwrap();
|
||||
repo.save(&reply).await.unwrap();
|
||||
let thread = repo.get_thread(&root.id).await.unwrap();
|
||||
assert_eq!(thread.len(), 2);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,48 @@
|
||||
use super::*;
|
||||
use crate::user::PgUserRepository;
|
||||
use domain::ports::UserWriter;
|
||||
use domain::{models::user::User, value_objects::*};
|
||||
|
||||
async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User {
|
||||
let repo = PgUserRepository::new(pool.clone());
|
||||
let u = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new(username).unwrap(),
|
||||
Email::new(email).unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
repo.save(&u).await.unwrap();
|
||||
u
|
||||
}
|
||||
use super::*;
|
||||
use crate::user::PgUserRepository;
|
||||
use domain::ports::UserWriter;
|
||||
use domain::{models::user::User, value_objects::*};
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn set_and_list_top_friends(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgTopFriendRepository::new(pool);
|
||||
repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)])
|
||||
.await
|
||||
.unwrap();
|
||||
let friends = repo.list_for_user(&alice.id).await.unwrap();
|
||||
assert_eq!(friends.len(), 1);
|
||||
assert_eq!(friends[0].0.position, 1);
|
||||
assert_eq!(friends[0].1.username.as_str(), "bob");
|
||||
}
|
||||
async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User {
|
||||
let repo = PgUserRepository::new(pool.clone());
|
||||
let u = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new(username).unwrap(),
|
||||
Email::new(email).unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
repo.save(&u).await.unwrap();
|
||||
u
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn replace_top_friends(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let carol = seed_user(&pool, "carol", "carol@ex.com").await;
|
||||
let repo = PgTopFriendRepository::new(pool);
|
||||
repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)])
|
||||
.await
|
||||
.unwrap();
|
||||
repo.set_top_friends(&alice.id, vec![(carol.id.clone(), 1)])
|
||||
.await
|
||||
.unwrap();
|
||||
let friends = repo.list_for_user(&alice.id).await.unwrap();
|
||||
assert_eq!(friends.len(), 1);
|
||||
assert_eq!(friends[0].1.username.as_str(), "carol");
|
||||
}
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn set_and_list_top_friends(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgTopFriendRepository::new(pool);
|
||||
repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)])
|
||||
.await
|
||||
.unwrap();
|
||||
let friends = repo.list_for_user(&alice.id).await.unwrap();
|
||||
assert_eq!(friends.len(), 1);
|
||||
assert_eq!(friends[0].0.position, 1);
|
||||
assert_eq!(friends[0].1.username.as_str(), "bob");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn replace_top_friends(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let carol = seed_user(&pool, "carol", "carol@ex.com").await;
|
||||
let repo = PgTopFriendRepository::new(pool);
|
||||
repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)])
|
||||
.await
|
||||
.unwrap();
|
||||
repo.set_top_friends(&alice.id, vec![(carol.id.clone(), 1)])
|
||||
.await
|
||||
.unwrap();
|
||||
let friends = repo.list_for_user(&alice.id).await.unwrap();
|
||||
assert_eq!(friends.len(), 1);
|
||||
assert_eq!(friends[0].1.username.as_str(), "carol");
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
)
|
||||
.bind(&uuids[..])
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
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| {
|
||||
let user = User::from(r);
|
||||
(user.id.clone(), user)
|
||||
}).collect())
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let user = User::from(r);
|
||||
(user.id.clone(), user)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,69 +1,70 @@
|
||||
use super::*;
|
||||
use domain::{models::user::User, value_objects::*};
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn save_and_find_by_id(pool: sqlx::PgPool) {
|
||||
let repo = PgUserRepository::new(pool);
|
||||
let user = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("alice").unwrap(),
|
||||
Email::new("alice@ex.com").unwrap(),
|
||||
PasswordHash("hash".into()),
|
||||
);
|
||||
repo.save(&user).await.unwrap();
|
||||
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
|
||||
assert_eq!(found.username.as_str(), "alice");
|
||||
assert_eq!(found.email.as_str(), "alice@ex.com");
|
||||
}
|
||||
use super::*;
|
||||
use domain::{models::user::User, value_objects::*};
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn find_by_username_returns_none_when_missing(pool: sqlx::PgPool) {
|
||||
let repo = PgUserRepository::new(pool);
|
||||
let result = repo
|
||||
.find_by_username(&Username::new("ghost").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn save_and_find_by_id(pool: sqlx::PgPool) {
|
||||
let repo = PgUserRepository::new(pool);
|
||||
let user = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("alice").unwrap(),
|
||||
Email::new("alice@ex.com").unwrap(),
|
||||
PasswordHash("hash".into()),
|
||||
);
|
||||
repo.save(&user).await.unwrap();
|
||||
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
|
||||
assert_eq!(found.username.as_str(), "alice");
|
||||
assert_eq!(found.email.as_str(), "alice@ex.com");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn find_by_email(pool: sqlx::PgPool) {
|
||||
let repo = PgUserRepository::new(pool);
|
||||
let user = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("bob").unwrap(),
|
||||
Email::new("bob@ex.com").unwrap(),
|
||||
PasswordHash("hash".into()),
|
||||
);
|
||||
repo.save(&user).await.unwrap();
|
||||
let found = repo
|
||||
.find_by_email(&Email::new("bob@ex.com").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(found.is_some());
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn update_profile_changes_fields(pool: sqlx::PgPool) {
|
||||
let repo = PgUserRepository::new(pool);
|
||||
let user = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("charlie").unwrap(),
|
||||
Email::new("charlie@ex.com").unwrap(),
|
||||
PasswordHash("hash".into()),
|
||||
);
|
||||
repo.save(&user).await.unwrap();
|
||||
repo.update_profile(
|
||||
&user.id,
|
||||
Some("Charlie".into()),
|
||||
Some("bio".into()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn find_by_username_returns_none_when_missing(pool: sqlx::PgPool) {
|
||||
let repo = PgUserRepository::new(pool);
|
||||
let result = repo
|
||||
.find_by_username(&Username::new("ghost").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
|
||||
assert_eq!(found.display_name.as_deref(), Some("Charlie"));
|
||||
assert_eq!(found.bio.as_deref(), Some("bio"));
|
||||
}
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn find_by_email(pool: sqlx::PgPool) {
|
||||
let repo = PgUserRepository::new(pool);
|
||||
let user = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("bob").unwrap(),
|
||||
Email::new("bob@ex.com").unwrap(),
|
||||
PasswordHash("hash".into()),
|
||||
);
|
||||
repo.save(&user).await.unwrap();
|
||||
let found = repo
|
||||
.find_by_email(&Email::new("bob@ex.com").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(found.is_some());
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn update_profile_changes_fields(pool: sqlx::PgPool) {
|
||||
let repo = PgUserRepository::new(pool);
|
||||
let user = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("charlie").unwrap(),
|
||||
Email::new("charlie@ex.com").unwrap(),
|
||||
PasswordHash("hash".into()),
|
||||
);
|
||||
repo.save(&user).await.unwrap();
|
||||
repo.update_profile(
|
||||
&user.id,
|
||||
Some("Charlie".into()),
|
||||
Some("bio".into()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
|
||||
assert_eq!(found.display_name.as_deref(), Some("Charlie"));
|
||||
assert_eq!(found.bio.as_deref(), Some("bio"));
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -34,21 +34,16 @@ 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 {
|
||||
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())
|
||||
}
|
||||
other => other,
|
||||
})?;
|
||||
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()),
|
||||
other => other,
|
||||
})?;
|
||||
events
|
||||
.publish(&DomainEvent::UserRegistered {
|
||||
user_id: user.id.clone(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -31,9 +31,16 @@ 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()))
|
||||
.await
|
||||
.unwrap();
|
||||
let out = create_thought(
|
||||
&store,
|
||||
&store,
|
||||
&store,
|
||||
&NoOpEventPublisher,
|
||||
&outbox,
|
||||
input(u.id.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.thought.content.as_str(), "hello");
|
||||
let staged = outbox.staged();
|
||||
assert_eq!(staged.len(), 1);
|
||||
@@ -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,9 +91,15 @@ async fn delete_own_thought_succeeds() {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &out.thought.id, &u.id)
|
||||
.await
|
||||
.unwrap();
|
||||
delete_thought(
|
||||
&store,
|
||||
&NoOpEventPublisher,
|
||||
&NoOpOutboxWriter,
|
||||
&out.thought.id,
|
||||
&u.id,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.thoughts.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
@@ -113,9 +128,15 @@ async fn delete_other_thought_returns_not_found() {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let err = delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &out.thought.id, &bob.id)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let err = delete_thought(
|
||||
&store,
|
||||
&NoOpEventPublisher,
|
||||
&NoOpOutboxWriter,
|
||||
&out.thought.id,
|
||||
&bob.id,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::NotFound));
|
||||
}
|
||||
|
||||
@@ -124,9 +145,16 @@ 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()))
|
||||
.await
|
||||
.unwrap();
|
||||
let out = create_thought(
|
||||
&store,
|
||||
&store,
|
||||
&store,
|
||||
&NoOpEventPublisher,
|
||||
&NoOpOutboxWriter,
|
||||
input(alice.id.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let tid = out.thought.id.clone();
|
||||
|
||||
edit_thought(&store, &store, &tid, &alice.id, "updated".to_string())
|
||||
@@ -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
|
||||
|
||||
@@ -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())),
|
||||
};
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })))
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
<h1
|
||||
className="text-5xl font-bold"
|
||||
style={{ textShadow: "2px 2px 4px rgba(0,0,0,0.1)" }}
|
||||
<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)",
|
||||
}}
|
||||
>
|
||||
Welcome to Thoughts
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Throwback to the golden age of microblogging.
|
||||
</p>
|
||||
<div className="mt-8 flex justify-center gap-4">
|
||||
<Button asChild>
|
||||
<Link href="/login">Login</Link>
|
||||
</Button>
|
||||
<Button variant="secondary" asChild>
|
||||
<Link href="/register">Register</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
{thoughtThreads.length === 0 && (
|
||||
<EmptyState message="This user hasn't posted any public thoughts yet." />
|
||||
)}
|
||||
<UserThoughtsList
|
||||
username={username}
|
||||
initialThoughts={thoughts}
|
||||
totalPages={totalPages}
|
||||
me={me}
|
||||
/>
|
||||
</TabsContent>
|
||||
{isOwnProfile && (
|
||||
<TabsContent value="federation">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,31 +11,101 @@ 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 (
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
variant={optimisticFollowing ? "secondary" : "default"}
|
||||
data-following={optimisticFollowing}
|
||||
>
|
||||
{optimisticFollowing ? (
|
||||
<><UserMinus className="mr-2 h-4 w-4" /> Unfollow</>
|
||||
) : (
|
||||
<><UserPlus className="mr-2 h-4 w-4" /> Follow</>
|
||||
)}
|
||||
</Button>
|
||||
<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 ? (
|
||||
<><UserMinus className="mr-2 h-4 w-4" /> Unfollow</>
|
||||
) : (
|
||||
<><UserPlus className="mr-2 h-4 w-4" /> Follow</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
Thoughts
|
||||
</span>
|
||||
</Link>
|
||||
<MainNav />
|
||||
</div>
|
||||
{/* 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 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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,12 +31,17 @@ 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"
|
||||
>
|
||||
{friend.displayName || friend.username}
|
||||
<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>
|
||||
))}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
72
thoughts-frontend/components/user-thoughts-list.tsx
Normal file
72
thoughts-frontend/components/user-thoughts-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
thoughts-frontend/public/bg1.avif
Normal file
BIN
thoughts-frontend/public/bg1.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
Reference in New Issue
Block a user