Compare commits
20 Commits
master
...
c5d9833c8b
| Author | SHA1 | Date | |
|---|---|---|---|
| c5d9833c8b | |||
| f39c1a614d | |||
| 30c8a17168 | |||
| 6a8c8b1fb8 | |||
| 4ec0725ff8 | |||
| 31e0f2958c | |||
| 555121ea75 | |||
| 9e795eefdc | |||
| 18cf2c9f54 | |||
| b58c96b843 | |||
| 8ea24461ba | |||
| e14a9f90c8 | |||
| 28756ef4cd | |||
| 7f27ae49c3 | |||
| 59f3423c00 | |||
| c48aa33592 | |||
| 8f3aa4b891 | |||
| 32bfb00970 | |||
| 7ce2901c2a | |||
| 8bbc713093 |
18
.claude/settings.json
Normal file
18
.claude/settings.json
Normal 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..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(¬e)?))
|
Ok((note_url, serde_json::to_value(¬e)?))
|
||||||
})
|
})
|
||||||
.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(¬e)?, created_at))
|
Ok((note_url, serde_json::to_value(¬e)?, 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(),
|
||||||
¬e.content,
|
author_id: &author_id,
|
||||||
note.published,
|
content: ¬e.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}"))?;
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(¬e).unwrap();
|
let json = serde_json::to_string(¬e).unwrap();
|
||||||
assert!(json.contains(AS_PUBLIC));
|
assert!(json.contains(AS_PUBLIC));
|
||||||
assert!(json.contains("Hello world"));
|
assert!(json.contains("Hello world"));
|
||||||
|
|||||||
@@ -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![])
|
||||||
|
|||||||
@@ -356,6 +356,5 @@ impl TryFrom<EventPayload> for DomainEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -109,6 +109,5 @@ impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -239,6 +239,5 @@ impl MessageSource for NatsMessageSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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::{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 })))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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"),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
|||||||
138
foundation.md
138
foundation.md
@@ -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.
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 & more
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
72
thoughts-frontend/components/user-thoughts-list.tsx
Normal file
72
thoughts-frontend/components/user-thoughts-list.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { getUserThoughts, Me, Thought } from "@/lib/api";
|
||||||
|
import { ThoughtThread } from "@/components/thought-thread";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { EmptyState } from "@/components/empty-state";
|
||||||
|
import { buildThoughtThreads } from "@/lib/utils";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
|
||||||
|
interface UserThoughtsListProps {
|
||||||
|
username: string;
|
||||||
|
initialThoughts: Thought[];
|
||||||
|
totalPages: number;
|
||||||
|
me: Me | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserThoughtsList({
|
||||||
|
username,
|
||||||
|
initialThoughts,
|
||||||
|
totalPages,
|
||||||
|
me,
|
||||||
|
}: UserThoughtsListProps) {
|
||||||
|
const [thoughts, setThoughts] = useState<Thought[]>(initialThoughts);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const { token } = useAuth();
|
||||||
|
|
||||||
|
const thoughtThreads = buildThoughtThreads(thoughts);
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const result = await getUserThoughts(username, token, page + 1);
|
||||||
|
setThoughts((prev) => [...prev, ...result.items]);
|
||||||
|
setPage((p) => p + 1);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to load more thoughts.");
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (thoughtThreads.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
emoji="💭"
|
||||||
|
title="Nothing here yet"
|
||||||
|
message="This user hasn't posted any public thoughts yet."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{thoughtThreads.map((thought) => (
|
||||||
|
<ThoughtThread key={thought.id} thought={thought} currentUser={me} />
|
||||||
|
))}
|
||||||
|
{page < totalPages && (
|
||||||
|
<Button
|
||||||
|
onClick={loadMore}
|
||||||
|
disabled={loadingMore}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full rounded-full"
|
||||||
|
>
|
||||||
|
{loadingMore ? "Loading…" : "Load more"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,68 +1,57 @@
|
|||||||
import { Link } from "lucide-react";
|
|
||||||
import {
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
BIN
thoughts-frontend/public/bg1.avif
Normal file
BIN
thoughts-frontend/public/bg1.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
Reference in New Issue
Block a user