Compare commits

...

20 Commits

Author SHA1 Message Date
c5d9833c8b refactor: replace long arg lists with input/config structs and builder
Some checks failed
lint / lint (push) Failing after 7m8s
test / unit (push) Successful in 17m2s
test / integration (push) Failing after 17m47s
- Thought::new_local → NewThought struct (7 args → 1)
- UserWriter::update_profile → UpdateProfileInput struct (6 args → 2)
- update_profile use case → UpdateProfileInput (8 args → 3)
- ActivityPubService::new → builder pattern (9 args → 5 required + 4 optional setters)
- accept_note → AcceptNoteInput struct (8 args → 1)
- ThoughtNote::new_public → ThoughtNoteInput struct (8 args → 1)

Remove all #[allow(clippy::too_many_arguments)] annotations.
2026-05-17 12:25:53 +02:00
f39c1a614d clean up
Some checks failed
lint / lint (push) Failing after 7m18s
test / integration (push) Has been cancelled
test / unit (push) Has been cancelled
2026-05-17 12:15:27 +02:00
30c8a17168 clean up
Some checks failed
test / unit (push) Has been cancelled
lint / lint (push) Has been cancelled
test / integration (push) Has been cancelled
2026-05-17 12:14:45 +02:00
6a8c8b1fb8 chore: add pre-commit fmt+clippy hooks, fix clippy warnings 2026-05-17 12:09:24 +02:00
4ec0725ff8 fmt
Some checks failed
lint / lint (push) Failing after 5m3s
test / integration (push) Has been cancelled
test / unit (push) Has been cancelled
2026-05-17 12:04:51 +02:00
31e0f2958c fix: make ThoughtNote sensitive field optional (default false) 2026-05-17 12:02:58 +02:00
555121ea75 fix: promote worker event logs from debug to info 2026-05-17 12:02:13 +02:00
9e795eefdc fix: make ThoughtNote url field optional for AP compat
Some checks failed
lint / lint (push) Failing after 5m1s
test / integration (push) Has been cancelled
test / unit (push) Has been cancelled
2026-05-17 11:57:10 +02:00
18cf2c9f54 feat: implement verify() for all stub activity handlers
Undo: inner activity actor must match Undo actor
Announce/Like/Block: verify_domains_match(activity_id, actor_url)
Add: attributedTo must match actor (same as Create/Update)
2026-05-17 11:55:17 +02:00
b58c96b843 feat: implement federation post/connections backfill schedulers
Some checks failed
lint / lint (push) Failing after 5m12s
test / integration (push) Has been cancelled
test / unit (push) Has been cancelled
schedule_actor_posts_fetch now spawns backfill_outbox in background,
fetching all pages of a remote outbox and persisting via accept_note.
schedule_connections_fetch follows AP collection next-links, resolves
profiles, and caches them in the DB. Both were no-ops ("deferred").

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

18
.claude/settings.json Normal file
View File

@@ -0,0 +1,18 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git commit*)",
"command": "cargo fmt --all 2>&1 && cargo clippy --workspace 2>&1 || echo '{\"continue\": false, \"stopReason\": \"cargo fmt or clippy failed — fix before committing\"}'",
"timeout": 120,
"statusMessage": "Running cargo fmt + clippy..."
}
]
}
]
}
}

View File

@@ -4,6 +4,7 @@ use activitypub_federation::{
kinds::activity::{ kinds::activity::{
AcceptType, CreateType, DeleteType, FollowType, RejectType, UndoType, UpdateType, AcceptType, CreateType, DeleteType, FollowType, RejectType, UndoType, UpdateType,
}, },
protocol::verification::verify_domains_match,
traits::Activity, traits::Activity,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -239,6 +240,14 @@ impl Activity for UndoActivity {
} }
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> { 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())
&& inner_actor != self.actor.inner().as_str()
{
return Err(Error::bad_request(anyhow::anyhow!(
"Undo actor does not match inner activity actor"
)));
}
Ok(()) Ok(())
} }
@@ -570,6 +579,7 @@ impl Activity for AnnounceActivity {
} }
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> { async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
verify_domains_match(&self.id, self.actor.inner())?;
Ok(()) Ok(())
} }
@@ -633,6 +643,7 @@ impl Activity for LikeActivity {
} }
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> { async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
verify_domains_match(&self.id, self.actor.inner())?;
Ok(()) Ok(())
} }
@@ -692,6 +703,14 @@ impl Activity for AddActivity {
} }
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> { 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(()) Ok(())
} }
@@ -742,6 +761,7 @@ impl Activity for BlockActivity {
} }
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> { async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
verify_domains_match(&self.id, self.actor.inner())?;
Ok(()) Ok(())
} }

View File

@@ -5,6 +5,17 @@ use domain::{
value_objects::{ThoughtId, UserId, Username}, value_objects::{ThoughtId, UserId, Username},
}; };
pub struct AcceptNoteInput<'a> {
pub ap_id: &'a str,
pub author_id: &'a UserId,
pub content: &'a str,
pub published: chrono::DateTime<chrono::Utc>,
pub sensitive: bool,
pub content_warning: Option<String>,
pub visibility: &'a str,
pub in_reply_to: Option<&'a str>,
}
/// AP-protocol endpoints for a locally-stored user (local or interned remote). /// AP-protocol endpoints for a locally-stored user (local or interned remote).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ActorApUrls { pub struct ActorApUrls {
@@ -61,18 +72,8 @@ pub trait ActivityPubRepository: Send + Sync {
// ── Inbox processing (remote → local) ─────────────────────────── // ── Inbox processing (remote → local) ───────────────────────────
/// Persist an incoming remote Note. Idempotent on ap_id. /// Persist an incoming remote Note. Idempotent on ap_id.
#[allow(clippy::too_many_arguments)]
async fn accept_note( async fn accept_note(&self, input: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError>;
&self,
ap_id: &str,
author_id: &UserId,
content: &str,
published: chrono::DateTime<chrono::Utc>,
sensitive: bool,
content_warning: Option<String>,
visibility: &str,
in_reply_to: Option<&str>,
) -> Result<ThoughtId, DomainError>;
/// Apply an Update to a previously accepted remote Note. /// Apply an Update to a previously accepted remote Note.
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>;

View File

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

View File

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

View File

@@ -7,9 +7,9 @@ use chrono::{DateTime, Utc};
use std::sync::Arc; use std::sync::Arc;
use url::Url; use url::Url;
use crate::note::ThoughtNote; use crate::note::{ThoughtNote, ThoughtNoteInput};
use crate::urls::ThoughtsUrls; use crate::urls::ThoughtsUrls;
use activitypub_base::{ActivityPubRepository, ApObjectHandler}; use activitypub_base::{AcceptNoteInput, ActivityPubRepository, ApObjectHandler};
use domain::ports::{EventPublisher, TagRepository}; use domain::ports::{EventPublisher, TagRepository};
use domain::value_objects::UserId; use domain::value_objects::UserId;
@@ -58,16 +58,16 @@ impl ApObjectHandler for ThoughtsObjectHandler {
.thought .thought
.in_reply_to_id .in_reply_to_id
.map(|id| self.urls.thought_url(id.as_uuid())); .map(|id| self.urls.thought_url(id.as_uuid()));
let note = ThoughtNote::new_public( let note = ThoughtNote::new_public(ThoughtNoteInput {
note_url.clone(), id: note_url.clone(),
actor_url, actor_url,
e.thought.content.as_str().to_owned(), content: e.thought.content.as_str().to_owned(),
e.thought.created_at, published: e.thought.created_at,
in_reply_to, in_reply_to,
e.thought.sensitive, sensitive: e.thought.sensitive,
e.thought.content_warning, summary: e.thought.content_warning,
followers, followers_url: followers,
); });
Ok((note_url, serde_json::to_value(&note)?)) Ok((note_url, serde_json::to_value(&note)?))
}) })
.collect() .collect()
@@ -96,16 +96,16 @@ impl ApObjectHandler for ThoughtsObjectHandler {
.thought .thought
.in_reply_to_id .in_reply_to_id
.map(|id| self.urls.thought_url(id.as_uuid())); .map(|id| self.urls.thought_url(id.as_uuid()));
let note = ThoughtNote::new_public( let note = ThoughtNote::new_public(ThoughtNoteInput {
note_url.clone(), id: note_url.clone(),
actor_url, actor_url,
e.thought.content.as_str().to_owned(), content: e.thought.content.as_str().to_owned(),
created_at, published: created_at,
in_reply_to, in_reply_to,
e.thought.sensitive, sensitive: e.thought.sensitive,
e.thought.content_warning, summary: e.thought.content_warning,
followers, followers_url: followers,
); });
Ok((note_url, serde_json::to_value(&note)?, created_at)) Ok((note_url, serde_json::to_value(&note)?, created_at))
}) })
.collect() .collect()
@@ -141,17 +141,18 @@ impl ApObjectHandler for ThoughtsObjectHandler {
"direct" "direct"
}; };
let thought_id = self.repo let thought_id = self
.accept_note( .repo
ap_id.as_str(), .accept_note(AcceptNoteInput {
&author_id, ap_id: ap_id.as_str(),
&note.content, author_id: &author_id,
note.published, content: &note.content,
note.sensitive, published: note.published,
note.summary, sensitive: note.sensitive,
content_warning: note.summary,
visibility, visibility,
note.in_reply_to.as_ref().map(|u| u.as_str()), in_reply_to: note.in_reply_to.as_ref().map(|u| u.as_str()),
) })
.await .await
.map_err(|e| anyhow!("{e}"))?; .map_err(|e| anyhow!("{e}"))?;

View File

@@ -11,7 +11,8 @@ pub struct ThoughtNote {
#[serde(rename = "type")] #[serde(rename = "type")]
pub kind: NoteType, pub kind: NoteType,
pub id: Url, 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 attributed_to: Url,
pub content: String, pub content: String,
pub published: DateTime<Utc>, pub published: DateTime<Utc>,
@@ -21,6 +22,7 @@ pub struct ThoughtNote {
pub cc: Vec<String>, pub cc: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub in_reply_to: Option<Url>, pub in_reply_to: Option<Url>,
#[serde(default)]
pub sensitive: bool, pub sensitive: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>, pub summary: Option<String>,
@@ -28,30 +30,31 @@ pub struct ThoughtNote {
pub tag: Vec<serde_json::Value>, pub tag: Vec<serde_json::Value>,
} }
pub struct ThoughtNoteInput {
pub id: Url,
pub actor_url: Url,
pub content: String,
pub published: DateTime<Utc>,
pub in_reply_to: Option<Url>,
pub sensitive: bool,
pub summary: Option<String>,
pub followers_url: Url,
}
impl ThoughtNote { impl ThoughtNote {
#[allow(clippy::too_many_arguments)] pub fn new_public(p: ThoughtNoteInput) -> Self {
pub fn new_public(
id: Url,
actor_url: Url,
content: String,
published: DateTime<Utc>,
in_reply_to: Option<Url>,
sensitive: bool,
summary: Option<String>,
followers_url: Url,
) -> Self {
Self { Self {
kind: Default::default(), kind: Default::default(),
url: id.clone(), url: Some(p.id.clone()),
id, id: p.id,
attributed_to: actor_url, attributed_to: p.actor_url,
content, content: p.content,
published, published: p.published,
to: vec![AS_PUBLIC.to_string()], to: vec![AS_PUBLIC.to_string()],
cc: vec![followers_url.to_string()], cc: vec![p.followers_url.to_string()],
in_reply_to, in_reply_to: p.in_reply_to,
sensitive, sensitive: p.sensitive,
summary, summary: p.summary,
tag: Vec::new(), tag: Vec::new(),
} }
} }

View File

@@ -2,16 +2,16 @@ use super::*;
#[test] #[test]
fn note_serializes_with_public_audience() { fn note_serializes_with_public_audience() {
let note = ThoughtNote::new_public( let note = ThoughtNote::new_public(super::ThoughtNoteInput {
"https://example.com/thoughts/1".parse().unwrap(), id: "https://example.com/thoughts/1".parse().unwrap(),
"https://example.com/users/alice".parse().unwrap(), actor_url: "https://example.com/users/alice".parse().unwrap(),
"Hello world".to_string(), content: "Hello world".to_string(),
chrono::Utc::now(), published: chrono::Utc::now(),
None, in_reply_to: None,
false, sensitive: false,
None, summary: None,
"https://example.com/users/alice/followers".parse().unwrap(), followers_url: "https://example.com/users/alice/followers".parse().unwrap(),
); });
let json = serde_json::to_string(&note).unwrap(); let json = serde_json::to_string(&note).unwrap();
assert!(json.contains(AS_PUBLIC)); assert!(json.contains(AS_PUBLIC));
assert!(json.contains("Hello world")); assert!(json.contains("Hello world"));

View File

@@ -18,7 +18,13 @@ impl ApiKeyRepository for FakeApiKeyRepo {
Ok(()) Ok(())
} }
async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> { 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> { async fn list_for_user(&self, _uid: &UserId) -> Result<Vec<ApiKey>, DomainError> {
Ok(vec![]) Ok(vec![])

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
use super::*; use super::*;
use domain::{ use domain::{
models::{ models::{
thought::{Thought, Visibility}, thought::{NewThought, Thought, Visibility},
user::User, user::User,
}, },
ports::{SearchPort, ThoughtRepository, UserWriter}, ports::{SearchPort, ThoughtRepository, UserWriter},
@@ -19,15 +19,15 @@ async fn seed_thought(pool: &sqlx::PgPool, username: &str, content: &str) -> (Us
PasswordHash("h".into()), PasswordHash("h".into()),
); );
urepo.save(&u).await.unwrap(); urepo.save(&u).await.unwrap();
let t = Thought::new_local( let t = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
u.id.clone(), user_id: u.id.clone(),
Content::new_local(content).unwrap(), content: Content::new_local(content).unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
trepo.save(&t).await.unwrap(); trepo.save(&t).await.unwrap();
(u, t) (u, t)
} }

View File

@@ -6,7 +6,7 @@ const THOUGHTS_PATH_PREFIX: &str = "/thoughts/";
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::PgPool; use sqlx::PgPool;
use activitypub_base::{ActivityPubRepository, ActorApUrls, OutboxEntry}; use activitypub_base::{AcceptNoteInput, ActivityPubRepository, ActorApUrls, OutboxEntry};
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::thought::{Thought, Visibility}, models::thought::{Thought, Visibility},
@@ -210,17 +210,17 @@ impl ActivityPubRepository for PgActivityPubRepository {
.map(|_| ()) .map(|_| ())
} }
async fn accept_note( async fn accept_note(&self, input: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError> {
&self, let AcceptNoteInput {
ap_id: &str, ap_id,
author_id: &UserId, author_id,
content: &str, content,
published: DateTime<Utc>, published,
sensitive: bool, sensitive,
content_warning: Option<String>, content_warning,
visibility: &str, visibility,
in_reply_to: Option<&str>, in_reply_to,
) -> Result<ThoughtId, DomainError> { } = input;
let capped: String = content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect(); let capped: String = content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
let (in_reply_to_id, in_reply_to_url) = match in_reply_to { let (in_reply_to_id, in_reply_to_url) = match in_reply_to {
Some(url) => { Some(url) => {
@@ -254,8 +254,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
.into_domain()?; .into_domain()?;
// SELECT the id — works whether the INSERT was a no-op or not (idempotent). // SELECT the id — works whether the INSERT was a no-op or not (idempotent).
let row: (uuid::Uuid,) = let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
.bind(ap_id) .bind(ap_id)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await

View File

@@ -1,44 +1,44 @@
use super::*; use super::*;
use activitypub_base::ActivityPubRepository; use activitypub_base::{AcceptNoteInput, ActivityPubRepository};
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) { async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool); let repo = PgActivityPubRepository::new(pool);
let url = "https://mastodon.social/users/alice"; let url = "https://mastodon.social/users/alice";
let id1 = repo.intern_remote_actor(url).await.unwrap(); let id1 = repo.intern_remote_actor(url).await.unwrap();
let id2 = repo.intern_remote_actor(url).await.unwrap(); let id2 = repo.intern_remote_actor(url).await.unwrap();
assert_eq!(id1, id2); assert_eq!(id1, id2);
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn accept_and_retract_note(pool: sqlx::PgPool) { async fn accept_and_retract_note(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool); let repo = PgActivityPubRepository::new(pool);
let actor_url = "https://remote.example/users/bob"; let actor_url = "https://remote.example/users/bob";
let ap_id = "https://remote.example/notes/1"; let ap_id = "https://remote.example/notes/1";
let author = repo.intern_remote_actor(actor_url).await.unwrap(); let author = repo.intern_remote_actor(actor_url).await.unwrap();
repo.accept_note( repo.accept_note(AcceptNoteInput {
ap_id, ap_id,
&author, author_id: &author,
"hello from remote", content: "hello from remote",
chrono::Utc::now(), published: chrono::Utc::now(),
false, sensitive: false,
None, content_warning: None,
"public", visibility: "public",
None, in_reply_to: None,
) })
.await .await
.unwrap(); .unwrap();
repo.retract_note(ap_id).await.unwrap(); repo.retract_note(ap_id).await.unwrap();
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn count_local_notes_excludes_remote(pool: sqlx::PgPool) { async fn count_local_notes_excludes_remote(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool); let repo = PgActivityPubRepository::new(pool);
assert_eq!(repo.count_local_notes().await.unwrap(), 0); assert_eq!(repo.count_local_notes().await.unwrap(), 0);
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn accept_note_returns_thought_id(pool: sqlx::PgPool) { async fn accept_note_returns_thought_id(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool.clone()); let repo = PgActivityPubRepository::new(pool.clone());
let actor_user_id = repo let actor_user_id = repo
.intern_remote_actor("https://remote.example/users/alice") .intern_remote_actor("https://remote.example/users/alice")
@@ -46,16 +46,16 @@
.unwrap(); .unwrap();
let thought_id = repo let thought_id = repo
.accept_note( .accept_note(AcceptNoteInput {
"https://remote.example/notes/1", ap_id: "https://remote.example/notes/1",
&actor_user_id, author_id: &actor_user_id,
"Hello #rust world", content: "Hello #rust world",
chrono::Utc::now(), published: chrono::Utc::now(),
false, sensitive: false,
None, content_warning: None,
"public", visibility: "public",
None, in_reply_to: None,
) })
.await .await
.unwrap(); .unwrap();
@@ -65,4 +65,4 @@
.await .await
.unwrap(); .unwrap();
assert_eq!(thought_id.as_uuid(), row.0); assert_eq!(thought_id.as_uuid(), row.0);
} }

View File

@@ -1,10 +1,10 @@
use super::*; use super::*;
use crate::user::PgUserRepository; use crate::user::PgUserRepository;
use chrono::Utc; use chrono::Utc;
use domain::ports::UserWriter; use domain::ports::UserWriter;
use domain::{models::user::User, value_objects::*}; use domain::{models::user::User, value_objects::*};
async fn seed_user(pool: &sqlx::PgPool) -> User { async fn seed_user(pool: &sqlx::PgPool) -> User {
let repo = PgUserRepository::new(pool.clone()); let repo = PgUserRepository::new(pool.clone());
let u = User::new_local( let u = User::new_local(
UserId::new(), UserId::new(),
@@ -14,10 +14,10 @@
); );
repo.save(&u).await.unwrap(); repo.save(&u).await.unwrap();
u u
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn save_and_find_by_hash(pool: sqlx::PgPool) { async fn save_and_find_by_hash(pool: sqlx::PgPool) {
let user = seed_user(&pool).await; let user = seed_user(&pool).await;
let repo = PgApiKeyRepository::new(pool); let repo = PgApiKeyRepository::new(pool);
let key = ApiKey { let key = ApiKey {
@@ -30,10 +30,10 @@
repo.save(&key).await.unwrap(); repo.save(&key).await.unwrap();
let found = repo.find_by_hash("abc123").await.unwrap().unwrap(); let found = repo.find_by_hash("abc123").await.unwrap().unwrap();
assert_eq!(found.name, "test"); assert_eq!(found.name, "test");
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn delete_key(pool: sqlx::PgPool) { async fn delete_key(pool: sqlx::PgPool) {
let user = seed_user(&pool).await; let user = seed_user(&pool).await;
let repo = PgApiKeyRepository::new(pool); let repo = PgApiKeyRepository::new(pool);
let key = ApiKey { let key = ApiKey {
@@ -46,4 +46,4 @@
repo.save(&key).await.unwrap(); repo.save(&key).await.unwrap();
repo.delete(&key.id, &user.id).await.unwrap(); repo.delete(&key.id, &user.id).await.unwrap();
assert!(repo.find_by_hash("def456").await.unwrap().is_none()); assert!(repo.find_by_hash("def456").await.unwrap().is_none());
} }

View File

@@ -1,10 +1,10 @@
use super::*; use super::*;
use crate::test_helpers::seed_user; use crate::test_helpers::seed_user;
use chrono::Utc; use chrono::Utc;
use domain::value_objects::*; use domain::value_objects::*;
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn block_exists(pool: sqlx::PgPool) { async fn block_exists(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgBlockRepository::new(pool); let repo = PgBlockRepository::new(pool);
@@ -16,10 +16,10 @@
repo.save(&block).await.unwrap(); repo.save(&block).await.unwrap();
assert!(repo.exists(&alice.id, &bob.id).await.unwrap()); assert!(repo.exists(&alice.id, &bob.id).await.unwrap());
assert!(!repo.exists(&bob.id, &alice.id).await.unwrap()); assert!(!repo.exists(&bob.id, &alice.id).await.unwrap());
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn unblock(pool: sqlx::PgPool) { async fn unblock(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgBlockRepository::new(pool); let repo = PgBlockRepository::new(pool);
@@ -31,4 +31,4 @@
repo.save(&block).await.unwrap(); repo.save(&block).await.unwrap();
repo.delete(&alice.id, &bob.id).await.unwrap(); repo.delete(&alice.id, &bob.id).await.unwrap();
assert!(!repo.exists(&alice.id, &bob.id).await.unwrap()); assert!(!repo.exists(&alice.id, &bob.id).await.unwrap());
} }

View File

@@ -1,10 +1,10 @@
use super::*; use super::*;
use crate::test_helpers::seed_user_and_thought; use crate::test_helpers::seed_user_and_thought;
use chrono::Utc; use chrono::Utc;
use domain::value_objects::*; use domain::value_objects::*;
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn boost_and_count(pool: sqlx::PgPool) { async fn boost_and_count(pool: sqlx::PgPool) {
let (user, thought) = seed_user_and_thought(&pool).await; let (user, thought) = seed_user_and_thought(&pool).await;
let repo = PgBoostRepository::new(pool); let repo = PgBoostRepository::new(pool);
let boost = Boost { let boost = Boost {
@@ -16,10 +16,10 @@
}; };
repo.save(&boost).await.unwrap(); repo.save(&boost).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1); assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn unboost(pool: sqlx::PgPool) { async fn unboost(pool: sqlx::PgPool) {
let (user, thought) = seed_user_and_thought(&pool).await; let (user, thought) = seed_user_and_thought(&pool).await;
let repo = PgBoostRepository::new(pool); let repo = PgBoostRepository::new(pool);
let boost = Boost { let boost = Boost {
@@ -32,4 +32,4 @@
repo.save(&boost).await.unwrap(); repo.save(&boost).await.unwrap();
repo.delete(&user.id, &thought.id).await.unwrap(); repo.delete(&user.id, &thought.id).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0); assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
} }

View File

@@ -1,16 +1,16 @@
use super::*; use super::*;
use crate::{thought::PgThoughtRepository, user::PgUserRepository}; use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::{ use domain::{
models::{ models::{
feed::PageParams, feed::PageParams,
thought::{Thought, Visibility}, thought::{NewThought, Thought, Visibility},
user::User, user::User,
}, },
ports::{FeedQuery, ThoughtRepository, UserWriter}, ports::{FeedQuery, ThoughtRepository, UserWriter},
value_objects::*, value_objects::*,
}; };
async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) { async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
let urepo = PgUserRepository::new(pool.clone()); let urepo = PgUserRepository::new(pool.clone());
let trepo = PgThoughtRepository::new(pool.clone()); let trepo = PgThoughtRepository::new(pool.clone());
let u = User::new_local( let u = User::new_local(
@@ -20,43 +20,49 @@
PasswordHash("h".into()), PasswordHash("h".into()),
); );
urepo.save(&u).await.unwrap(); urepo.save(&u).await.unwrap();
let t = Thought::new_local( let t = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
u.id.clone(), user_id: u.id.clone(),
Content::new_local(content).unwrap(), content: Content::new_local(content).unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
trepo.save(&t).await.unwrap(); trepo.save(&t).await.unwrap();
(u, t) (u, t)
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) { async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) {
let (_, _) = seed(&pool, "alice", "hello").await; let (_, _) = seed(&pool, "alice", "hello").await;
let repo = PgFeedRepository::new(pool); let repo = PgFeedRepository::new(pool);
let result = repo let result = repo
.query(&FeedQuery::public( .query(&FeedQuery::public(
PageParams { page: 1, per_page: 20 }, PageParams {
page: 1,
per_page: 20,
},
None, None,
)) ))
.await .await
.unwrap(); .unwrap();
assert_eq!(result.total, 1); assert_eq!(result.total, 1);
assert_eq!(result.items[0].thought.content.as_str(), "hello"); assert_eq!(result.items[0].thought.content.as_str(), "hello");
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn search_returns_matching_thoughts(pool: sqlx::PgPool) { async fn search_returns_matching_thoughts(pool: sqlx::PgPool) {
let (_, _) = seed(&pool, "alice", "hello world").await; let (_, _) = seed(&pool, "alice", "hello world").await;
let (_, _) = seed(&pool, "bob", "goodbye world").await; let (_, _) = seed(&pool, "bob", "goodbye world").await;
let repo = PgFeedRepository::new(pool); let repo = PgFeedRepository::new(pool);
let result = repo let result = repo
.query(&FeedQuery::search( .query(&FeedQuery::search(
"hello world", "hello world",
PageParams { page: 1, per_page: 20 }, PageParams {
page: 1,
per_page: 20,
},
None, None,
)) ))
.await .await
@@ -66,4 +72,4 @@
.items .items
.iter() .iter()
.any(|e| e.thought.content.as_str() == "hello world")); .any(|e| e.thought.content.as_str() == "hello world"));
} }

View File

@@ -1,10 +1,10 @@
use super::*; use super::*;
use crate::test_helpers::seed_user; use crate::test_helpers::seed_user;
use chrono::Utc; use chrono::Utc;
use domain::value_objects::*; use domain::value_objects::*;
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn save_and_find_follow(pool: sqlx::PgPool) { async fn save_and_find_follow(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgFollowRepository::new(pool); let repo = PgFollowRepository::new(pool);
@@ -18,10 +18,10 @@
repo.save(&follow).await.unwrap(); repo.save(&follow).await.unwrap();
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap(); let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
assert_eq!(found.state, FollowState::Accepted); assert_eq!(found.state, FollowState::Accepted);
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn update_state(pool: sqlx::PgPool) { async fn update_state(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgFollowRepository::new(pool); let repo = PgFollowRepository::new(pool);
@@ -38,10 +38,10 @@
.unwrap(); .unwrap();
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap(); let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
assert_eq!(found.state, FollowState::Accepted); assert_eq!(found.state, FollowState::Accepted);
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn get_accepted_following_ids(pool: sqlx::PgPool) { async fn get_accepted_following_ids(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgFollowRepository::new(pool); let repo = PgFollowRepository::new(pool);
@@ -55,4 +55,4 @@
repo.save(&follow).await.unwrap(); repo.save(&follow).await.unwrap();
let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap(); let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap();
assert_eq!(ids, vec![bob.id]); assert_eq!(ids, vec![bob.id]);
} }

View File

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

View File

@@ -1,10 +1,10 @@
use super::*; use super::*;
use crate::test_helpers::seed_user_and_thought; use crate::test_helpers::seed_user_and_thought;
use chrono::Utc; use chrono::Utc;
use domain::value_objects::*; use domain::value_objects::*;
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn like_and_count(pool: sqlx::PgPool) { async fn like_and_count(pool: sqlx::PgPool) {
let (user, thought) = seed_user_and_thought(&pool).await; let (user, thought) = seed_user_and_thought(&pool).await;
let repo = PgLikeRepository::new(pool); let repo = PgLikeRepository::new(pool);
let like = Like { let like = Like {
@@ -16,10 +16,10 @@
}; };
repo.save(&like).await.unwrap(); repo.save(&like).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1); assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn unlike(pool: sqlx::PgPool) { async fn unlike(pool: sqlx::PgPool) {
let (user, thought) = seed_user_and_thought(&pool).await; let (user, thought) = seed_user_and_thought(&pool).await;
let repo = PgLikeRepository::new(pool); let repo = PgLikeRepository::new(pool);
let like = Like { let like = Like {
@@ -32,4 +32,4 @@
repo.save(&like).await.unwrap(); repo.save(&like).await.unwrap();
repo.delete(&user.id, &thought.id).await.unwrap(); repo.delete(&user.id, &thought.id).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0); assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
} }

View File

@@ -1,13 +1,13 @@
use super::*; use super::*;
use crate::test_helpers; use crate::test_helpers;
use chrono::Utc; use chrono::Utc;
use domain::{ use domain::{
models::{notification::NotificationKind, user::User}, models::{notification::NotificationKind, user::User},
value_objects::*, value_objects::*,
}; };
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn save_and_list(pool: sqlx::PgPool) { async fn save_and_list(pool: sqlx::PgPool) {
let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await; 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 from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgNotificationRepository::new(pool); let repo = PgNotificationRepository::new(pool);
@@ -34,10 +34,10 @@
.unwrap(); .unwrap();
assert_eq!(page.total, 1); assert_eq!(page.total, 1);
assert!(!page.items[0].read); assert!(!page.items[0].read);
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn mark_all_read(pool: sqlx::PgPool) { async fn mark_all_read(pool: sqlx::PgPool) {
let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await; 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 from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgNotificationRepository::new(pool); let repo = PgNotificationRepository::new(pool);
@@ -64,4 +64,4 @@
.await .await
.unwrap(); .unwrap();
assert!(page.items[0].read); assert!(page.items[0].read);
} }

View File

@@ -1,25 +1,25 @@
use super::*; use super::*;
use crate::{thought::PgThoughtRepository, user::PgUserRepository}; use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::ports::{ThoughtRepository, UserWriter}; use domain::ports::{ThoughtRepository, UserWriter};
use domain::{ use domain::{
models::{ models::{
thought::{Thought, Visibility}, thought::{NewThought, Thought, Visibility},
user::User, user::User,
}, },
value_objects::*, value_objects::*,
}; };
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn find_or_create_tag(pool: sqlx::PgPool) { async fn find_or_create_tag(pool: sqlx::PgPool) {
let repo = PgTagRepository::new(pool); let repo = PgTagRepository::new(pool);
let t1 = repo.find_or_create("rust").await.unwrap(); let t1 = repo.find_or_create("rust").await.unwrap();
let t2 = 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.id, t2.id);
assert_eq!(t1.name, "rust"); assert_eq!(t1.name, "rust");
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn attach_and_list(pool: sqlx::PgPool) { async fn attach_and_list(pool: sqlx::PgPool) {
let urepo = PgUserRepository::new(pool.clone()); let urepo = PgUserRepository::new(pool.clone());
let trepo = PgThoughtRepository::new(pool.clone()); let trepo = PgThoughtRepository::new(pool.clone());
let u = User::new_local( let u = User::new_local(
@@ -29,15 +29,15 @@
PasswordHash("h".into()), PasswordHash("h".into()),
); );
urepo.save(&u).await.unwrap(); urepo.save(&u).await.unwrap();
let t = Thought::new_local( let t = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
u.id.clone(), user_id: u.id.clone(),
Content::new_local("hi").unwrap(), content: Content::new_local("hi").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
trepo.save(&t).await.unwrap(); trepo.save(&t).await.unwrap();
let repo = PgTagRepository::new(pool); let repo = PgTagRepository::new(pool);
let tag = repo.find_or_create("greetings").await.unwrap(); let tag = repo.find_or_create("greetings").await.unwrap();
@@ -45,4 +45,4 @@
let tags = repo.list_for_thought(&t.id).await.unwrap(); let tags = repo.list_for_thought(&t.id).await.unwrap();
assert_eq!(tags.len(), 1); assert_eq!(tags.len(), 1);
assert_eq!(tags[0].name, "greetings"); assert_eq!(tags[0].name, "greetings");
} }

View File

@@ -1,7 +1,7 @@
use crate::{thought::PgThoughtRepository, user::PgUserRepository}; use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::{ use domain::{
models::{ models::{
thought::{Thought, Visibility}, thought::{NewThought, Thought, Visibility},
user::User, user::User,
}, },
ports::{ThoughtRepository, UserWriter}, ports::{ThoughtRepository, UserWriter},
@@ -23,15 +23,15 @@ pub async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User
pub async fn seed_user_and_thought(pool: &sqlx::PgPool) -> (User, Thought) { pub async fn seed_user_and_thought(pool: &sqlx::PgPool) -> (User, Thought) {
let user = seed_user(pool, "alice", "alice@ex.com").await; let user = seed_user(pool, "alice", "alice@ex.com").await;
let trepo = PgThoughtRepository::new(pool.clone()); let trepo = PgThoughtRepository::new(pool.clone());
let t = Thought::new_local( let t = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
user.id.clone(), user_id: user.id.clone(),
Content::new_local("hi").unwrap(), content: Content::new_local("hi").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
trepo.save(&t).await.unwrap(); trepo.save(&t).await.unwrap();
(user, t) (user, t)
} }

View File

@@ -1,90 +1,90 @@
use super::*; use super::*;
use crate::test_helpers::seed_user; use crate::test_helpers::seed_user;
use domain::{ use domain::{
models::thought::{Thought, Visibility}, models::thought::{NewThought, Thought, Visibility},
value_objects::*, value_objects::*,
}; };
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn save_and_find_thought(pool: sqlx::PgPool) { async fn save_and_find_thought(pool: sqlx::PgPool) {
let user = seed_user(&pool, "alice", "alice@ex.com").await; let user = seed_user(&pool, "alice", "alice@ex.com").await;
let repo = PgThoughtRepository::new(pool); let repo = PgThoughtRepository::new(pool);
let t = Thought::new_local( let t = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
user.id.clone(), user_id: user.id.clone(),
Content::new_local("hello world").unwrap(), content: Content::new_local("hello world").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
repo.save(&t).await.unwrap(); repo.save(&t).await.unwrap();
let found = repo.find_by_id(&t.id).await.unwrap().unwrap(); let found = repo.find_by_id(&t.id).await.unwrap().unwrap();
assert_eq!(found.content.as_str(), "hello world"); assert_eq!(found.content.as_str(), "hello world");
assert!(found.local); assert!(found.local);
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn delete_thought(pool: sqlx::PgPool) { async fn delete_thought(pool: sqlx::PgPool) {
let user = seed_user(&pool, "bob", "bob@ex.com").await; let user = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgThoughtRepository::new(pool); let repo = PgThoughtRepository::new(pool);
let t = Thought::new_local( let t = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
user.id.clone(), user_id: user.id.clone(),
Content::new_local("bye").unwrap(), content: Content::new_local("bye").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
repo.save(&t).await.unwrap(); repo.save(&t).await.unwrap();
repo.delete(&t.id, &user.id).await.unwrap(); repo.delete(&t.id, &user.id).await.unwrap();
assert!(repo.find_by_id(&t.id).await.unwrap().is_none()); assert!(repo.find_by_id(&t.id).await.unwrap().is_none());
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn delete_wrong_owner_returns_not_found(pool: sqlx::PgPool) { async fn delete_wrong_owner_returns_not_found(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgThoughtRepository::new(pool); let repo = PgThoughtRepository::new(pool);
let t = Thought::new_local( let t = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
alice.id.clone(), user_id: alice.id.clone(),
Content::new_local("secret").unwrap(), content: Content::new_local("secret").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
repo.save(&t).await.unwrap(); repo.save(&t).await.unwrap();
let err = repo.delete(&t.id, &bob.id).await.unwrap_err(); let err = repo.delete(&t.id, &bob.id).await.unwrap_err();
assert!(matches!(err, DomainError::NotFound)); assert!(matches!(err, DomainError::NotFound));
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) { async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
let user = seed_user(&pool, "charlie", "charlie@ex.com").await; let user = seed_user(&pool, "charlie", "charlie@ex.com").await;
let repo = PgThoughtRepository::new(pool); let repo = PgThoughtRepository::new(pool);
let root = Thought::new_local( let root = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
user.id.clone(), user_id: user.id.clone(),
Content::new_local("root").unwrap(), content: Content::new_local("root").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
let reply = Thought::new_local( let reply = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
user.id.clone(), user_id: user.id.clone(),
Content::new_local("reply").unwrap(), content: Content::new_local("reply").unwrap(),
Some(root.id.clone()), in_reply_to_id: Some(root.id.clone()),
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
repo.save(&root).await.unwrap(); repo.save(&root).await.unwrap();
repo.save(&reply).await.unwrap(); repo.save(&reply).await.unwrap();
let thread = repo.get_thread(&root.id).await.unwrap(); let thread = repo.get_thread(&root.id).await.unwrap();
assert_eq!(thread.len(), 2); assert_eq!(thread.len(), 2);
} }

View File

@@ -1,9 +1,9 @@
use super::*; use super::*;
use crate::user::PgUserRepository; use crate::user::PgUserRepository;
use domain::ports::UserWriter; use domain::ports::UserWriter;
use domain::{models::user::User, value_objects::*}; use domain::{models::user::User, value_objects::*};
async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User { async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User {
let repo = PgUserRepository::new(pool.clone()); let repo = PgUserRepository::new(pool.clone());
let u = User::new_local( let u = User::new_local(
UserId::new(), UserId::new(),
@@ -13,10 +13,10 @@
); );
repo.save(&u).await.unwrap(); repo.save(&u).await.unwrap();
u u
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn set_and_list_top_friends(pool: sqlx::PgPool) { async fn set_and_list_top_friends(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgTopFriendRepository::new(pool); let repo = PgTopFriendRepository::new(pool);
@@ -27,10 +27,10 @@
assert_eq!(friends.len(), 1); assert_eq!(friends.len(), 1);
assert_eq!(friends[0].0.position, 1); assert_eq!(friends[0].0.position, 1);
assert_eq!(friends[0].1.username.as_str(), "bob"); assert_eq!(friends[0].1.username.as_str(), "bob");
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn replace_top_friends(pool: sqlx::PgPool) { async fn replace_top_friends(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let carol = seed_user(&pool, "carol", "carol@ex.com").await; let carol = seed_user(&pool, "carol", "carol@ex.com").await;
@@ -44,4 +44,4 @@
let friends = repo.list_for_user(&alice.id).await.unwrap(); let friends = repo.list_for_user(&alice.id).await.unwrap();
assert_eq!(friends.len(), 1); assert_eq!(friends.len(), 1);
assert_eq!(friends[0].1.username.as_str(), "carol"); assert_eq!(friends[0].1.username.as_str(), "carol");
} }

View File

@@ -4,7 +4,7 @@ use chrono::{DateTime, Utc};
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::feed::{PageParams, Paginated, UserSummary}, models::feed::{PageParams, Paginated, UserSummary},
models::user::User, models::user::{UpdateProfileInput, User},
ports::{UserReader, UserWriter}, ports::{UserReader, UserWriter},
value_objects::{Email, PasswordHash, UserId, Username}, value_objects::{Email, PasswordHash, UserId, Username},
}; };
@@ -139,7 +139,10 @@ impl UserReader for PgUserRepository {
.into_domain() .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)] #[derive(sqlx::FromRow)]
struct Row { struct Row {
id: uuid::Uuid, id: uuid::Uuid,
@@ -187,7 +190,12 @@ impl UserReader for PgUserRepository {
following_count: r.following_count, following_count: r.following_count,
}) })
.collect(); .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> { 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()); return Ok(HashMap::new());
} }
let uuids: Vec<uuid::Uuid> = ids.iter().map(|id| id.as_uuid()).collect(); let uuids: Vec<uuid::Uuid> = ids.iter().map(|id| id.as_uuid()).collect();
let rows = sqlx::query_as::<_, UserRow>( let rows = sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE id = ANY($1)"))
&format!("{USER_SELECT} WHERE id = ANY($1)")
)
.bind(&uuids[..]) .bind(&uuids[..])
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
Ok(rows.into_iter().map(|r| { Ok(rows
.into_iter()
.map(|r| {
let user = User::from(r); let user = User::from(r);
(user.id.clone(), user) (user.id.clone(), user)
}).collect()) })
.collect())
} }
} }
@@ -256,21 +265,17 @@ impl UserWriter for PgUserRepository {
async fn update_profile( async fn update_profile(
&self, &self,
user_id: &UserId, user_id: &UserId,
display_name: Option<String>, input: UpdateProfileInput,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
sqlx::query( sqlx::query(
"UPDATE users SET display_name=$2,bio=$3,avatar_url=$4,header_url=$5,custom_css=$6,updated_at=NOW() WHERE id=$1" "UPDATE users SET display_name=$2,bio=$3,avatar_url=$4,header_url=$5,custom_css=$6,updated_at=NOW() WHERE id=$1"
) )
.bind(user_id.as_uuid()) .bind(user_id.as_uuid())
.bind(display_name) .bind(input.display_name)
.bind(bio) .bind(input.bio)
.bind(avatar_url) .bind(input.avatar_url)
.bind(header_url) .bind(input.header_url)
.bind(custom_css) .bind(input.custom_css)
.execute(&self.pool) .execute(&self.pool)
.await .await
.into_domain() .into_domain()

View File

@@ -1,8 +1,11 @@
use super::*; use super::*;
use domain::{models::user::User, value_objects::*}; use domain::{
models::user::{UpdateProfileInput, User},
value_objects::*,
};
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn save_and_find_by_id(pool: sqlx::PgPool) { async fn save_and_find_by_id(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool); let repo = PgUserRepository::new(pool);
let user = User::new_local( let user = User::new_local(
UserId::new(), UserId::new(),
@@ -14,20 +17,20 @@
let found = repo.find_by_id(&user.id).await.unwrap().unwrap(); let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
assert_eq!(found.username.as_str(), "alice"); assert_eq!(found.username.as_str(), "alice");
assert_eq!(found.email.as_str(), "alice@ex.com"); assert_eq!(found.email.as_str(), "alice@ex.com");
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn find_by_username_returns_none_when_missing(pool: sqlx::PgPool) { async fn find_by_username_returns_none_when_missing(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool); let repo = PgUserRepository::new(pool);
let result = repo let result = repo
.find_by_username(&Username::new("ghost").unwrap()) .find_by_username(&Username::new("ghost").unwrap())
.await .await
.unwrap(); .unwrap();
assert!(result.is_none()); assert!(result.is_none());
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn find_by_email(pool: sqlx::PgPool) { async fn find_by_email(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool); let repo = PgUserRepository::new(pool);
let user = User::new_local( let user = User::new_local(
UserId::new(), UserId::new(),
@@ -41,10 +44,10 @@
.await .await
.unwrap(); .unwrap();
assert!(found.is_some()); assert!(found.is_some());
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn update_profile_changes_fields(pool: sqlx::PgPool) { async fn update_profile_changes_fields(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool); let repo = PgUserRepository::new(pool);
let user = User::new_local( let user = User::new_local(
UserId::new(), UserId::new(),
@@ -55,15 +58,15 @@
repo.save(&user).await.unwrap(); repo.save(&user).await.unwrap();
repo.update_profile( repo.update_profile(
&user.id, &user.id,
Some("Charlie".into()), UpdateProfileInput {
Some("bio".into()), display_name: Some("Charlie".into()),
None, bio: Some("bio".into()),
None, ..Default::default()
None, },
) )
.await .await
.unwrap(); .unwrap();
let found = repo.find_by_id(&user.id).await.unwrap().unwrap(); let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
assert_eq!(found.display_name.as_deref(), Some("Charlie")); assert_eq!(found.display_name.as_deref(), Some("Charlie"));
assert_eq!(found.bio.as_deref(), Some("bio")); assert_eq!(found.bio.as_deref(), Some("bio"));
} }

View File

@@ -1,11 +1,11 @@
use super::*; use super::*;
use crate::testing::TestApRepo;
use activitypub_base::{ActorApUrls, OutboundFederationPort}; use activitypub_base::{ActorApUrls, OutboundFederationPort};
use async_trait::async_trait; use async_trait::async_trait;
use crate::testing::TestApRepo;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::thought::{Thought, Visibility}, models::thought::{NewThought, Thought, Visibility},
models::user::User, models::user::User,
testing::TestStore, testing::TestStore,
value_objects::*, value_objects::*,
@@ -56,21 +56,12 @@ impl OutboundFederationPort for SpyPort {
self.announced.lock().unwrap().push(ap_id.to_string()); self.announced.lock().unwrap().push(ap_id.to_string());
Ok(()) Ok(())
} }
async fn broadcast_undo_announce( async fn broadcast_undo_announce(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> {
&self,
_: &UserId,
ap_id: &str,
) -> Result<(), DomainError> {
self.undo_announced.lock().unwrap().push(ap_id.to_string()); self.undo_announced.lock().unwrap().push(ap_id.to_string());
Ok(()) Ok(())
} }
async fn broadcast_like( async fn broadcast_like(&self, _: &UserId, ap_id: &str, _: &str) -> Result<(), DomainError> {
&self,
_: &UserId,
ap_id: &str,
_: &str,
) -> Result<(), DomainError> {
self.liked.lock().unwrap().push(ap_id.to_string()); self.liked.lock().unwrap().push(ap_id.to_string());
Ok(()) Ok(())
} }
@@ -101,15 +92,15 @@ fn alice() -> User {
} }
fn local_thought(author_id: UserId) -> Thought { fn local_thought(author_id: UserId) -> Thought {
Thought::new_local( Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
author_id, user_id: author_id,
Content::new_local("hello").unwrap(), content: Content::new_local("hello").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
) })
} }
fn svc(store: &TestStore, spy: Arc<SpyPort>) -> FederationEventService { fn svc(store: &TestStore, spy: Arc<SpyPort>) -> FederationEventService {
@@ -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 { FederationEventService {
thoughts: Arc::new(store.clone()), thoughts: Arc::new(store.clone()),
users: Arc::new(store.clone()), users: Arc::new(store.clone()),
@@ -280,15 +275,15 @@ async fn boost_of_remote_thought_announces_remote_ap_id() {
async fn direct_thought_created_does_not_broadcast() { async fn direct_thought_created_does_not_broadcast() {
let store = TestStore::default(); let store = TestStore::default();
let alice = alice(); let alice = alice();
let thought = Thought::new_local( let thought = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
alice.id.clone(), user_id: alice.id.clone(),
Content::new_local("private").unwrap(), content: Content::new_local("private").unwrap(),
None, in_reply_to_id: None,
Visibility::Direct, visibility: Visibility::Direct,
None, content_warning: None,
false, sensitive: false,
); });
store.users.lock().unwrap().push(alice.clone()); store.users.lock().unwrap().push(alice.clone());
store.thoughts.lock().unwrap().push(thought.clone()); store.thoughts.lock().unwrap().push(thought.clone());
@@ -309,15 +304,15 @@ async fn direct_thought_created_does_not_broadcast() {
async fn followers_only_thought_does_not_broadcast_publicly() { async fn followers_only_thought_does_not_broadcast_publicly() {
let store = TestStore::default(); let store = TestStore::default();
let alice = alice(); let alice = alice();
let thought = Thought::new_local( let thought = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
alice.id.clone(), user_id: alice.id.clone(),
Content::new_local("for followers").unwrap(), content: Content::new_local("for followers").unwrap(),
None, in_reply_to_id: None,
Visibility::Followers, visibility: Visibility::Followers,
None, content_warning: None,
false, sensitive: false,
); });
store.users.lock().unwrap().push(alice.clone()); store.users.lock().unwrap().push(alice.clone());
store.thoughts.lock().unwrap().push(thought.clone()); store.thoughts.lock().unwrap().push(thought.clone());

View File

@@ -2,7 +2,7 @@ use super::*;
use domain::{ use domain::{
models::{ models::{
notification::NotificationKind, notification::NotificationKind,
thought::{Thought, Visibility}, thought::{NewThought, Thought, Visibility},
user::User, user::User,
}, },
testing::TestStore, testing::TestStore,
@@ -24,15 +24,15 @@ async fn like_creates_notification_for_thought_author() {
let store = TestStore::default(); let store = TestStore::default();
let alice = alice(); let alice = alice();
let bob_id = UserId::new(); let bob_id = UserId::new();
let thought = Thought::new_local( let thought = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
alice.id.clone(), user_id: alice.id.clone(),
Content::new_local("hello").unwrap(), content: Content::new_local("hello").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
store.thoughts.lock().unwrap().push(thought.clone()); store.thoughts.lock().unwrap().push(thought.clone());
let svc = NotificationEventService { let svc = NotificationEventService {
thoughts: Arc::new(store.clone()), thoughts: Arc::new(store.clone()),
@@ -54,15 +54,15 @@ async fn like_creates_notification_for_thought_author() {
async fn self_like_creates_no_notification() { async fn self_like_creates_no_notification() {
let store = TestStore::default(); let store = TestStore::default();
let alice = alice(); let alice = alice();
let thought = Thought::new_local( let thought = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
alice.id.clone(), user_id: alice.id.clone(),
Content::new_local("hello").unwrap(), content: Content::new_local("hello").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
store.thoughts.lock().unwrap().push(thought.clone()); store.thoughts.lock().unwrap().push(thought.clone());
let svc = NotificationEventService { let svc = NotificationEventService {
thoughts: Arc::new(store.clone()), thoughts: Arc::new(store.clone()),
@@ -103,15 +103,15 @@ async fn reply_creates_notification_for_original_author() {
let store = TestStore::default(); let store = TestStore::default();
let alice = alice(); let alice = alice();
let bob_id = UserId::new(); let bob_id = UserId::new();
let original = Thought::new_local( let original = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
alice.id.clone(), user_id: alice.id.clone(),
Content::new_local("original").unwrap(), content: Content::new_local("original").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
store.thoughts.lock().unwrap().push(original.clone()); store.thoughts.lock().unwrap().push(original.clone());
let svc = NotificationEventService { let svc = NotificationEventService {
thoughts: Arc::new(store.clone()), thoughts: Arc::new(store.clone()),
@@ -133,15 +133,15 @@ async fn reply_creates_notification_for_original_author() {
async fn self_reply_creates_no_notification() { async fn self_reply_creates_no_notification() {
let store = TestStore::default(); let store = TestStore::default();
let alice = alice(); let alice = alice();
let original = Thought::new_local( let original = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
alice.id.clone(), user_id: alice.id.clone(),
Content::new_local("original").unwrap(), content: Content::new_local("original").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
store.thoughts.lock().unwrap().push(original.clone()); store.thoughts.lock().unwrap().push(original.clone());
let svc = NotificationEventService { let svc = NotificationEventService {
thoughts: Arc::new(store.clone()), thoughts: Arc::new(store.clone()),
@@ -161,15 +161,15 @@ async fn self_reply_creates_no_notification() {
async fn self_boost_creates_no_notification() { async fn self_boost_creates_no_notification() {
let store = TestStore::default(); let store = TestStore::default();
let alice = alice(); let alice = alice();
let thought = Thought::new_local( let thought = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
alice.id.clone(), user_id: alice.id.clone(),
Content::new_local("hello").unwrap(), content: Content::new_local("hello").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
store.thoughts.lock().unwrap().push(thought.clone()); store.thoughts.lock().unwrap().push(thought.clone());
let svc = NotificationEventService { let svc = NotificationEventService {
thoughts: Arc::new(store.clone()), thoughts: Arc::new(store.clone()),

View File

@@ -95,22 +95,11 @@ impl ActivityPubRepository for TestApRepo {
} }
async fn accept_note( async fn accept_note(
&self, &self,
_ap_id: &str, _input: activitypub_base::AcceptNoteInput<'_>,
_author_id: &UserId,
_content: &str,
_published: chrono::DateTime<chrono::Utc>,
_sensitive: bool,
_content_warning: Option<String>,
_visibility: &str,
_in_reply_to: Option<&str>,
) -> Result<ThoughtId, DomainError> { ) -> Result<ThoughtId, DomainError> {
Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4())) Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4()))
} }
async fn apply_note_update( async fn apply_note_update(&self, _ap_id: &str, _new_content: &str) -> Result<(), DomainError> {
&self,
_ap_id: &str,
_new_content: &str,
) -> Result<(), DomainError> {
Ok(()) Ok(())
} }
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> { async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {

View File

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

View File

@@ -3,7 +3,10 @@ use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::{feed::{PageParams, Paginated, UserSummary}, user::User}, models::{
feed::{PageParams, Paginated, UserSummary},
user::{UpdateProfileInput, User},
},
ports::{AuthService, GeneratedToken, PasswordHasher, UserReader, UserWriter}, ports::{AuthService, GeneratedToken, PasswordHasher, UserReader, UserWriter},
testing::{NoOpEventPublisher, TestStore}, testing::{NoOpEventPublisher, TestStore},
value_objects::{Email, PasswordHash, UserId, Username}, 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> { async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
self.0.find_by_id(id).await self.0.find_by_id(id).await
} }
async fn find_by_username( async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
&self,
username: &Username,
) -> Result<Option<User>, DomainError> {
self.0.find_by_username(username).await self.0.find_by_username(username).await
} }
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> { 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> { async fn count(&self) -> Result<i64, DomainError> {
self.0.count().await 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 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 self.0.find_by_ids(ids).await
} }
} }
@@ -50,15 +56,9 @@ impl UserWriter for ConflictOnSaveStore {
async fn update_profile( async fn update_profile(
&self, &self,
user_id: &UserId, user_id: &UserId,
display_name: Option<String>, input: UpdateProfileInput,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
self.0 self.0.update_profile(user_id, input).await
.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css)
.await
} }
} }
@@ -67,10 +67,7 @@ impl UserReader for EmailConflictOnSaveStore {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> { async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
self.0.find_by_id(id).await self.0.find_by_id(id).await
} }
async fn find_by_username( async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
&self,
username: &Username,
) -> Result<Option<User>, DomainError> {
self.0.find_by_username(username).await self.0.find_by_username(username).await
} }
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> { async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
@@ -82,10 +79,16 @@ impl UserReader for EmailConflictOnSaveStore {
async fn count(&self) -> Result<i64, DomainError> { async fn count(&self) -> Result<i64, DomainError> {
self.0.count().await 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 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 self.0.find_by_ids(ids).await
} }
} }
@@ -98,15 +101,9 @@ impl UserWriter for EmailConflictOnSaveStore {
async fn update_profile( async fn update_profile(
&self, &self,
user_id: &UserId, user_id: &UserId,
display_name: Option<String>, input: UpdateProfileInput,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
self.0 self.0.update_profile(user_id, input).await
.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css)
.await
} }
} }

View File

@@ -7,9 +7,9 @@ use domain::{
remote_actor::RemoteActor, remote_actor::RemoteActor,
}, },
ports::{ ports::{
EventPublisher, FederationActionPort, FederationFollowPort, EventPublisher, FederationActionPort, FederationFollowPort, FederationFollowRequestPort,
FederationFollowRequestPort, FederationSchedulerPort, FeedQuery, FeedRepository, FederationSchedulerPort, FeedQuery, FeedRepository, FollowRepository,
FollowRepository, RemoteActorConnectionRepository, UserReader, RemoteActorConnectionRepository, UserReader,
}, },
value_objects::UserId, value_objects::UserId,
}; };
@@ -86,7 +86,13 @@ pub async fn get_remote_actor_posts(
Some(id) => id, Some(id) => id,
None => ap_repo.intern_remote_actor(&actor.url).await?, 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 { if let Some(outbox_url) = actor.outbox_url {
let _ = scheduler let _ = scheduler
.schedule_actor_posts_fetch(&actor.url, &outbox_url) .schedule_actor_posts_fetch(&actor.url, &outbox_url)

View File

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

View File

@@ -3,7 +3,10 @@ const MAX_TOP_FRIENDS: usize = 8;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::{top_friend::TopFriend, user::User}, models::{
top_friend::TopFriend,
user::{UpdateProfileInput, User},
},
ports::{EventPublisher, TopFriendRepository, UserReader, UserWriter}, ports::{EventPublisher, TopFriendRepository, UserReader, UserWriter},
value_objects::{UserId, Username}, value_objects::{UserId, Username},
}; };
@@ -41,27 +44,13 @@ pub async fn get_user_by_id_or_username(
} }
} }
#[allow(clippy::too_many_arguments)]
pub async fn update_profile( pub async fn update_profile(
users: &dyn UserWriter, users: &dyn UserWriter,
events: &dyn EventPublisher, events: &dyn EventPublisher,
user_id: &UserId, user_id: &UserId,
display_name: Option<String>, input: UpdateProfileInput,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
users users.update_profile(user_id, input).await?;
.update_profile(
user_id,
display_name,
bio,
avatar_url,
header_url,
custom_css,
)
.await?;
events events
.publish(&DomainEvent::ProfileUpdated { .publish(&DomainEvent::ProfileUpdated {
user_id: user_id.clone(), user_id: user_id.clone(),

View File

@@ -1,7 +1,7 @@
use super::*; use super::*;
use domain::{ use domain::{
models::{ models::{
thought::{Thought, Visibility}, thought::{NewThought, Thought, Visibility},
user::User, user::User,
}, },
testing::TestStore, testing::TestStore,
@@ -22,15 +22,19 @@ async fn like_and_unlike() {
let store = TestStore::default(); let store = TestStore::default();
let alice = user("alice"); let alice = user("alice");
let tid = ThoughtId::new(); let tid = ThoughtId::new();
store.thoughts.lock().unwrap().push(Thought::new_local( store
tid.clone(), .thoughts
alice.id.clone(), .lock()
Content::new_local("hi").unwrap(), .unwrap()
None, .push(Thought::new_local(NewThought {
Visibility::Public, id: tid.clone(),
None, user_id: alice.id.clone(),
false, content: Content::new_local("hi").unwrap(),
)); in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
}));
like_thought(&store, &store, &alice.id, &tid).await.unwrap(); like_thought(&store, &store, &alice.id, &tid).await.unwrap();
assert_eq!(store.likes.lock().unwrap().len(), 1); assert_eq!(store.likes.lock().unwrap().len(), 1);
unlike_thought(&store, &store, &alice.id, &tid) unlike_thought(&store, &store, &alice.id, &tid)

View File

@@ -3,9 +3,12 @@ use domain::{
events::DomainEvent, events::DomainEvent,
models::{ models::{
feed::{EngagementStats, FeedEntry}, feed::{EngagementStats, FeedEntry},
thought::{Thought, Visibility}, thought::{NewThought, Thought, Visibility},
},
ports::{
EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository,
UserReader,
}, },
ports::{EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserReader},
value_objects::{Content, ThoughtId, UserId}, value_objects::{Content, ThoughtId, UserId},
}; };
@@ -43,15 +46,15 @@ pub async fn create_thought(
Some("direct") => Visibility::Direct, Some("direct") => Visibility::Direct,
_ => Visibility::Public, _ => Visibility::Public,
}; };
let thought = Thought::new_local( let thought = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
input.user_id, user_id: input.user_id,
content.clone(), content: content.clone(),
input.in_reply_to_id.clone(), in_reply_to_id: input.in_reply_to_id.clone(),
visibility, visibility,
input.content_warning, content_warning: input.content_warning,
input.sensitive, sensitive: input.sensitive,
); });
thoughts.save(&thought).await?; thoughts.save(&thought).await?;
// Extract and attach hashtags from content. // Extract and attach hashtags from content.
@@ -132,11 +135,23 @@ pub async fn get_thought_view(
.find_by_id(&thought.user_id) .find_by_id(&thought.user_id)
.await? .await?
.ok_or(DomainError::NotFound)?; .ok_or(DomainError::NotFound)?;
let mut map = engagement.get_for_thoughts(&[id.clone()], viewer).await?; let mut map = engagement
let (stats, viewer_ctx) = map.remove(id).unwrap_or( .get_for_thoughts(std::slice::from_ref(id), viewer)
(EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None) .await?;
); let (stats, viewer_ctx) = map.remove(id).unwrap_or((
Ok(FeedEntry { thought, author, stats, viewer: viewer_ctx }) 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. /// Fetches a thread (root + replies) enriched with authors + real engagement stats.
@@ -169,10 +184,20 @@ pub async fn get_thread_views(
.get(&thought.user_id) .get(&thought.user_id)
.cloned() .cloned()
.ok_or(DomainError::NotFound)?; .ok_or(DomainError::NotFound)?;
let (stats, viewer_ctx) = engagement_map.remove(&thought.id).unwrap_or( let (stats, viewer_ctx) = engagement_map.remove(&thought.id).unwrap_or((
(EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None) EngagementStats {
); like_count: 0,
entries.push(FeedEntry { thought, author, stats, viewer: viewer_ctx }); boost_count: 0,
reply_count: 0,
},
None,
));
entries.push(FeedEntry {
thought,
author,
stats,
viewer: viewer_ctx,
});
} }
Ok(entries) Ok(entries)
} }

View File

@@ -31,7 +31,14 @@ async fn create_thought_saves_and_stages_outbox_event() {
let outbox = TestOutbox::default(); let outbox = TestOutbox::default();
let u = user(); let u = user();
store.users.lock().unwrap().push(u.clone()); store.users.lock().unwrap().push(u.clone());
let out = create_thought(&store, &store, &store, &NoOpEventPublisher, &outbox, input(u.id.clone())) let out = create_thought(
&store,
&store,
&store,
&NoOpEventPublisher,
&outbox,
input(u.id.clone()),
)
.await .await
.unwrap(); .unwrap();
assert_eq!(out.thought.content.as_str(), "hello"); assert_eq!(out.thought.content.as_str(), "hello");
@@ -64,7 +71,9 @@ async fn delete_thought_stages_outbox_event() {
let staged = outbox.staged(); let staged = outbox.staged();
assert_eq!(staged.len(), 1); 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] #[tokio::test]
@@ -82,7 +91,13 @@ async fn delete_own_thought_succeeds() {
) )
.await .await
.unwrap(); .unwrap();
delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &out.thought.id, &u.id) delete_thought(
&store,
&NoOpEventPublisher,
&NoOpOutboxWriter,
&out.thought.id,
&u.id,
)
.await .await
.unwrap(); .unwrap();
assert!(store.thoughts.lock().unwrap().is_empty()); assert!(store.thoughts.lock().unwrap().is_empty());
@@ -113,7 +128,13 @@ async fn delete_other_thought_returns_not_found() {
) )
.await .await
.unwrap(); .unwrap();
let err = delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &out.thought.id, &bob.id) let err = delete_thought(
&store,
&NoOpEventPublisher,
&NoOpOutboxWriter,
&out.thought.id,
&bob.id,
)
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(err, DomainError::NotFound)); assert!(matches!(err, DomainError::NotFound));
@@ -124,7 +145,14 @@ async fn edit_thought_changes_content_and_emits_event() {
let store = TestStore::default(); let store = TestStore::default();
let alice = user(); let alice = user();
store.users.lock().unwrap().push(alice.clone()); store.users.lock().unwrap().push(alice.clone());
let out = create_thought(&store, &store, &store, &NoOpEventPublisher, &NoOpOutboxWriter, input(alice.id.clone())) let out = create_thought(
&store,
&store,
&store,
&NoOpEventPublisher,
&NoOpOutboxWriter,
input(alice.id.clone()),
)
.await .await
.unwrap(); .unwrap();
let tid = out.thought.id.clone(); let tid = out.thought.id.clone();
@@ -194,7 +222,7 @@ async fn create_reply_sets_in_reply_to_id() {
// enrichment_tests (combined from second cfg(test) block) // enrichment_tests (combined from second cfg(test) block)
use domain::models::thought::{Thought, Visibility}; use domain::models::thought::{NewThought, Thought, Visibility};
use domain::ports::{ThoughtRepository, UserWriter}; use domain::ports::{ThoughtRepository, UserWriter};
fn make_user() -> User { fn make_user() -> User {
@@ -207,24 +235,28 @@ fn make_user() -> User {
} }
fn make_thought(user_id: UserId) -> Thought { fn make_thought(user_id: UserId) -> Thought {
Thought::new_local( Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
user_id, user_id,
Content::new_local(String::from("hello")).unwrap(), content: Content::new_local(String::from("hello")).unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
) })
} }
#[tokio::test] #[tokio::test]
async fn get_thought_view_returns_feed_entry() { async fn get_thought_view_returns_feed_entry() {
let store = TestStore::default(); let store = TestStore::default();
let user = make_user(); 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()); 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) let entry = get_thought_view(&store, &store, &store, &thought.id, None)
.await .await
@@ -248,19 +280,25 @@ async fn get_thought_view_returns_not_found_for_missing_thought() {
async fn get_thread_views_batches_correctly() { async fn get_thread_views_batches_correctly() {
let store = TestStore::default(); let store = TestStore::default();
let user = make_user(); 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()); let root = make_thought(user.id.clone());
<TestStore as ThoughtRepository>::save(&store, &root).await.unwrap(); <TestStore as ThoughtRepository>::save(&store, &root)
let reply = Thought::new_local( .await
ThoughtId::new(), .unwrap();
user.id.clone(), let reply = Thought::new_local(NewThought {
Content::new_local(String::from("reply")).unwrap(), id: ThoughtId::new(),
Some(root.id.clone()), user_id: user.id.clone(),
Visibility::Public, content: Content::new_local(String::from("reply")).unwrap(),
None, in_reply_to_id: Some(root.id.clone()),
false, visibility: Visibility::Public,
); content_warning: None,
<TestStore as ThoughtRepository>::save(&store, &reply).await.unwrap(); sensitive: false,
});
<TestStore as ThoughtRepository>::save(&store, &reply)
.await
.unwrap();
let entries = get_thread_views(&store, &store, &store, &root.id, None) let entries = get_thread_views(&store, &store, &store, &root.id, None)
.await .await

View File

@@ -8,7 +8,11 @@ use std::sync::Arc;
use activitypub::ThoughtsObjectHandler; use activitypub::ThoughtsObjectHandler;
use activitypub_base::service::ActivityPubService; use activitypub_base::service::ActivityPubService;
use auth::ApiKeyServiceImpl; 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 event_transport::EventPublisherAdapter;
use nats::NatsTransport; use nats::NatsTransport;
use postgres::activitypub::PgActivityPubRepository; use postgres::activitypub::PgActivityPubRepository;
@@ -69,7 +73,7 @@ pub async fn build(cfg: &Config) -> Infrastructure {
// 3. ActivityPub federation // 3. ActivityPub federation
let ap_service = Arc::new( let ap_service = Arc::new(
ActivityPubService::new( ActivityPubService::builder(
Arc::new(PostgresFederationRepository::new(pool.clone())), Arc::new(PostgresFederationRepository::new(pool.clone())),
Arc::new(PostgresApUserRepository::new( Arc::new(PostgresApUserRepository::new(
pool.clone(), pool.clone(),
@@ -82,11 +86,12 @@ pub async fn build(cfg: &Config) -> Infrastructure {
Arc::new(postgres::tag::PgTagRepository::new(pool.clone())), Arc::new(postgres::tag::PgTagRepository::new(pool.clone())),
)), )),
cfg.base_url.clone(), cfg.base_url.clone(),
cfg.allow_registration, Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())),
"thoughts".to_string(),
cfg.debug,
None,
) )
.allow_registration(cfg.allow_registration)
.software_name("thoughts")
.debug(cfg.debug)
.build()
.await .await
.expect("Failed to build ActivityPubService"), .expect("Failed to build ActivityPubService"),
); );
@@ -129,9 +134,9 @@ pub async fn build(cfg: &Config) -> Infrastructure {
ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())), ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),
remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())), remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())),
federation_scheduler: ap_service.clone() as Arc<dyn domain::ports::FederationSchedulerPort>, federation_scheduler: ap_service.clone() as Arc<dyn domain::ports::FederationSchedulerPort>,
api_key_auth: Arc::new(ApiKeyServiceImpl::new( api_key_auth: Arc::new(ApiKeyServiceImpl::new(Arc::new(
Arc::new(postgres::api_key::PgApiKeyRepository::new(pool.clone())), postgres::api_key::PgApiKeyRepository::new(pool.clone()),
)), ))),
engagement: Arc::new(PgEngagementRepository::new(pool.clone())), engagement: Arc::new(PgEngagementRepository::new(pool.clone())),
}; };

View File

@@ -46,24 +46,26 @@ impl Visibility {
} }
} }
pub struct NewThought {
pub id: ThoughtId,
pub user_id: UserId,
pub content: Content,
pub in_reply_to_id: Option<ThoughtId>,
pub visibility: Visibility,
pub content_warning: Option<String>,
pub sensitive: bool,
}
impl Thought { impl Thought {
pub fn new_local( pub fn new_local(p: NewThought) -> Self {
id: ThoughtId,
user_id: UserId,
content: Content,
in_reply_to_id: Option<ThoughtId>,
visibility: Visibility,
content_warning: Option<String>,
sensitive: bool,
) -> Self {
Self { Self {
id, id: p.id,
user_id, user_id: p.user_id,
content, content: p.content,
in_reply_to_id, in_reply_to_id: p.in_reply_to_id,
visibility, visibility: p.visibility,
content_warning, content_warning: p.content_warning,
sensitive, sensitive: p.sensitive,
local: true, local: true,
created_at: Utc::now(), created_at: Utc::now(),
updated_at: None, updated_at: None,

View File

@@ -1,6 +1,15 @@
use crate::value_objects::{Email, PasswordHash, UserId, Username}; use crate::value_objects::{Email, PasswordHash, UserId, Username};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
#[derive(Debug, Default, Clone)]
pub struct UpdateProfileInput {
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub header_url: Option<String>,
pub custom_css: Option<String>,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct User { pub struct User {
pub id: UserId, pub id: UserId,

View File

@@ -12,7 +12,7 @@ use crate::{
tag::Tag, tag::Tag,
thought::Thought, thought::Thought,
top_friend::TopFriend, top_friend::TopFriend,
user::User, user::{UpdateProfileInput, User},
}, },
value_objects::{ value_objects::{
ApiKeyId, Content, Email, NotificationId, PasswordHash, ThoughtId, UserId, Username, ApiKeyId, Content, Email, NotificationId, PasswordHash, ThoughtId, UserId, Username,
@@ -58,7 +58,8 @@ pub trait UserReader: Send + Sync {
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>; async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError>; async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError>;
async fn count(&self) -> Result<i64, 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>; async fn find_by_ids(&self, ids: &[UserId]) -> Result<HashMap<UserId, User>, DomainError>;
} }
@@ -68,11 +69,7 @@ pub trait UserWriter: Send + Sync {
async fn update_profile( async fn update_profile(
&self, &self,
user_id: &UserId, user_id: &UserId,
display_name: Option<String>, input: UpdateProfileInput,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
) -> Result<(), DomainError>; ) -> Result<(), DomainError>;
} }
@@ -353,19 +350,43 @@ pub struct FeedQuery {
impl FeedQuery { impl FeedQuery {
pub fn home(viewer_id: UserId, following_ids: Vec<UserId>, page: PageParams) -> Self { 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 { 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 { 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 { 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 { 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 +413,6 @@ pub trait SearchPort: Send + Sync {
) -> Result<Paginated<User>, DomainError>; ) -> Result<Paginated<User>, DomainError>;
} }
#[async_trait] #[async_trait]
pub trait FederationSchedulerPort: Send + Sync { pub trait FederationSchedulerPort: Send + Sync {
async fn schedule_actor_posts_fetch( async fn schedule_actor_posts_fetch(

View File

@@ -10,7 +10,7 @@ use crate::{
tag::Tag, tag::Tag,
thought::Thought, thought::Thought,
top_friend::TopFriend, top_friend::TopFriend,
user::User, user::{UpdateProfileInput, User},
}, },
ports::*, ports::*,
value_objects::{ value_objects::{
@@ -83,17 +83,30 @@ impl UserReader for TestStore {
.count() as i64) .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 all = self.list_with_stats().await?;
let total = all.len() as i64; let total = all.len() as i64;
let start = page.offset() as usize; let start = page.offset() as usize;
let items: Vec<UserSummary> = all.into_iter().skip(start).take(page.limit() as usize).collect(); let items: Vec<UserSummary> = all
Ok(Paginated { items, total, page: page.page, per_page: page.per_page }) .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> { async fn find_by_ids(&self, ids: &[UserId]) -> Result<HashMap<UserId, User>, DomainError> {
let g = self.users.lock().unwrap(); let g = self.users.lock().unwrap();
let map = g.iter() let map = g
.iter()
.filter(|u| ids.contains(&u.id)) .filter(|u| ids.contains(&u.id))
.map(|u| (u.id.clone(), u.clone())) .map(|u| (u.id.clone(), u.clone()))
.collect(); .collect();
@@ -112,11 +125,7 @@ impl UserWriter for TestStore {
async fn update_profile( async fn update_profile(
&self, &self,
user_id: &UserId, user_id: &UserId,
display_name: Option<String>, input: UpdateProfileInput,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
if let Some(u) = self if let Some(u) = self
.users .users
@@ -125,11 +134,11 @@ impl UserWriter for TestStore {
.iter_mut() .iter_mut()
.find(|u| &u.id == user_id) .find(|u| &u.id == user_id)
{ {
u.display_name = display_name; u.display_name = input.display_name;
u.bio = bio; u.bio = input.bio;
u.avatar_url = avatar_url; u.avatar_url = input.avatar_url;
u.header_url = header_url; u.header_url = input.header_url;
u.custom_css = custom_css; u.custom_css = input.custom_css;
} }
Ok(()) Ok(())
} }
@@ -294,7 +303,16 @@ impl EngagementRepository for TestStore {
&self, &self,
thought_ids: &[ThoughtId], thought_ids: &[ThoughtId],
viewer_id: Option<&UserId>, 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}; use crate::models::feed::{EngagementStats, ViewerContext};
let likes = self.likes.lock().unwrap(); let likes = self.likes.lock().unwrap();
let boosts = self.boosts.lock().unwrap(); let boosts = self.boosts.lock().unwrap();
@@ -304,12 +322,29 @@ impl EngagementRepository for TestStore {
for tid in thought_ids { for tid in thought_ids {
let like_count = likes.iter().filter(|l| &l.thought_id == tid).count() as i64; 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 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 { let viewer = viewer_id.map(|vid| ViewerContext {
liked: likes.iter().any(|l| &l.thought_id == tid && &l.user_id == vid), liked: likes
boosted: boosts.iter().any(|b| &b.thought_id == tid && &b.user_id == vid), .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) Ok(result)
} }
@@ -763,7 +798,10 @@ impl RemoteActorConnectionRepository for TestStore {
#[async_trait] #[async_trait]
impl FeedRepository for TestStore { 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 { Ok(Paginated {
items: vec![], items: vec![],
total: 0, total: 0,

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,10 @@ use axum::{
}; };
use domain::{ use domain::{
models::feed::PageParams, models::feed::PageParams,
ports::{FederationActionPort, FeedQuery, FeedRepository, FollowRepository, SearchPort, TagRepository, UserRepository}, ports::{
FederationActionPort, FeedQuery, FeedRepository, FollowRepository, SearchPort,
TagRepository, UserRepository,
},
}; };
deps_struct!(FeedDeps { deps_struct!(FeedDeps {
@@ -224,7 +227,10 @@ pub async fn user_thoughts_handler(
page: q.page(), page: q.page(),
per_page: q.per_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!({ Ok(Json(serde_json::json!({
"total": result.total, "total": result.total,
"page": result.page, "page": result.page,
@@ -241,7 +247,10 @@ pub async fn get_popular_tags(
.get("limit") .get("limit")
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(api_types::requests::DEFAULT_PER_PAGE as usize); .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!({ Ok(Json(serde_json::json!({
"tags": tags.iter().map(|(name, count)| serde_json::json!({ "tags": tags.iter().map(|(name, count)| serde_json::json!({
"name": name, "name": name,
@@ -268,7 +277,10 @@ pub async fn tag_thoughts_handler(
page: q.page(), page: q.page(),
per_page: q.per_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!({ Ok(Json(serde_json::json!({
"tag": tag_name, "tag": tag_name,
"total": result.total, "total": result.total,

View File

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

View File

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

View File

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

View File

@@ -17,8 +17,9 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Json, Json,
}; };
use domain::ports::{ use domain::{
EventPublisher, FederationActionPort, FollowRepository, SearchPort, UserRepository, models::user::UpdateProfileInput,
ports::{EventPublisher, FederationActionPort, FollowRepository, SearchPort, UserRepository},
}; };
use std::sync::Arc; use std::sync::Arc;
@@ -96,11 +97,13 @@ pub async fn patch_profile(
&*d.users, &*d.users,
&*d.events, &*d.events,
&uid, &uid,
body.display_name, UpdateProfileInput {
body.bio, display_name: body.display_name,
body.avatar_url, bio: body.bio,
body.header_url, avatar_url: body.avatar_url,
body.custom_css, header_url: body.header_url,
custom_css: body.custom_css,
},
) )
.await?; .await?;
let user = fetch_user(&*d.users, &uid).await?; let user = fetch_user(&*d.users, &uid).await?;
@@ -191,9 +194,7 @@ pub async fn get_users(
}))) })))
} }
pub async fn get_user_count( pub async fn get_user_count(Deps(d): Deps<UsersDeps>) -> Result<Json<serde_json::Value>, ApiError> {
Deps(d): Deps<UsersDeps>,
) -> Result<Json<serde_json::Value>, ApiError> {
let count = d.users.count().await?; let count = d.users.count().await?;
Ok(Json(serde_json::json!({ "count": count }))) Ok(Json(serde_json::json!({ "count": count })))
} }

View File

@@ -68,22 +68,11 @@ impl ActivityPubRepository for NoOpApRepo {
} }
async fn accept_note( async fn accept_note(
&self, &self,
_ap_id: &str, _input: activitypub_base::AcceptNoteInput<'_>,
_author_id: &UserId,
_content: &str,
_published: chrono::DateTime<chrono::Utc>,
_sensitive: bool,
_content_warning: Option<String>,
_visibility: &str,
_in_reply_to: Option<&str>,
) -> Result<ThoughtId, DomainError> { ) -> Result<ThoughtId, DomainError> {
Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4())) Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4()))
} }
async fn apply_note_update( async fn apply_note_update(&self, _ap_id: &str, _new_content: &str) -> Result<(), DomainError> {
&self,
_ap_id: &str,
_new_content: &str,
) -> Result<(), DomainError> {
Ok(()) Ok(())
} }
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> { async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {

View File

@@ -1,11 +1,12 @@
use postgres::failed_event::PgFailedEventStore; use postgres::failed_event::PgFailedEventStore;
use postgres::remote_actor_connections::PgRemoteActorConnectionRepository;
use sqlx::PgPool; use sqlx::PgPool;
use std::sync::Arc; use std::sync::Arc;
use activitypub::ThoughtsObjectHandler; use activitypub::ThoughtsObjectHandler;
use activitypub_base::ActivityPubService; use activitypub_base::ActivityPubService;
use application::services::{FederationEventService, NotificationEventService};
use activitypub_base::{ActivityPubRepository, OutboundFederationPort}; use activitypub_base::{ActivityPubRepository, OutboundFederationPort};
use application::services::{FederationEventService, NotificationEventService};
use domain::ports::EventPublisher; use domain::ports::EventPublisher;
use postgres::activitypub::PgActivityPubRepository; use postgres::activitypub::PgActivityPubRepository;
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository}; use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};
@@ -39,7 +40,7 @@ pub async fn build(database_url: &str, base_url: &str, nats_url: &str) -> Worker
// ActivityPub service (for federation fan-out) // ActivityPub service (for federation fan-out)
let ap_service = Arc::new( let ap_service = Arc::new(
ActivityPubService::new( ActivityPubService::builder(
Arc::new(PostgresFederationRepository::new(pool.clone())), Arc::new(PostgresFederationRepository::new(pool.clone())),
Arc::new(PostgresApUserRepository::new( Arc::new(PostgresApUserRepository::new(
pool.clone(), pool.clone(),
@@ -51,12 +52,11 @@ pub async fn build(database_url: &str, base_url: &str, nats_url: &str) -> Worker
None, None,
Arc::new(postgres::tag::PgTagRepository::new(pool.clone())), Arc::new(postgres::tag::PgTagRepository::new(pool.clone())),
)), )),
base_url.to_string(), base_url,
false, Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())),
"thoughts".to_string(),
false,
None,
) )
.software_name("thoughts")
.build()
.await .await
.expect("ActivityPubService build failed"), .expect("ActivityPubService build failed"),
); );

View File

@@ -43,13 +43,19 @@ async fn main() {
match result { match result {
Ok(envelope) => { Ok(envelope) => {
let event = &envelope.event; 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 n = infra.handlers.notification.handle(event).await;
let f = infra.handlers.federation.handle(event).await; let f = infra.handlers.federation.handle(event).await;
if n.is_ok() && f.is_ok() { if n.is_ok() && f.is_ok() {
(envelope.ack)(); (envelope.ack)();
tracing::info!(event_type, "event handled ok");
} else { } else {
if let Err(e) = &n { if let Err(e) = &n {
tracing::error!("notification handler: {e}"); tracing::error!("notification handler: {e}");

View File

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

View File

@@ -1,138 +0,0 @@
Project Thoughts: Foundational Plan & System Architecture
1. Vision & Core Philosophy
Project Vision: To create a decentralized social media platform that prioritizes genuine user connection, creative self-expression, and a user-controlled experience. Thoughts is a deliberate departure from algorithm-driven feeds and corporate-controlled online spaces.
Core Philosophy: We are building a digital "third place" reminiscent of the early internet, where the focus is on community and individuality. The platform's aesthetic and function are guided by the "Frutiger Aero" design language—optimistic, humanistic, and clear.
Tagline: Your Space, Your Friends, Your Feed.
2. Guiding Principles & Rules to Follow
These are the non-negotiable rules that will guide all development and feature decisions.
Chronological Above All: The main feed will always be in reverse chronological order. There will be no algorithmic sorting or "while you were away" features.
User in Control: Users, not the platform, decide what they see by choosing who to follow. There will be no algorithmic content or user suggestions.
Radical Self-Expression: Profiles are a canvas. Users will be given significant freedom to customize the look and feel of their personal space.
Open and Federated: The platform must be a citizen of the Fediverse. ActivityPub integration is a primary, not secondary, feature.
Performance by Default: The user experience should be fast and lightweight, minimizing client-side JavaScript and optimizing for quick page loads.
API-First Design: The backend is the core service. The frontend is simply the first and primary client of a well-documented, public-facing API. This ensures that third-party developers have the same power as the official web client.
3. System Architecture
This section details the technical structure of the platform. Your concern about running a Rust backend with a Next.js frontend is a common one. The standard and most effective solution is to run them as two separate services that communicate with each other, orchestrated by a reverse proxy.
3.1. Architecture Diagram (High Level)
+----------------+ +---------------------+ +------------------------+ +-------------------+
| User's | <--> | Reverse Proxy | <--> | Next.js Frontend | | |
| Browser | | (Nginx / Caddy) | | (Web Server) | | |
+----------------+ +---------------------+ +------------------------+ | PostgreSQL |
| | | Database |
| (Routes /api/\*) | | |
| | +-------------------+
v |
+------------------------+ <--------------------------------------+
| Rust Backend |
| (API & ActivityPub) |
+------------------------+
3.2. Component Breakdown
Frontend: Next.js
Role: Acts as the primary client for the Rust API. It is responsible for rendering the user interface.
Justification: Using Next.js for Server-Side Rendering (SSR) gives us the best of both worlds: fast initial page loads with minimal client-side JavaScript (fulfilling the "lightweight" requirement) and a modern, component-based development experience. It will handle user sessions and render pages by fetching data from the Rust API during the request-response cycle on the server.
Backend: Rust
Role: The core of the application. It's a stateless API server that handles all business logic, user authentication, database operations, and ActivityPub federation logic.
Recommended Framework: axum. It is modern, modular, and integrates seamlessly with the tokio ecosystem, making it a robust choice for building high-performance APIs.
API Specification: We will use the OpenAPI 3.0 standard to define and document the REST API.
Database: PostgreSQL
Role: The single source of truth for all user data, posts, follows, etc.
Justification: PostgreSQL is a powerful, reliable, and open-source relational database with excellent support in the Rust ecosystem (via libraries like sqlx and diesel). It provides the structure needed for a social platform.
Deployment: Docker & Docker Compose
Role: To containerize each component (Frontend, Backend, Database, Reverse Proxy) for consistent, reproducible, and easy deployments.
docker-compose.yml Structure: The final docker-compose.yml file will define four services:
thoughts-backend: Builds and runs the Rust application.
thoughts-frontend: Builds and runs the Next.js application.
database: Runs the official PostgreSQL image, with a persistent volume for data.
proxy: Runs an Nginx or Caddy container configured to route traffic. Requests to yourdomain.com/api/\* will be forwarded to the Rust service, and all other requests will go to the Next.js service.
4. Features & Acceptance Criteria
This outlines the minimum viable product (MVP) features.
Feature
Description
Acceptance Criteria
User Accounts
Users can sign up, log in, log out. Usernames are unique.
I can successfully create an account. I can log in with my credentials and am issued a session. I can log out, which invalidates my session.
Customizable Profiles
Each user has a public profile page (/username). Profiles have a display name, bio, avatar, header, and a "Top Friends" list.
I can edit my profile details. I can upload an avatar and header image. I can select up to 8 other users to display in my "Top Friends" list.
Profile CSS
A field in the profile settings allows users to input custom CSS to style their profile page. This CSS is sanitized to prevent security risks.
I can add custom CSS to my profile. When another user visits my profile, they see my custom styling applied. Malicious CSS (e.g., url() with external scripts) is stripped out.
Posting Thoughts
Users can publish short text posts (max 128 characters) to their profile. Posts can include hashtags (e.g., #frutigeraero).
I can publish a post. It appears on my profile and in the feeds of my followers. Hashtags are clickable links. I receive an error if my post is over 128 characters.
Following System
Users can follow and unfollow other users on the platform.
When I visit a user's profile, I see a "Follow" button. Clicking it adds them to my follow list. Clicking "Unfollow" removes them.
The Main Feed
The homepage for a logged-in user. It displays posts from all followed users in reverse chronological order.
My feed contains posts from everyone I follow. The newest post is always at the top. The feed contains no posts from users I don't follow.
Tag Discovery
A "Popular Tags" section on the main page lists tags that are currently trending. Clicking a tag shows a public feed of all posts with that tag.
The popular tags list updates based on recent post activity. Clicking a tag takes me to a page showing all posts with that tag, sorted chronologically.
ActivityPub (MVP)
A user's profile is exposed as an ActivityPub actor (e.g., @username@thoughts.social).
A user on another Fediverse platform (e.g., Mastodon) can search for my profile and follow me. My new posts are federated and appear on their timeline.
Public API (MVP)
A simple, token-based API for publishing thoughts.
I can generate an API key from my settings. Using this key and curl or another tool, I can successfully publish a "thought" to my profile. API documentation is available.

View File

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

View File

@@ -4,6 +4,7 @@ import { AuthProvider } from "@/hooks/use-auth";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { Header } from "@/components/header"; import { Header } from "@/components/header";
import localFont from "next/font/local"; import localFont from "next/font/local";
import Image from "next/image";
import InstallPrompt from "@/components/install-prompt"; import InstallPrompt from "@/components/install-prompt";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -52,6 +53,16 @@ export default function RootLayout({
return ( return (
<html lang="en"> <html lang="en">
<body className={`${frutiger.className} antialiased`}> <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> <AuthProvider>
<Header /> <Header />
<main className="flex-1">{children}</main> <main className="flex-1">{children}</main>

View File

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

View File

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

View File

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

View File

@@ -67,7 +67,7 @@ export default async function TagPage({ params }: TagPageProps) {
/> />
))} ))}
{thoughtThreads.length === 0 && ( {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> </main>
</div> </div>

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { useOptimistic } from "react" import { useOptimistic, useRef } from "react"
import { followUser, unfollowUser } from "@/app/actions/social" import { followUser, unfollowUser } from "@/app/actions/social"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { toast } from "sonner" import { toast } from "sonner"
@@ -11,24 +11,93 @@ interface FollowButtonProps {
isInitiallyFollowing: boolean 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) { export function FollowButton({ username, isInitiallyFollowing }: FollowButtonProps) {
const [optimisticFollowing, setOptimisticFollowing] = useOptimistic(isInitiallyFollowing) const [optimisticFollowing, setOptimisticFollowing] = useOptimistic(isInitiallyFollowing)
const canvasRef = useRef<HTMLCanvasElement>(null)
async function handleClick() { async function handleClick() {
const next = !optimisticFollowing const next = !optimisticFollowing
setOptimisticFollowing(next) setOptimisticFollowing(next)
if (next && canvasRef.current) {
burstParticles(canvasRef.current)
}
try { try {
await (next ? followUser(username) : unfollowUser(username)) await (next ? followUser(username) : unfollowUser(username))
} catch { } catch {
setOptimisticFollowing(!next) // revert setOptimisticFollowing(!next)
toast.error(`Failed to ${next ? "follow" : "unfollow"} user.`) toast.error(`Failed to ${next ? "follow" : "unfollow"} user.`)
} }
} }
return ( return (
<div className="relative inline-block">
<canvas
ref={canvasRef}
width={160}
height={80}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
aria-hidden
/>
<Button <Button
onClick={handleClick} onClick={handleClick}
variant={optimisticFollowing ? "secondary" : "default"} variant={optimisticFollowing ? "secondary" : "default"}
className="relative rounded-full"
data-following={optimisticFollowing} data-following={optimisticFollowing}
> >
{optimisticFollowing ? ( {optimisticFollowing ? (
@@ -37,5 +106,6 @@ export function FollowButton({ username, isInitiallyFollowing }: FollowButtonPro
<><UserPlus className="mr-2 h-4 w-4" /> Follow</> <><UserPlus className="mr-2 h-4 w-4" /> Follow</>
)} )}
</Button> </Button>
</div>
) )
} }

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { UserNav } from "./user-nav"; import { UserNav } from "./user-nav";
@@ -10,25 +11,33 @@ export function Header() {
const { token } = useAuth(); const { token } = useAuth();
return ( 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="container flex h-14 items-center px-2">
<div className="flex gap-2"> {/* Logo */}
<Link href="/" className="flex items-center gap-1"> <Link href="/" className="flex items-center gap-2 mr-4 shrink-0">
<span className="hidden font-bold text-primary sm:inline-block"> <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 Thoughts
</span> </span>
</Link> </Link>
<MainNav /> <MainNav />
</div>
<div className="flex flex-1 items-center justify-end space-x-2"> <div className="flex flex-1 items-center justify-end space-x-2">
{token ? ( {token ? (
<UserNav /> <UserNav />
) : ( ) : (
<> <>
<Button asChild size="sm"> <Button asChild size="sm" variant="outline" className="rounded-full">
<Link href="/login">Login</Link> <Link href="/login">Login</Link>
</Button> </Button>
<Button asChild size="sm"> <Button asChild size="sm" className="rounded-full">
<Link href="/register">Register</Link> <Link href="/register">Register</Link>
</Button> </Button>
</> </>

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { UserMinus, UserPlus } from "lucide-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 { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -14,14 +14,18 @@ import { Connections } from "./connections";
interface RemoteUserProfileProps { interface RemoteUserProfileProps {
actor: RemoteActor; actor: RemoteActor;
handle: string;
initialPosts: Thought[]; initialPosts: Thought[];
initialTotalPages: number;
me: Me | null; me: Me | null;
initialFollowed?: boolean; initialFollowed?: boolean;
} }
export function RemoteUserProfile({ export function RemoteUserProfile({
actor, actor,
handle,
initialPosts, initialPosts,
initialTotalPages,
me, me,
initialFollowed = false, initialFollowed = false,
}: RemoteUserProfileProps) { }: RemoteUserProfileProps) {
@@ -29,6 +33,24 @@ export function RemoteUserProfile({
const [followLoading, setFollowLoading] = useState(false); const [followLoading, setFollowLoading] = useState(false);
const { token } = useAuth(); 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 [followersActive, setFollowersActive] = useState(false);
const [followingActive, setFollowingActive] = useState(false); const [followingActive, setFollowingActive] = useState(false);
@@ -108,8 +130,20 @@ export function RemoteUserProfile({
</TabsList> </TabsList>
<TabsContent value="posts" className="space-y-4 mt-4"> <TabsContent value="posts" className="space-y-4 mt-4">
{initialPosts.length > 0 ? ( {posts.length > 0 ? (
<ThoughtList thoughts={initialPosts} currentUser={me} /> <>
<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"> <Card className="flex items-center justify-center h-48">
<p className="text-center text-muted-foreground"> <p className="text-center text-muted-foreground">

View File

@@ -46,6 +46,18 @@ interface ThoughtCardProps {
isReply?: boolean; 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({ export function ThoughtCard({
thought, thought,
currentUser, currentUser,
@@ -54,6 +66,7 @@ export function ThoughtCard({
const { author } = thought; const { author } = thought;
const [isAlertOpen, setIsAlertOpen] = useState(false); const [isAlertOpen, setIsAlertOpen] = useState(false);
const [isReplyOpen, setIsReplyOpen] = useState(false); const [isReplyOpen, setIsReplyOpen] = useState(false);
const [deletingState, setDeletingState] = useState<"idle" | "shaking" | "fading">("idle");
const { token } = useAuth(); const { token } = useAuth();
const timeAgo = formatDistanceToNow(new Date(thought.createdAt), { const timeAgo = formatDistanceToNow(new Date(thought.createdAt), {
addSuffix: true, addSuffix: true,
@@ -62,14 +75,18 @@ export function ThoughtCard({
const isAuthor = currentUser?.username === thought.author.username; const isAuthor = currentUser?.username === thought.author.username;
const handleDelete = async () => { const handleDelete = async () => {
setIsAlertOpen(false);
setDeletingState("shaking");
await new Promise((r) => setTimeout(r, 450));
setDeletingState("fading");
await new Promise((r) => setTimeout(r, 300));
try { try {
await deleteThought(thought.id); await deleteThought(thought.id);
toast.success("Thought deleted successfully."); toast.success("Thought deleted.");
} catch (error) { } catch (error) {
console.error("Failed to delete thought:", error); console.error("Failed to delete thought:", error);
setDeletingState("idle");
toast.error("Failed to delete thought."); toast.error("Failed to delete thought.");
} finally {
setIsAlertOpen(false);
} }
}; };
@@ -115,7 +132,13 @@ export function ThoughtCard({
</div> </div>
)} )}
</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"> <CardHeader className="flex flex-row items-center justify-between space-y-0">
<Link <Link
href={`/users/${author.username}`} href={`/users/${author.username}`}
@@ -166,7 +189,7 @@ export function ThoughtCard({
<CardContent> <CardContent>
{thought.author.local ? ( {thought.author.local ? (
<p className="whitespace-pre-wrap break-words text-shadow-sm"> <p className="whitespace-pre-wrap break-words text-shadow-sm">
{thought.content} {renderWithHashtags(thought.content)}
</p> </p>
) : ( ) : (
<div <div
@@ -185,6 +208,7 @@ export function ThoughtCard({
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="rounded-full bg-primary/8 border border-primary/15 text-primary hover:bg-primary/15"
onClick={() => setIsReplyOpen(!isReplyOpen)} onClick={() => setIsReplyOpen(!isReplyOpen)}
> >
<MessageSquare className="mr-2 h-4 w-4" /> <MessageSquare className="mr-2 h-4 w-4" />
@@ -194,7 +218,7 @@ export function ThoughtCard({
)} )}
{isReplyOpen && ( {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 <ThoughtForm
replyToId={thought.id} replyToId={thought.id}
onSuccess={() => setIsReplyOpen(false)} onSuccess={() => setIsReplyOpen(false)}

View File

@@ -17,12 +17,13 @@ export async function TopFriends({ username }: TopFriendsProps) {
return ( return (
<Card id="top-friends" className="p-4"> <Card id="top-friends" className="p-4">
<CardHeader id="top-friends__header" className="p-0 pb-2"> <CardHeader id="top-friends__header" className="p-0 pb-3">
<CardTitle id="top-friends__title" className="text-lg text-shadow-md"> <CardTitle id="top-friends__title" className="text-lg flex items-center gap-2">
<span className="widget-icon widget-icon-green">👥</span>
Top Friends Top Friends
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent id="top-friends__content" className="p-0"> <CardContent id="top-friends__content" className="p-0 space-y-1">
{friends.map((friend) => ( {friends.map((friend) => (
<Link <Link
id={`top-friends__link-${friend.id}`} id={`top-friends__link-${friend.id}`}
@@ -30,13 +31,18 @@ export async function TopFriends({ username }: TopFriendsProps) {
key={friend.id} key={friend.id}
className="flex items-center gap-3 py-2 px-2 -mx-2 rounded-lg hover:bg-accent/50 transition-colors" 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} /> <UserAvatar src={friend.avatarUrl} alt={friend.displayName || friend.username} />
<span <div className="flex flex-col min-w-0">
id={`top-friends__name-${friend.id}`} <span className="text-xs font-semibold truncate text-shadow-sm">
className="text-xs truncate w-full font-medium text-shadow-sm"
>
{friend.displayName || friend.username} {friend.displayName || friend.username}
</span> </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> </Link>
))} ))}
</CardContent> </CardContent>

View File

@@ -12,10 +12,14 @@ const badgeVariants = cva(
default: default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80 glossy-effect bottom text-shadow-sm", "border-transparent bg-primary text-primary-foreground hover:bg-primary/80 glossy-effect bottom text-shadow-sm",
secondary: 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: destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80 glossy-effect bottom text-shadow-sm", "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", 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: { defaultVariants: {

View File

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

View File

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

View File

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

View File

@@ -1,68 +1,57 @@
import { Link } from "lucide-react";
import { import {
Card, Card,
CardHeader, CardHeader,
CardTitle, CardTitle,
CardContent, CardContent,
CardDescription,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { getAllUsersCount } from "@/lib/api"; import { getAllUsersCount } from "@/lib/api";
export async function UsersCount() { export async function UsersCount() {
const usersCount = await getAllUsersCount().catch(() => null); const usersCount = await getAllUsersCount().catch(() => null);
if (usersCount === null) { 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>
<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>
);
}
return ( return (
<Card className="p-4"> <Card className="p-4">
<CardHeader className="p-0 pb-2"> <CardHeader className="p-0 pb-3">
<CardTitle className="text-lg text-shadow-md">Users Count</CardTitle> <CardTitle className="text-lg flex items-center gap-2">
<CardDescription> <span className="widget-icon widget-icon-purple"></span>
Total number of registered users on Thoughts. Community
</CardDescription> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-0">
<div className="text-muted-foreground text-sm text-center py-4"> {count === null ? (
{usersCount.count} registered users. <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>
<div className="text-[10px] uppercase tracking-widest text-muted-foreground mt-1 font-semibold">
members
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
); );

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB