Compare commits
12 Commits
0b74344efe
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f69cfb011 | |||
| 9aea5c1bd9 | |||
| 4d6df1ea60 | |||
| 5a65fda0bc | |||
| 6dbd4dafdc | |||
| 90d13c883b | |||
| 0c8fa01ab9 | |||
| 78daca0377 | |||
| 3357484bbf | |||
| 442a61bbdb | |||
| be27fe04e2 | |||
| 6040cf1e53 |
164
ARCHITECTURE.md
Normal file
164
ARCHITECTURE.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# Architecture
|
||||
|
||||
Hexagonal (ports & adapters) architecture. Dependencies point inward — adapters implement domain ports, application orchestrates use cases, presentation handles HTTP.
|
||||
|
||||
## Crate dependency graph
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Entry Points
|
||||
bootstrap["bootstrap<br/><small>HTTP server, DI wiring</small>"]
|
||||
worker["worker<br/><small>background job consumer</small>"]
|
||||
end
|
||||
|
||||
subgraph Interface Layer
|
||||
presentation["presentation<br/><small>axum handlers, extractors, AppState</small>"]
|
||||
api_types["api-types<br/><small>DTOs, OpenAPI</small>"]
|
||||
end
|
||||
|
||||
subgraph Application Layer
|
||||
application["application<br/><small>use cases, FederationEventService</small>"]
|
||||
end
|
||||
|
||||
subgraph Domain Layer
|
||||
domain["domain<br/><small>models, value objects, events, port traits</small>"]
|
||||
end
|
||||
|
||||
subgraph Adapters
|
||||
postgres["postgres<br/><small>UserRepo, ThoughtRepo, LikeRepo,<br/>BoostRepo, FollowRepo, BlockRepo,<br/>TagRepo, FeedRepo, FederationContentRepo, ...</small>"]
|
||||
activitypub["activitypub<br/><small>FederationActionPort,<br/>FederationBroadcastPort,<br/>FederationSchedulerPort<br/>(wraps k-ap)</small>"]
|
||||
postgres_fed["postgres-federation<br/><small>k-ap DB traits</small>"]
|
||||
postgres_search["postgres-search<br/><small>SearchPort</small>"]
|
||||
auth["auth<br/><small>AuthService, ApiKeyService</small>"]
|
||||
nats["nats<br/><small>EventPublisher, EventConsumer</small>"]
|
||||
storage["storage<br/><small>MediaStore</small>"]
|
||||
event_transport["event-transport<br/><small>event delivery</small>"]
|
||||
event_payload["event-payload<br/><small>event serialization</small>"]
|
||||
end
|
||||
|
||||
bootstrap --> presentation
|
||||
bootstrap --> application
|
||||
bootstrap --> postgres
|
||||
bootstrap --> postgres_fed
|
||||
bootstrap --> postgres_search
|
||||
bootstrap --> activitypub
|
||||
bootstrap --> auth
|
||||
bootstrap --> nats
|
||||
bootstrap --> storage
|
||||
bootstrap --> event_transport
|
||||
bootstrap --> event_payload
|
||||
|
||||
worker --> application
|
||||
worker --> activitypub
|
||||
worker --> postgres
|
||||
worker --> postgres_fed
|
||||
worker --> nats
|
||||
worker --> event_transport
|
||||
worker --> event_payload
|
||||
|
||||
presentation --> application
|
||||
presentation --> api_types
|
||||
presentation --> domain
|
||||
|
||||
application --> domain
|
||||
|
||||
postgres --> domain
|
||||
activitypub --> domain
|
||||
postgres_fed -.-> domain
|
||||
postgres_search --> domain
|
||||
postgres_search --> postgres
|
||||
auth --> domain
|
||||
nats --> domain
|
||||
storage --> domain
|
||||
event_transport --> domain
|
||||
event_payload --> domain
|
||||
```
|
||||
|
||||
## Domain ports
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class domain {
|
||||
<<core>>
|
||||
}
|
||||
|
||||
namespace Data Ports {
|
||||
class UserRepository {
|
||||
<<trait>>
|
||||
find_by_id()
|
||||
find_by_username()
|
||||
save()
|
||||
update_profile()
|
||||
}
|
||||
class ThoughtRepository {
|
||||
<<trait>>
|
||||
save()
|
||||
find_by_id()
|
||||
delete()
|
||||
update_content()
|
||||
}
|
||||
class LikeRepository { <<trait>> }
|
||||
class BoostRepository { <<trait>> }
|
||||
class FollowRepository { <<trait>> }
|
||||
class BlockRepository { <<trait>> }
|
||||
class TagRepository { <<trait>> }
|
||||
class FeedRepository { <<trait>> }
|
||||
class NotificationRepository { <<trait>> }
|
||||
class EngagementRepository { <<trait>> }
|
||||
class SearchPort { <<trait>> }
|
||||
}
|
||||
|
||||
namespace Federation Ports {
|
||||
class FederationContentRepository {
|
||||
<<trait>>
|
||||
outbox_entries_for_actor()
|
||||
find_remote_actor_id()
|
||||
intern_remote_actor()
|
||||
accept_note()
|
||||
retract_note()
|
||||
}
|
||||
class FederationBroadcastPort {
|
||||
<<trait>>
|
||||
broadcast_create()
|
||||
broadcast_delete()
|
||||
broadcast_update()
|
||||
broadcast_announce()
|
||||
broadcast_like()
|
||||
}
|
||||
class FederationActionPort {
|
||||
<<supertrait>>
|
||||
}
|
||||
class FederationLookupPort { <<trait>> }
|
||||
class FederationFollowPort { <<trait>> }
|
||||
class FederationFollowRequestPort { <<trait>> }
|
||||
class FederationFetchPort { <<trait>> }
|
||||
class FederationBlockPort { <<trait>> }
|
||||
class FederationSchedulerPort { <<trait>> }
|
||||
}
|
||||
|
||||
namespace Infra Ports {
|
||||
class EventPublisher { <<trait>> }
|
||||
class EventConsumer { <<trait>> }
|
||||
class AuthService { <<trait>> }
|
||||
class PasswordHasher { <<trait>> }
|
||||
class MediaStore { <<trait>> }
|
||||
}
|
||||
|
||||
FederationActionPort --|> FederationLookupPort
|
||||
FederationActionPort --|> FederationFollowPort
|
||||
FederationActionPort --|> FederationFollowRequestPort
|
||||
FederationActionPort --|> FederationFetchPort
|
||||
FederationActionPort --|> FederationBlockPort
|
||||
```
|
||||
|
||||
## Dependency rule
|
||||
|
||||
```
|
||||
bootstrap/worker ──► presentation ──► application ──► domain ◄── adapters
|
||||
```
|
||||
|
||||
- **domain** — zero framework deps, pure business logic, defines all port traits
|
||||
- **application** — orchestrates use cases, depends only on domain
|
||||
- **presentation** — HTTP handlers (axum), depends on domain + application
|
||||
- **adapters** — implement domain ports, depend inward on domain only
|
||||
- **bootstrap/worker** — composition roots, wire adapters into ports
|
||||
9
Cargo.lock
generated
9
Cargo.lock
generated
@@ -273,13 +273,13 @@ dependencies = [
|
||||
name = "application"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"activitypub",
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"domain",
|
||||
"futures",
|
||||
"hex",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
@@ -2017,9 +2017,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "k-ap"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
source = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/"
|
||||
checksum = "f73de37ac4feab6d7b78e73c60acbb07933c2be58dcbb12e8a34201f66e0480d"
|
||||
checksum = "ccaa914953bfd45ea206e11826da8f61ce1fbe02f8fe0622880527046ad6ae24"
|
||||
dependencies = [
|
||||
"activitypub_federation",
|
||||
"anyhow",
|
||||
@@ -2604,7 +2604,6 @@ dependencies = [
|
||||
name = "presentation"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"activitypub",
|
||||
"api-types",
|
||||
"application",
|
||||
"async-trait",
|
||||
@@ -4571,7 +4570,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
26
README.md
26
README.md
@@ -14,7 +14,14 @@ A self-hosted microblogging server with full ActivityPub federation. Write short
|
||||
- JWT authentication (Bearer token) with API key support for third-party clients
|
||||
- OpenAPI documentation at `/docs` (Swagger UI) and `/scalar` (Scalar)
|
||||
- Full-text search over thoughts and users via PostgreSQL trigram indexes
|
||||
- Top friends — pin up to 5 users as highlighted contacts
|
||||
- **Profile fields** — up to 4 custom key/value fields (Website, Pronouns, etc.), federated as AP `PropertyValue` attachment
|
||||
- **Custom CSS** — per-user stylesheet applied to their profile page
|
||||
- **Visibility levels** — public, followers-only, unlisted, and direct posts
|
||||
- **Content warnings** — optional CW label and sensitive flag on posts
|
||||
- **Feed controls** — sort by newest, oldest, most liked, most boosted, or most discussed; filter to originals only, replies only, local only, or hide sensitive
|
||||
- **Popular tags** — trending hashtag discovery
|
||||
- Top friends — pin up to 8 users as highlighted contacts
|
||||
- Account migration — set `alsoKnownAs` for Fediverse actor moves
|
||||
- Home feed, public feed, and per-user thought timelines
|
||||
- Rate limiting and registration control
|
||||
|
||||
@@ -108,7 +115,7 @@ Copy `.env.example` to `.env` and fill in your values.
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `HOST` | `0.0.0.0` | Interface to bind |
|
||||
| `PORT` | `3000` | Port to listen on |
|
||||
| `PORT` | `8000` | Port to listen on |
|
||||
| `NATS_URL` | — | NATS connection string. If unset, a no-op publisher is used and events are not delivered to the worker |
|
||||
| `CORS_ORIGINS` | `*` | Comma-separated allowed origins for CORS, e.g. `https://app.example.com` |
|
||||
| `RATE_LIMIT` | disabled | Max requests per minute per IP |
|
||||
@@ -201,18 +208,7 @@ Interactive API documentation is available at runtime:
|
||||
|
||||
## Frontend
|
||||
|
||||
The Next.js frontend lives in `thoughts-frontend/`. It requires two environment variables:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000 # client-side requests
|
||||
NEXT_PUBLIC_SERVER_SIDE_API_URL=http://localhost:8000 # SSR requests
|
||||
```
|
||||
|
||||
```bash
|
||||
cd thoughts-frontend
|
||||
bun install
|
||||
bun run dev # http://localhost:3000
|
||||
```
|
||||
The Next.js frontend lives in `thoughts-frontend/`. See [Frontend environment](#frontend-environment) for required env vars, or follow the [local development](#local-development-recommended) steps above.
|
||||
|
||||
## Docker
|
||||
|
||||
@@ -270,7 +266,7 @@ Services:
|
||||
|
||||
Contributions are welcome. A few guidelines:
|
||||
|
||||
- **Run tests before opening a PR.** At minimum: `cargo test -p application` (no database needed). For adapter changes: `cargo test --workspace` with a live database.
|
||||
- **Run tests before opening a PR.** At minimum: `make test-unit` (no database needed). For adapter changes: `make test-integration` with a live database. `make check` runs the full suite (fmt + clippy + tests).
|
||||
- **Keep the hexagonal boundary.** `domain` and `application` must not import any adapter crate. Use `&dyn Port` traits for all I/O.
|
||||
- **No ORM.** The project uses raw `sqlx`. Keep it that way.
|
||||
- **ActivityPub changes** — test against a live Mastodon instance if possible, or use the AP debug logs (`RUST_ENV=development`).
|
||||
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
k-ap = { version = "0.3.1", registry = "gitea" }
|
||||
k-ap = { version = "0.4.0", registry = "gitea" }
|
||||
domain = { workspace = true }
|
||||
url = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -210,11 +210,11 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
let obj_type = object.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
match obj_type {
|
||||
"Note" | "Article" | "Page" => {
|
||||
let Some((note, _)) = ThoughtNote::try_from_ap(object) else {
|
||||
let Some((note, note_extensions)) = ThoughtNote::try_from_ap(object) else {
|
||||
return Ok(());
|
||||
};
|
||||
self.repo
|
||||
.apply_note_update(ap_id.as_str(), ¬e.content)
|
||||
.apply_note_update(ap_id.as_str(), ¬e.content, note_extensions)
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ pub mod port;
|
||||
pub mod service;
|
||||
pub mod urls;
|
||||
|
||||
pub const INSTANCE_ACTOR_ID: uuid::Uuid =
|
||||
uuid::Uuid::from_bytes([0, 0, 0, 0, 0, 0, 0x40, 0, 0x80, 0, 0, 0, 0, 0, 0, 0]);
|
||||
|
||||
pub use handler::ThoughtsObjectHandler;
|
||||
pub use note::ThoughtNote;
|
||||
pub use port::{
|
||||
@@ -43,7 +46,8 @@ pub async fn build_ap_service(
|
||||
.object_handler(cfg.ap_handler)
|
||||
.allow_registration(cfg.allow_registration)
|
||||
.software_name("thoughts")
|
||||
.debug(cfg.debug);
|
||||
.debug(cfg.debug)
|
||||
.signed_fetch_actor_id(INSTANCE_ACTOR_ID);
|
||||
if let Some(publisher) = cfg.event_publisher {
|
||||
builder = builder.event_publisher(publisher);
|
||||
}
|
||||
|
||||
@@ -1,171 +1,5 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::thought::Thought,
|
||||
value_objects::{ThoughtId, UserId, Username},
|
||||
pub use domain::ports::{
|
||||
AcceptNoteInput, ActorFederationUrls as ActorApUrls,
|
||||
FederationBroadcastPort as OutboundFederationPort,
|
||||
FederationContentRepository as ActivityPubRepository, OutboxEntry,
|
||||
};
|
||||
|
||||
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>,
|
||||
pub note_extensions: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// AP-protocol endpoints for a locally-stored user (local or interned remote).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActorApUrls {
|
||||
pub ap_id: String,
|
||||
pub inbox_url: String,
|
||||
}
|
||||
|
||||
/// A local thought ready for AP serialization, with the author's username
|
||||
/// pre-joined so the handler can build AP URLs without a second query.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OutboxEntry {
|
||||
pub thought: Thought,
|
||||
pub author_username: Username,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ActivityPubRepository: Send + Sync {
|
||||
// ── Outbox (local → remote) ──────────────────────────────────────
|
||||
|
||||
/// All public local thoughts for this actor. Used for outbox totals
|
||||
/// and full-collection delivery.
|
||||
async fn outbox_entries_for_actor(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<OutboxEntry>, DomainError>;
|
||||
|
||||
/// Cursor page of public local thoughts, newest-first, before `before`.
|
||||
/// Used for OrderedCollectionPage responses.
|
||||
async fn outbox_page_for_actor(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<OutboxEntry>, DomainError>;
|
||||
|
||||
// ── Remote actor resolution ──────────────────────────────────────
|
||||
|
||||
/// Find the local UserId for a remote actor by its AP URL.
|
||||
async fn find_remote_actor_id(&self, actor_ap_url: &str)
|
||||
-> Result<Option<UserId>, DomainError>;
|
||||
|
||||
/// Ensure a remote actor placeholder exists; create one if absent.
|
||||
/// Idempotent — safe to call multiple times with the same URL.
|
||||
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError>;
|
||||
|
||||
/// Update display_name and avatar_url for an already-interned remote actor.
|
||||
async fn update_remote_actor_display(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
display_name: Option<&str>,
|
||||
avatar_url: Option<&str>,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
// ── Inbox processing (remote → local) ───────────────────────────
|
||||
|
||||
/// Persist an incoming remote Note. Idempotent on ap_id.
|
||||
async fn accept_note(&self, input: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError>;
|
||||
|
||||
/// Apply an Update to a previously accepted remote Note.
|
||||
async fn apply_note_update(&self, ap_id: &str, new_content: &str) -> Result<(), DomainError>;
|
||||
|
||||
/// Remove a specific remote Note (Delete activity). Only touches
|
||||
/// remotely-originated thoughts.
|
||||
async fn retract_note(&self, ap_id: &str) -> Result<(), DomainError>;
|
||||
|
||||
/// Remove all Notes from a remote actor (actor-level Delete/Tombstone).
|
||||
async fn retract_actor_notes(&self, actor_ap_url: &str) -> Result<(), DomainError>;
|
||||
|
||||
// ── Node-level stats ─────────────────────────────────────────────
|
||||
|
||||
/// Total locally-authored thought count for NodeInfo responses.
|
||||
async fn count_local_notes(&self) -> Result<u64, DomainError>;
|
||||
|
||||
/// Return the ActivityPub object URL for a thought, if one is stored.
|
||||
/// Returns None for local thoughts (caller constructs URL from base_url + thought_id).
|
||||
async fn get_thought_ap_id(
|
||||
&self,
|
||||
thought_id: &ThoughtId,
|
||||
) -> Result<Option<String>, DomainError>;
|
||||
|
||||
/// Return the AP actor URL and inbox URL for a user, if stored.
|
||||
/// Returns None for users that have not been federated.
|
||||
async fn get_actor_ap_urls(&self, user_id: &UserId)
|
||||
-> Result<Option<ActorApUrls>, DomainError>;
|
||||
|
||||
/// Sync display_name + avatar_url from remote_actors to users table.
|
||||
async fn sync_remote_actor_to_user(&self, actor_ap_url: &str) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OutboundFederationPort: Send + Sync {
|
||||
/// Fan out a new local Note to all accepted followers.
|
||||
async fn broadcast_create(
|
||||
&self,
|
||||
author_user_id: &UserId,
|
||||
thought: &Thought,
|
||||
author_username: &str,
|
||||
in_reply_to_url: Option<&str>,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Fan out a Delete tombstone for a now-deleted local Note.
|
||||
/// `thought_ap_id` is pre-constructed by the caller because the thought
|
||||
/// has already been deleted from the DB when this fires.
|
||||
async fn broadcast_delete(
|
||||
&self,
|
||||
author_user_id: &UserId,
|
||||
thought_ap_id: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Fan out an Update(Note) for an edited local thought.
|
||||
async fn broadcast_update(
|
||||
&self,
|
||||
author_user_id: &UserId,
|
||||
thought: &Thought,
|
||||
author_username: &str,
|
||||
in_reply_to_url: Option<&str>,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Fan out an Announce(object_ap_id) for a boost.
|
||||
async fn broadcast_announce(
|
||||
&self,
|
||||
booster_user_id: &UserId,
|
||||
object_ap_id: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Fan out an Undo(Announce) to followers when a boost is removed.
|
||||
async fn broadcast_undo_announce(
|
||||
&self,
|
||||
booster_user_id: &UserId,
|
||||
object_ap_id: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Send a Like activity to a remote thought author's inbox.
|
||||
/// Only called when a LOCAL user likes a REMOTE thought (one with an ap_id).
|
||||
async fn broadcast_like(
|
||||
&self,
|
||||
liker_user_id: &UserId,
|
||||
object_ap_id: &str,
|
||||
author_inbox_url: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Send Undo(Like) to a remote thought author's inbox.
|
||||
async fn broadcast_undo_like(
|
||||
&self,
|
||||
liker_user_id: &UserId,
|
||||
object_ap_id: &str,
|
||||
author_inbox_url: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Fan out an Update(Actor) to all accepted followers after a profile change.
|
||||
async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
@@ -95,6 +95,16 @@ fn build_note_json(
|
||||
.collect();
|
||||
note["tag"] = serde_json::json!(ap_tags);
|
||||
}
|
||||
if let Some(ref mood) = thought.mood {
|
||||
note["mood"] = serde_json::json!(mood);
|
||||
}
|
||||
if let Some(ref ext) = thought.note_extensions {
|
||||
if let Some(obj) = ext.as_object() {
|
||||
for (k, v) in obj {
|
||||
note.as_object_mut().unwrap().entry(k).or_insert(v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
note
|
||||
}
|
||||
|
||||
@@ -114,7 +124,7 @@ fn k_ap_actor_to_domain(a: k_ap::RemoteActor) -> DomainRemoteActor {
|
||||
display_name: a.display_name,
|
||||
avatar_url: a.avatar_url,
|
||||
outbox_url: a.outbox_url,
|
||||
last_fetched_at: chrono::Utc::now(),
|
||||
last_fetched_at: a.fetched_at.unwrap_or_else(chrono::Utc::now),
|
||||
bio: a.bio,
|
||||
banner_url: a.banner_url,
|
||||
also_known_as: a.also_known_as,
|
||||
@@ -126,8 +136,6 @@ fn k_ap_actor_to_domain(a: k_ap::RemoteActor) -> DomainRemoteActor {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: these fetches are unsigned — fails on instances with authorized-fetch (Secure Mode).
|
||||
// Fix requires exposing k-ap's signed HTTP client.
|
||||
async fn resolve_actor_profiles_from_urls(
|
||||
urls: Vec<String>,
|
||||
) -> Vec<domain::models::actor_connection_summary::ActorConnectionSummary> {
|
||||
|
||||
@@ -3,11 +3,13 @@ use async_trait::async_trait;
|
||||
use domain::value_objects::{ThoughtId, UserId};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
type CallLog = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
struct SpyTransport {
|
||||
calls: Arc<Mutex<Vec<(String, Vec<u8>)>>>,
|
||||
calls: CallLog,
|
||||
}
|
||||
impl SpyTransport {
|
||||
fn new() -> (Self, Arc<Mutex<Vec<(String, Vec<u8>)>>>) {
|
||||
fn new() -> (Self, CallLog) {
|
||||
let calls = Arc::new(Mutex::new(vec![]));
|
||||
(
|
||||
Self {
|
||||
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
k-ap = { version = "0.3.1", registry = "gitea" }
|
||||
k-ap = { version = "0.4.0", registry = "gitea" }
|
||||
sqlx = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
@@ -59,6 +59,7 @@ struct RemoteActorRow {
|
||||
followers_url: Option<String>,
|
||||
following_url: Option<String>,
|
||||
also_known_as: Option<Vec<String>>,
|
||||
last_fetched_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
fn map_remote_actor(r: RemoteActorRow) -> RemoteActor {
|
||||
@@ -75,6 +76,7 @@ fn map_remote_actor(r: RemoteActorRow) -> RemoteActor {
|
||||
followers_url: r.followers_url,
|
||||
following_url: r.following_url,
|
||||
also_known_as: r.also_known_as.unwrap_or_default(),
|
||||
fetched_at: r.last_fetched_at,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +176,7 @@ impl FollowRepository for PgFederationRepository {
|
||||
"SELECT f.remote_actor_url AS url, f.status,
|
||||
COALESCE(r.handle,'') AS handle, COALESCE(r.inbox_url,'') AS inbox_url,
|
||||
r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url,
|
||||
r.bio, r.banner_url, r.followers_url, r.following_url, r.also_known_as
|
||||
r.bio, r.banner_url, r.followers_url, r.following_url, r.also_known_as, r.last_fetched_at
|
||||
FROM federation_followers f
|
||||
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
|
||||
WHERE f.local_user_id=$1 AND f.status='accepted'",
|
||||
@@ -209,7 +211,7 @@ impl FollowRepository for PgFederationRepository {
|
||||
"SELECT f.remote_actor_url AS url, f.status,
|
||||
COALESCE(r.handle,'') AS handle, COALESCE(r.inbox_url,'') AS inbox_url,
|
||||
r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url,
|
||||
r.bio, r.banner_url, r.followers_url, r.following_url, r.also_known_as
|
||||
r.bio, r.banner_url, r.followers_url, r.following_url, r.also_known_as, r.last_fetched_at
|
||||
FROM federation_followers f
|
||||
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
|
||||
WHERE f.local_user_id=$1 AND f.status='accepted'
|
||||
@@ -261,7 +263,7 @@ impl FollowRepository for PgFederationRepository {
|
||||
sqlx::query_as::<_, RemoteActorRow>(
|
||||
"SELECT f.remote_actor_url AS url, COALESCE(r.handle,'') AS handle,
|
||||
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url,
|
||||
r.bio, r.banner_url, r.followers_url, r.following_url, r.also_known_as
|
||||
r.bio, r.banner_url, r.followers_url, r.following_url, r.also_known_as, r.last_fetched_at
|
||||
FROM federation_followers f
|
||||
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
|
||||
WHERE f.local_user_id=$1 AND f.status='accepted'
|
||||
@@ -305,7 +307,7 @@ impl FollowRepository for PgFederationRepository {
|
||||
sqlx::query_as::<_, RemoteActorRow>(
|
||||
"SELECT f.remote_actor_url AS url, COALESCE(r.handle,'') AS handle,
|
||||
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url,
|
||||
r.bio, r.banner_url, r.followers_url, r.following_url, r.also_known_as
|
||||
r.bio, r.banner_url, r.followers_url, r.following_url, r.also_known_as, r.last_fetched_at
|
||||
FROM federation_followers f
|
||||
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
|
||||
WHERE f.local_user_id=$1 AND f.status='pending'",
|
||||
@@ -389,7 +391,7 @@ impl FollowRepository for PgFederationRepository {
|
||||
sqlx::query_as::<_, RemoteActorRow>(
|
||||
"SELECT f.remote_actor_url AS url, COALESCE(r.handle,'') AS handle,
|
||||
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url,
|
||||
r.bio, r.banner_url, r.followers_url, r.following_url, r.also_known_as
|
||||
r.bio, r.banner_url, r.followers_url, r.following_url, r.also_known_as, r.last_fetched_at
|
||||
FROM federation_following f
|
||||
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
|
||||
WHERE f.local_user_id=$1",
|
||||
@@ -410,7 +412,7 @@ impl FollowRepository for PgFederationRepository {
|
||||
sqlx::query_as::<_, RemoteActorRow>(
|
||||
"SELECT f.remote_actor_url AS url, COALESCE(r.handle,'') AS handle,
|
||||
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url,
|
||||
r.bio, r.banner_url, r.followers_url, r.following_url, r.also_known_as
|
||||
r.bio, r.banner_url, r.followers_url, r.following_url, r.also_known_as, r.last_fetched_at
|
||||
FROM federation_following f
|
||||
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
|
||||
WHERE f.local_user_id=$1
|
||||
@@ -585,7 +587,7 @@ impl ActorRepository for PgFederationRepository {
|
||||
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
|
||||
sqlx::query_as::<_, RemoteActorRow>(
|
||||
"SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url,
|
||||
bio, banner_url, followers_url, following_url, also_known_as
|
||||
bio, banner_url, followers_url, following_url, also_known_as, last_fetched_at
|
||||
FROM remote_actors WHERE url=$1",
|
||||
)
|
||||
.bind(actor_url)
|
||||
@@ -917,6 +919,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let repo = PgApUserRepository::new(pool, "https://example.com".into());
|
||||
assert_eq!(repo.count_users().await.unwrap(), 2);
|
||||
// 2 seeded local users + 1 instance actor from migration 022
|
||||
assert_eq!(repo.count_users().await.unwrap(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ struct FeedRow {
|
||||
thought_created_at: DateTime<Utc>,
|
||||
thought_updated_at: Option<DateTime<Utc>>,
|
||||
note_extensions: Option<serde_json::Value>,
|
||||
mood: Option<String>,
|
||||
#[sqlx(flatten)]
|
||||
author: postgres::user::UserRow,
|
||||
like_count: i64,
|
||||
@@ -58,9 +59,9 @@ fn feed_select(viewer: Option<uuid::Uuid>) -> String {
|
||||
t.id AS thought_id, t.user_id AS t_user_id, t.content,\n\
|
||||
t.in_reply_to_id,\n\
|
||||
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,\n\
|
||||
t.created_at AS thought_created_at, t.updated_at AS thought_updated_at, t.note_extensions,\n\
|
||||
t.created_at AS thought_created_at, t.updated_at AS thought_updated_at, t.note_extensions, t.mood,\n\
|
||||
u.id, u.username, u.email, u.password_hash,\n\
|
||||
u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css, u.profile_fields,\n\
|
||||
u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.custom_moods,\n\
|
||||
u.local,\n\
|
||||
u.created_at, u.updated_at,\n\
|
||||
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,\n\
|
||||
@@ -84,6 +85,7 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
|
||||
created_at: r.thought_created_at,
|
||||
updated_at: r.thought_updated_at,
|
||||
note_extensions: r.note_extensions,
|
||||
mood: r.mood,
|
||||
};
|
||||
let author = User::from(r.author);
|
||||
Ok(FeedEntry {
|
||||
|
||||
@@ -27,6 +27,7 @@ async fn seed_thought(pool: &sqlx::PgPool, username: &str, content: &str) -> (Us
|
||||
visibility: Visibility::Public,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
mood: None,
|
||||
});
|
||||
trepo.save(&t).await.unwrap();
|
||||
(u, t)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE thoughts ADD COLUMN IF NOT EXISTS mood VARCHAR(64);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS custom_moods JSONB DEFAULT '[]'::jsonb;
|
||||
10
crates/adapters/postgres/migrations/022_instance_actor.sql
Normal file
10
crates/adapters/postgres/migrations/022_instance_actor.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
INSERT INTO users (id, username, email, password_hash, display_name, bio)
|
||||
VALUES (
|
||||
'00000000-0000-4000-8000-000000000000',
|
||||
'instance',
|
||||
'noreply@instance.invalid',
|
||||
'!service-actor-no-login',
|
||||
NULL,
|
||||
NULL
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
@@ -41,6 +41,7 @@ impl OutboxRow {
|
||||
created_at: self.created_at,
|
||||
updated_at: self.updated_at,
|
||||
note_extensions: None,
|
||||
mood: None,
|
||||
},
|
||||
author_username: Username::from_trusted(self.username),
|
||||
}
|
||||
@@ -269,13 +270,19 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
||||
Ok(ThoughtId::from_uuid(row.0))
|
||||
}
|
||||
|
||||
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,
|
||||
note_extensions: Option<serde_json::Value>,
|
||||
) -> Result<(), DomainError> {
|
||||
let capped: String = new_content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
|
||||
sqlx::query(
|
||||
"UPDATE thoughts SET content=$2,updated_at=NOW() WHERE ap_id=$1 AND local=false",
|
||||
"UPDATE thoughts SET content=$2,note_extensions=$3,updated_at=NOW() WHERE ap_id=$1 AND local=false",
|
||||
)
|
||||
.bind(ap_id)
|
||||
.bind(&capped)
|
||||
.bind(¬e_extensions)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
|
||||
@@ -36,6 +36,7 @@ struct FeedRow {
|
||||
thought_created_at: DateTime<Utc>,
|
||||
thought_updated_at: Option<DateTime<Utc>>,
|
||||
note_extensions: Option<serde_json::Value>,
|
||||
mood: Option<String>,
|
||||
#[sqlx(flatten)]
|
||||
author: crate::user::UserRow,
|
||||
like_count: i64,
|
||||
@@ -58,6 +59,7 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
|
||||
created_at: r.thought_created_at,
|
||||
updated_at: r.thought_updated_at,
|
||||
note_extensions: r.note_extensions,
|
||||
mood: r.mood,
|
||||
};
|
||||
let author = User::from(r.author);
|
||||
Ok(FeedEntry {
|
||||
@@ -112,7 +114,7 @@ impl<'a> FeedSqlBuilder<'a> {
|
||||
t.in_reply_to_id,
|
||||
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
|
||||
t.created_at AS thought_created_at, t.updated_at AS thought_updated_at,
|
||||
t.note_extensions,
|
||||
t.note_extensions, t.mood,
|
||||
u.id,
|
||||
CASE WHEN NOT u.local AND ra.handle IS NOT NULL AND ra.handle != ''
|
||||
THEN '@' || ra.handle ||
|
||||
@@ -124,7 +126,7 @@ impl<'a> FeedSqlBuilder<'a> {
|
||||
COALESCE(ra.display_name, u.display_name) AS display_name,
|
||||
u.bio,
|
||||
COALESCE(ra.avatar_url, u.avatar_url) AS avatar_url,
|
||||
u.header_url, u.custom_css, u.profile_fields,
|
||||
u.header_url, u.custom_css, u.profile_fields, u.custom_moods,
|
||||
u.local,
|
||||
u.created_at, u.updated_at,
|
||||
COALESCE(l_agg.cnt, 0) AS like_count,
|
||||
|
||||
@@ -28,6 +28,7 @@ async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thou
|
||||
visibility: Visibility::Public,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
mood: None,
|
||||
});
|
||||
trepo.save(&t).await.unwrap();
|
||||
(u, t)
|
||||
|
||||
@@ -120,7 +120,7 @@ impl FollowRepository for PgFollowRepository {
|
||||
.into_domain()?;
|
||||
|
||||
let rows = sqlx::query_as::<_, crate::user::UserRow>(
|
||||
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.profile_fields,u.local,u.created_at,u.updated_at
|
||||
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.profile_fields,u.custom_moods,u.local,u.created_at,u.updated_at
|
||||
FROM users u JOIN follows f ON f.follower_id=u.id
|
||||
WHERE f.following_id=$1 AND f.state='accepted'
|
||||
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
|
||||
@@ -154,7 +154,7 @@ impl FollowRepository for PgFollowRepository {
|
||||
.into_domain()?;
|
||||
|
||||
let rows = sqlx::query_as::<_, crate::user::UserRow>(
|
||||
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.profile_fields,u.local,u.created_at,u.updated_at
|
||||
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.profile_fields,u.custom_moods,u.local,u.created_at,u.updated_at
|
||||
FROM users u JOIN follows f ON f.following_id=u.id
|
||||
WHERE f.follower_id=$1 AND f.state='accepted'
|
||||
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
|
||||
@@ -210,7 +210,7 @@ impl FollowRepository for PgFollowRepository {
|
||||
|
||||
let rows = sqlx::query_as::<_, crate::user::UserRow>(
|
||||
"SELECT u.id, u.username, u.email, u.password_hash, u.display_name, u.bio,
|
||||
u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.local,
|
||||
u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.custom_moods, u.local,
|
||||
u.created_at, u.updated_at
|
||||
FROM users u
|
||||
JOIN follows f1
|
||||
|
||||
@@ -37,6 +37,7 @@ async fn attach_and_list(pool: sqlx::PgPool) {
|
||||
visibility: Visibility::Public,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
mood: None,
|
||||
});
|
||||
trepo.save(&t).await.unwrap();
|
||||
let repo = PgTagRepository::new(pool);
|
||||
|
||||
@@ -31,6 +31,7 @@ pub async fn seed_user_and_thought(pool: &sqlx::PgPool) -> (User, Thought) {
|
||||
visibility: Visibility::Public,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
mood: None,
|
||||
});
|
||||
trepo.save(&t).await.unwrap();
|
||||
(user, t)
|
||||
|
||||
@@ -35,6 +35,7 @@ pub(crate) struct ThoughtRow {
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
pub note_extensions: Option<serde_json::Value>,
|
||||
pub mood: Option<String>,
|
||||
}
|
||||
|
||||
impl TryFrom<ThoughtRow> for Thought {
|
||||
@@ -52,19 +53,20 @@ impl TryFrom<ThoughtRow> for Thought {
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
note_extensions: r.note_extensions,
|
||||
mood: r.mood,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const THOUGHT_SELECT: &str =
|
||||
"SELECT id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at,updated_at,note_extensions FROM thoughts";
|
||||
"SELECT id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at,updated_at,note_extensions,mood FROM thoughts";
|
||||
|
||||
#[async_trait]
|
||||
impl ThoughtRepository for PgThoughtRepository {
|
||||
async fn save(&self, t: &Thought) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO thoughts(id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
"INSERT INTO thoughts(id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at,mood)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||
ON CONFLICT(id) DO UPDATE SET content=EXCLUDED.content,updated_at=NOW()"
|
||||
)
|
||||
.bind(t.id.as_uuid())
|
||||
@@ -76,6 +78,7 @@ impl ThoughtRepository for PgThoughtRepository {
|
||||
.bind(t.sensitive)
|
||||
.bind(t.local)
|
||||
.bind(t.created_at)
|
||||
.bind(&t.mood)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
@@ -119,11 +122,11 @@ impl ThoughtRepository for PgThoughtRepository {
|
||||
sqlx::query_as::<_, ThoughtRow>(
|
||||
"WITH RECURSIVE thread AS (
|
||||
SELECT id,user_id,content,in_reply_to_id,
|
||||
visibility,content_warning,sensitive,local,created_at,updated_at,note_extensions
|
||||
visibility,content_warning,sensitive,local,created_at,updated_at,note_extensions,mood
|
||||
FROM thoughts WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT t.id,t.user_id,t.content,t.in_reply_to_id,
|
||||
t.visibility,t.content_warning,t.sensitive,t.local,t.created_at,t.updated_at,t.note_extensions
|
||||
t.visibility,t.content_warning,t.sensitive,t.local,t.created_at,t.updated_at,t.note_extensions,t.mood
|
||||
FROM thoughts t JOIN thread ON t.in_reply_to_id = thread.id
|
||||
)
|
||||
SELECT * FROM thread ORDER BY created_at ASC",
|
||||
|
||||
@@ -14,6 +14,7 @@ async fn save_and_find_thought(pool: sqlx::PgPool) {
|
||||
visibility: Visibility::Public,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
mood: None,
|
||||
});
|
||||
repo.save(&t).await.unwrap();
|
||||
let found = repo.find_by_id(&t.id).await.unwrap().unwrap();
|
||||
@@ -33,6 +34,7 @@ async fn delete_thought(pool: sqlx::PgPool) {
|
||||
visibility: Visibility::Public,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
mood: None,
|
||||
});
|
||||
repo.save(&t).await.unwrap();
|
||||
repo.delete(&t.id, &user.id).await.unwrap();
|
||||
@@ -52,6 +54,7 @@ async fn delete_wrong_owner_returns_not_found(pool: sqlx::PgPool) {
|
||||
visibility: Visibility::Public,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
mood: None,
|
||||
});
|
||||
repo.save(&t).await.unwrap();
|
||||
let err = repo.delete(&t.id, &bob.id).await.unwrap_err();
|
||||
@@ -70,6 +73,7 @@ async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
|
||||
visibility: Visibility::Public,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
mood: None,
|
||||
});
|
||||
let reply = Thought::new_local(NewThought {
|
||||
id: ThoughtId::new(),
|
||||
@@ -79,6 +83,7 @@ async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
|
||||
visibility: Visibility::Public,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
mood: None,
|
||||
});
|
||||
repo.save(&root).await.unwrap();
|
||||
repo.save(&reply).await.unwrap();
|
||||
|
||||
@@ -54,7 +54,7 @@ impl TopFriendRepository for PgTopFriendRepository {
|
||||
let rows = sqlx::query_as::<_, TopFriendRow>(
|
||||
"SELECT tf.user_id AS tf_user_id, tf.friend_id, tf.position,
|
||||
u.id, u.username, u.email, u.password_hash, u.display_name, u.bio,
|
||||
u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.local,
|
||||
u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.custom_moods, u.local,
|
||||
u.created_at, u.updated_at
|
||||
FROM top_friends tf JOIN users u ON u.id=tf.friend_id
|
||||
WHERE tf.user_id=$1 ORDER BY tf.position",
|
||||
|
||||
@@ -32,6 +32,7 @@ pub struct UserRow {
|
||||
pub header_url: Option<String>,
|
||||
pub custom_css: Option<String>,
|
||||
pub profile_fields: Option<serde_json::Value>,
|
||||
pub custom_moods: Option<serde_json::Value>,
|
||||
pub local: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
@@ -50,6 +51,7 @@ impl From<UserRow> for User {
|
||||
header_url: r.header_url,
|
||||
custom_css: r.custom_css,
|
||||
profile_fields: crate::jsonb::parse_name_value(r.profile_fields),
|
||||
custom_moods: crate::jsonb::parse_name_value(r.custom_moods),
|
||||
local: r.local,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
@@ -59,7 +61,7 @@ impl From<UserRow> for User {
|
||||
|
||||
pub const USER_SELECT: &str =
|
||||
"SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,\
|
||||
custom_css,profile_fields,local,created_at,updated_at FROM users";
|
||||
custom_css,profile_fields,custom_moods,local,created_at,updated_at FROM users";
|
||||
|
||||
#[async_trait]
|
||||
impl UserReader for PgUserRepository {
|
||||
@@ -225,15 +227,17 @@ impl UserReader for PgUserRepository {
|
||||
impl UserWriter for PgUserRepository {
|
||||
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||
let profile_fields_json = crate::jsonb::serialize_name_value(&user.profile_fields);
|
||||
let custom_moods_json = crate::jsonb::serialize_name_value(&user.custom_moods);
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,profile_fields,local,created_at,updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
"INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,profile_fields,custom_moods,local,created_at,updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
username=EXCLUDED.username, email=EXCLUDED.email,
|
||||
password_hash=EXCLUDED.password_hash, display_name=EXCLUDED.display_name,
|
||||
bio=EXCLUDED.bio, avatar_url=EXCLUDED.avatar_url,
|
||||
header_url=EXCLUDED.header_url, custom_css=EXCLUDED.custom_css,
|
||||
profile_fields=EXCLUDED.profile_fields,
|
||||
custom_moods=EXCLUDED.custom_moods,
|
||||
local=EXCLUDED.local,
|
||||
updated_at=NOW()"
|
||||
)
|
||||
@@ -247,6 +251,7 @@ impl UserWriter for PgUserRepository {
|
||||
.bind(&user.header_url)
|
||||
.bind(&user.custom_css)
|
||||
.bind(&profile_fields_json)
|
||||
.bind(&custom_moods_json)
|
||||
.bind(user.local)
|
||||
.bind(user.created_at)
|
||||
.bind(user.updated_at)
|
||||
@@ -276,6 +281,10 @@ impl UserWriter for PgUserRepository {
|
||||
.profile_fields
|
||||
.as_ref()
|
||||
.map(|f| crate::jsonb::serialize_name_value(f));
|
||||
let custom_moods_json: Option<serde_json::Value> = input
|
||||
.custom_moods
|
||||
.as_ref()
|
||||
.map(|f| crate::jsonb::serialize_name_value(f));
|
||||
sqlx::query(
|
||||
"UPDATE users SET \
|
||||
display_name = COALESCE($2, display_name), \
|
||||
@@ -284,6 +293,7 @@ impl UserWriter for PgUserRepository {
|
||||
header_url = COALESCE($5, header_url), \
|
||||
custom_css = COALESCE($6, custom_css), \
|
||||
profile_fields = COALESCE($7, profile_fields), \
|
||||
custom_moods = COALESCE($8, custom_moods), \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1",
|
||||
)
|
||||
@@ -294,6 +304,7 @@ impl UserWriter for PgUserRepository {
|
||||
.bind(input.header_url)
|
||||
.bind(input.custom_css)
|
||||
.bind(profile_fields_json)
|
||||
.bind(custom_moods_json)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
|
||||
@@ -31,6 +31,7 @@ pub struct CreateThoughtRequest {
|
||||
pub visibility: Option<String>,
|
||||
pub content_warning: Option<String>,
|
||||
pub sensitive: Option<bool>,
|
||||
pub mood: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
@@ -48,6 +49,7 @@ pub struct UpdateProfileRequest {
|
||||
pub header_url: Option<String>,
|
||||
pub custom_css: Option<String>,
|
||||
pub profile_fields: Option<Vec<crate::responses::ProfileField>>,
|
||||
pub custom_moods: Option<Vec<crate::responses::ProfileField>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
|
||||
@@ -20,6 +20,7 @@ pub struct UserResponse {
|
||||
pub header_url: Option<String>,
|
||||
pub custom_css: Option<String>,
|
||||
pub profile_fields: Vec<ProfileField>,
|
||||
pub custom_moods: Vec<ProfileField>,
|
||||
pub local: bool,
|
||||
pub is_followed_by_viewer: bool,
|
||||
#[serde(rename = "joinedAt")]
|
||||
@@ -48,6 +49,8 @@ pub struct ThoughtResponse {
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub note_extensions: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mood: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
|
||||
@@ -5,7 +5,6 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
activitypub = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
@@ -21,3 +20,4 @@ futures = { workspace = true }
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
domain = { workspace = true, features = ["test-helpers"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use activitypub::{ActivityPubRepository, OutboundFederationPort};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::thought::Visibility,
|
||||
ports::{ThoughtRepository, UserReader},
|
||||
ports::{FederationBroadcastPort, FederationContentRepository, ThoughtRepository, UserReader},
|
||||
value_objects::ThoughtId,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
@@ -15,9 +14,9 @@ fn should_broadcast(t: &domain::models::thought::Thought) -> bool {
|
||||
pub struct FederationEventService {
|
||||
pub thoughts: Arc<dyn ThoughtRepository>,
|
||||
pub users: Arc<dyn UserReader>,
|
||||
pub ap: Arc<dyn OutboundFederationPort>,
|
||||
pub ap: Arc<dyn FederationBroadcastPort>,
|
||||
pub base_url: String,
|
||||
pub ap_repo: Arc<dyn ActivityPubRepository>,
|
||||
pub ap_repo: Arc<dyn FederationContentRepository>,
|
||||
}
|
||||
|
||||
impl FederationEventService {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::*;
|
||||
use crate::testing::TestApRepo;
|
||||
use activitypub::{ActorApUrls, OutboundFederationPort};
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{ActorFederationUrls, FederationBroadcastPort};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
@@ -27,7 +27,7 @@ struct SpyPort {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OutboundFederationPort for SpyPort {
|
||||
impl FederationBroadcastPort for SpyPort {
|
||||
async fn broadcast_create(
|
||||
&self,
|
||||
_: &UserId,
|
||||
@@ -100,6 +100,7 @@ fn local_thought(author_id: UserId) -> Thought {
|
||||
visibility: Visibility::Public,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
mood: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -283,6 +284,7 @@ async fn direct_thought_created_does_not_broadcast() {
|
||||
visibility: Visibility::Direct,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
mood: None,
|
||||
});
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
@@ -312,6 +314,7 @@ async fn followers_only_thought_does_not_broadcast_publicly() {
|
||||
visibility: Visibility::Followers,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
mood: None,
|
||||
});
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
@@ -479,7 +482,7 @@ async fn like_added_local_user_remote_thought_broadcasts_like() {
|
||||
let ap_repo = TestApRepo::new(store.clone());
|
||||
ap_repo.actor_ap_urls.lock().unwrap().insert(
|
||||
author.id.clone(),
|
||||
ActorApUrls {
|
||||
ActorFederationUrls {
|
||||
ap_id: "https://mastodon.social/users/author".into(),
|
||||
inbox_url: "https://mastodon.social/users/author/inbox".into(),
|
||||
},
|
||||
|
||||
@@ -32,6 +32,7 @@ async fn like_creates_notification_for_thought_author() {
|
||||
visibility: Visibility::Public,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
mood: None,
|
||||
});
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
let svc = NotificationEventService {
|
||||
@@ -62,6 +63,7 @@ async fn self_like_creates_no_notification() {
|
||||
visibility: Visibility::Public,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
mood: None,
|
||||
});
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
let svc = NotificationEventService {
|
||||
@@ -111,6 +113,7 @@ async fn reply_creates_notification_for_original_author() {
|
||||
visibility: Visibility::Public,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
mood: None,
|
||||
});
|
||||
store.thoughts.lock().unwrap().push(original.clone());
|
||||
let svc = NotificationEventService {
|
||||
@@ -141,6 +144,7 @@ async fn self_reply_creates_no_notification() {
|
||||
visibility: Visibility::Public,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
mood: None,
|
||||
});
|
||||
store.thoughts.lock().unwrap().push(original.clone());
|
||||
let svc = NotificationEventService {
|
||||
@@ -169,6 +173,7 @@ async fn self_boost_creates_no_notification() {
|
||||
visibility: Visibility::Public,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
mood: None,
|
||||
});
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
let svc = NotificationEventService {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/// Test helpers for application-layer tests that need activitypub traits.
|
||||
use activitypub::{ActivityPubRepository, ActorApUrls, OutboxEntry};
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::user::User,
|
||||
ports::{AcceptNoteInput, ActorFederationUrls, FederationContentRepository, OutboxEntry},
|
||||
testing::TestStore,
|
||||
value_objects::{Email, ThoughtId, UserId, Username},
|
||||
};
|
||||
@@ -14,8 +13,8 @@ use std::sync::{Arc, Mutex};
|
||||
#[derive(Default, Clone)]
|
||||
pub struct TestApRepo {
|
||||
pub inner: TestStore,
|
||||
/// UserId → ActorApUrls (for get_actor_ap_urls)
|
||||
pub actor_ap_urls: Arc<Mutex<HashMap<UserId, ActorApUrls>>>,
|
||||
/// UserId → ActorFederationUrls (for get_actor_ap_urls)
|
||||
pub actor_ap_urls: Arc<Mutex<HashMap<UserId, ActorFederationUrls>>>,
|
||||
}
|
||||
|
||||
impl TestApRepo {
|
||||
@@ -28,7 +27,7 @@ impl TestApRepo {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ActivityPubRepository for TestApRepo {
|
||||
impl FederationContentRepository for TestApRepo {
|
||||
async fn outbox_entries_for_actor(
|
||||
&self,
|
||||
_uid: &UserId,
|
||||
@@ -84,13 +83,15 @@ impl ActivityPubRepository for TestApRepo {
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn accept_note(
|
||||
&self,
|
||||
_input: activitypub::AcceptNoteInput<'_>,
|
||||
) -> Result<ThoughtId, DomainError> {
|
||||
async fn accept_note(&self, _input: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError> {
|
||||
Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4()))
|
||||
}
|
||||
async fn apply_note_update(&self, _ap_id: &str, _new_content: &str) -> Result<(), DomainError> {
|
||||
async fn apply_note_update(
|
||||
&self,
|
||||
_ap_id: &str,
|
||||
_new_content: &str,
|
||||
_: Option<serde_json::Value>,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {
|
||||
@@ -124,7 +125,7 @@ impl ActivityPubRepository for TestApRepo {
|
||||
async fn get_actor_ap_urls(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Option<ActorApUrls>, DomainError> {
|
||||
) -> Result<Option<ActorFederationUrls>, DomainError> {
|
||||
Ok(self.actor_ap_urls.lock().unwrap().get(user_id).cloned())
|
||||
}
|
||||
async fn sync_remote_actor_to_user(&self, _actor_ap_url: &str) -> Result<(), DomainError> {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use activitypub::ActivityPubRepository;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
@@ -8,9 +7,10 @@ use domain::{
|
||||
remote_actor::RemoteActor,
|
||||
},
|
||||
ports::{
|
||||
EventPublisher, FederationActionPort, FederationFollowPort, FederationFollowRequestPort,
|
||||
FederationSchedulerPort, FeedOptions, FeedQuery, FeedRepository, FeedRequest,
|
||||
FollowRepository, RemoteActorConnectionRepository, UserReader, UserWriter,
|
||||
EventPublisher, FederationActionPort, FederationContentRepository, FederationFollowPort,
|
||||
FederationFollowRequestPort, FederationSchedulerPort, FeedOptions, FeedQuery,
|
||||
FeedRepository, FeedRequest, FollowRepository, RemoteActorConnectionRepository, UserReader,
|
||||
UserWriter,
|
||||
},
|
||||
value_objects::UserId,
|
||||
};
|
||||
@@ -119,7 +119,7 @@ pub async fn remove_remote_following(
|
||||
|
||||
pub async fn get_remote_actor_posts(
|
||||
federation: &dyn FederationActionPort,
|
||||
ap_repo: &dyn ActivityPubRepository,
|
||||
ap_repo: &dyn FederationContentRepository,
|
||||
feed: &dyn FeedRepository,
|
||||
scheduler: &dyn FederationSchedulerPort,
|
||||
handle: &str,
|
||||
|
||||
@@ -2,6 +2,9 @@ const MAX_TOP_FRIENDS: usize = 8;
|
||||
const MAX_PROFILE_FIELDS: usize = 4;
|
||||
const MAX_FIELD_NAME_LEN: usize = 64;
|
||||
const MAX_FIELD_VALUE_LEN: usize = 256;
|
||||
const MAX_CUSTOM_MOODS: usize = 8;
|
||||
const MAX_MOOD_LABEL_LEN: usize = 32;
|
||||
const MAX_MOOD_EMOJI_LEN: usize = 8;
|
||||
|
||||
use bytes::Bytes;
|
||||
use domain::{
|
||||
@@ -72,6 +75,20 @@ pub async fn update_profile(
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ref moods) = input.custom_moods {
|
||||
if moods.len() > MAX_CUSTOM_MOODS {
|
||||
return Err(DomainError::InvalidInput(format!(
|
||||
"custom moods: max {MAX_CUSTOM_MOODS}"
|
||||
)));
|
||||
}
|
||||
for (label, emoji) in moods {
|
||||
if label.len() > MAX_MOOD_LABEL_LEN || emoji.len() > MAX_MOOD_EMOJI_LEN {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"custom mood label or emoji too long".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
users.update_profile(user_id, input).await?;
|
||||
events
|
||||
.publish(&DomainEvent::ProfileUpdated {
|
||||
|
||||
@@ -34,6 +34,7 @@ async fn like_and_unlike() {
|
||||
visibility: Visibility::Public,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
mood: None,
|
||||
}));
|
||||
like_thought(&store, &store, &alice.id, &tid).await.unwrap();
|
||||
assert_eq!(store.likes.lock().unwrap().len(), 1);
|
||||
|
||||
@@ -26,6 +26,7 @@ pub struct CreateThoughtInput {
|
||||
pub visibility: Option<String>,
|
||||
pub content_warning: Option<String>,
|
||||
pub sensitive: bool,
|
||||
pub mood: Option<String>,
|
||||
}
|
||||
pub struct CreateThoughtOutput {
|
||||
pub thought: Thought,
|
||||
@@ -39,6 +40,11 @@ pub async fn create_thought(
|
||||
outbox: &dyn OutboxWriter,
|
||||
input: CreateThoughtInput,
|
||||
) -> Result<CreateThoughtOutput, DomainError> {
|
||||
if let Some(ref m) = input.mood {
|
||||
if m.len() > 64 {
|
||||
return Err(DomainError::InvalidInput("mood: max 64 chars".into()));
|
||||
}
|
||||
}
|
||||
let content = Content::new_local(input.content)?;
|
||||
let visibility = match input.visibility.as_deref() {
|
||||
Some("followers") => Visibility::Followers,
|
||||
@@ -54,6 +60,7 @@ pub async fn create_thought(
|
||||
visibility,
|
||||
content_warning: input.content_warning,
|
||||
sensitive: input.sensitive,
|
||||
mood: input.mood,
|
||||
});
|
||||
thoughts.save(&thought).await?;
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ fn input(uid: UserId) -> CreateThoughtInput {
|
||||
visibility: None,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
mood: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +208,7 @@ async fn create_reply_sets_in_reply_to_id() {
|
||||
visibility: None,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
mood: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -243,6 +245,7 @@ fn make_thought(user_id: UserId) -> Thought {
|
||||
visibility: Visibility::Public,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
mood: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -295,6 +298,7 @@ async fn get_thread_views_batches_correctly() {
|
||||
visibility: Visibility::Public,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
mood: None,
|
||||
});
|
||||
<TestStore as ThoughtRepository>::save(&store, &reply)
|
||||
.await
|
||||
|
||||
@@ -14,7 +14,7 @@ postgres = { workspace = true }
|
||||
postgres-search = { workspace = true }
|
||||
postgres-federation = { workspace = true }
|
||||
activitypub = { workspace = true }
|
||||
k-ap = { version = "0.3.1", registry = "gitea" }
|
||||
k-ap = { version = "0.4.0", registry = "gitea" }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
nats = { workspace = true }
|
||||
|
||||
@@ -31,12 +31,12 @@ impl Config {
|
||||
Self {
|
||||
database_url: std::env::var("DATABASE_URL").expect("DATABASE_URL is required"),
|
||||
jwt_secret: std::env::var("JWT_SECRET").expect("JWT_SECRET is required"),
|
||||
base_url: std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()),
|
||||
base_url: std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:8000".into()),
|
||||
nats_url: std::env::var("NATS_URL").ok(),
|
||||
port: std::env::var("PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(3000),
|
||||
.unwrap_or(8000),
|
||||
allow_registration: std::env::var("ALLOW_REGISTRATION")
|
||||
.map(|v| v == "true")
|
||||
.unwrap_or(true),
|
||||
|
||||
@@ -22,6 +22,7 @@ pub struct Thought {
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
pub note_extensions: Option<serde_json::Value>,
|
||||
pub mood: Option<String>,
|
||||
}
|
||||
|
||||
impl Visibility {
|
||||
@@ -55,6 +56,7 @@ pub struct NewThought {
|
||||
pub visibility: Visibility,
|
||||
pub content_warning: Option<String>,
|
||||
pub sensitive: bool,
|
||||
pub mood: Option<String>,
|
||||
}
|
||||
|
||||
impl Thought {
|
||||
@@ -71,6 +73,7 @@ impl Thought {
|
||||
created_at: Utc::now(),
|
||||
updated_at: None,
|
||||
note_extensions: None,
|
||||
mood: p.mood,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ pub struct UpdateProfileInput {
|
||||
pub header_url: Option<String>,
|
||||
pub custom_css: Option<String>,
|
||||
pub profile_fields: Option<Vec<(String, String)>>,
|
||||
pub custom_moods: Option<Vec<(String, String)>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -23,6 +24,7 @@ pub struct User {
|
||||
pub header_url: Option<String>,
|
||||
pub custom_css: Option<String>,
|
||||
pub profile_fields: Vec<(String, String)>,
|
||||
pub custom_moods: Vec<(String, String)>,
|
||||
pub local: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
@@ -47,6 +49,7 @@ impl User {
|
||||
header_url: None,
|
||||
custom_css: None,
|
||||
profile_fields: vec![],
|
||||
custom_moods: vec![],
|
||||
local: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
@@ -66,6 +69,7 @@ impl User {
|
||||
header_url: None,
|
||||
custom_css: None,
|
||||
profile_fields: vec![],
|
||||
custom_moods: vec![],
|
||||
local: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
|
||||
@@ -511,3 +511,136 @@ pub trait FederationSchedulerPort: Send + Sync {
|
||||
page: u32,
|
||||
) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
// ── Federation content & broadcast ports ────────────────────────────────
|
||||
|
||||
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>,
|
||||
pub note_extensions: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActorFederationUrls {
|
||||
pub ap_id: String,
|
||||
pub inbox_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OutboxEntry {
|
||||
pub thought: Thought,
|
||||
pub author_username: Username,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait FederationContentRepository: Send + Sync {
|
||||
async fn outbox_entries_for_actor(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<OutboxEntry>, DomainError>;
|
||||
|
||||
async fn outbox_page_for_actor(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<OutboxEntry>, DomainError>;
|
||||
|
||||
async fn find_remote_actor_id(&self, actor_ap_url: &str)
|
||||
-> Result<Option<UserId>, DomainError>;
|
||||
|
||||
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError>;
|
||||
|
||||
async fn update_remote_actor_display(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
display_name: Option<&str>,
|
||||
avatar_url: Option<&str>,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
async fn accept_note(&self, input: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError>;
|
||||
|
||||
async fn apply_note_update(
|
||||
&self,
|
||||
ap_id: &str,
|
||||
new_content: &str,
|
||||
note_extensions: Option<serde_json::Value>,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
async fn retract_note(&self, ap_id: &str) -> Result<(), DomainError>;
|
||||
|
||||
async fn retract_actor_notes(&self, actor_ap_url: &str) -> Result<(), DomainError>;
|
||||
|
||||
async fn count_local_notes(&self) -> Result<u64, DomainError>;
|
||||
|
||||
async fn get_thought_ap_id(
|
||||
&self,
|
||||
thought_id: &ThoughtId,
|
||||
) -> Result<Option<String>, DomainError>;
|
||||
|
||||
async fn get_actor_ap_urls(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Option<ActorFederationUrls>, DomainError>;
|
||||
|
||||
async fn sync_remote_actor_to_user(&self, actor_ap_url: &str) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait FederationBroadcastPort: Send + Sync {
|
||||
async fn broadcast_create(
|
||||
&self,
|
||||
author_user_id: &UserId,
|
||||
thought: &Thought,
|
||||
author_username: &str,
|
||||
in_reply_to_url: Option<&str>,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
async fn broadcast_delete(
|
||||
&self,
|
||||
author_user_id: &UserId,
|
||||
thought_ap_id: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
async fn broadcast_update(
|
||||
&self,
|
||||
author_user_id: &UserId,
|
||||
thought: &Thought,
|
||||
author_username: &str,
|
||||
in_reply_to_url: Option<&str>,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
async fn broadcast_announce(
|
||||
&self,
|
||||
booster_user_id: &UserId,
|
||||
object_ap_id: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
async fn broadcast_undo_announce(
|
||||
&self,
|
||||
booster_user_id: &UserId,
|
||||
object_ap_id: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
async fn broadcast_like(
|
||||
&self,
|
||||
liker_user_id: &UserId,
|
||||
object_ap_id: &str,
|
||||
author_inbox_url: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
async fn broadcast_undo_like(
|
||||
&self,
|
||||
liker_user_id: &UserId,
|
||||
object_ap_id: &str,
|
||||
author_inbox_url: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
activitypub = { workspace = true }
|
||||
application = { workspace = true }
|
||||
api-types = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
|
||||
@@ -32,6 +32,14 @@ pub fn to_user_response(u: &domain::models::user::User) -> UserResponse {
|
||||
value: v.clone(),
|
||||
})
|
||||
.collect(),
|
||||
custom_moods: u
|
||||
.custom_moods
|
||||
.iter()
|
||||
.map(|(n, v)| ProfileField {
|
||||
name: n.clone(),
|
||||
value: v.clone(),
|
||||
})
|
||||
.collect(),
|
||||
local: u.local,
|
||||
is_followed_by_viewer: false,
|
||||
created_at: u.created_at,
|
||||
@@ -48,6 +56,7 @@ pub fn to_summary_response(u: &UserSummary) -> UserResponse {
|
||||
header_url: None,
|
||||
custom_css: None,
|
||||
profile_fields: vec![],
|
||||
custom_moods: vec![],
|
||||
local: true,
|
||||
is_followed_by_viewer: false,
|
||||
created_at: chrono::Utc::now(),
|
||||
|
||||
@@ -4,7 +4,6 @@ use crate::{
|
||||
handlers::feed::to_thought_response,
|
||||
state::AppState,
|
||||
};
|
||||
use activitypub::ActivityPubRepository;
|
||||
use api_types::{
|
||||
requests::PaginationQuery,
|
||||
responses::{
|
||||
@@ -18,6 +17,7 @@ use axum::{
|
||||
extract::{Path, Query},
|
||||
Json,
|
||||
};
|
||||
use domain::ports::FederationContentRepository;
|
||||
use domain::{
|
||||
models::feed::PageParams,
|
||||
ports::{
|
||||
@@ -29,7 +29,7 @@ use std::sync::Arc;
|
||||
|
||||
pub struct FederationActorsDeps {
|
||||
pub federation: Arc<dyn FederationActionPort>,
|
||||
pub ap_repo: Arc<dyn ActivityPubRepository>,
|
||||
pub ap_repo: Arc<dyn FederationContentRepository>,
|
||||
pub feed: Arc<dyn FeedRepository>,
|
||||
pub federation_scheduler: Arc<dyn FederationSchedulerPort>,
|
||||
pub remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>,
|
||||
|
||||
@@ -109,6 +109,7 @@ pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtRespon
|
||||
created_at: e.thought.created_at,
|
||||
updated_at: e.thought.updated_at,
|
||||
note_extensions: e.thought.note_extensions.clone(),
|
||||
mood: e.thought.mood.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ pub async fn post_thought(
|
||||
visibility: body.visibility,
|
||||
content_warning: body.content_warning,
|
||||
sensitive: body.sensitive.unwrap_or(false),
|
||||
mood: body.mood,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -117,6 +117,9 @@ pub async fn patch_profile(
|
||||
profile_fields: body
|
||||
.profile_fields
|
||||
.map(|f| f.into_iter().map(|pf| (pf.name, pf.value)).collect()),
|
||||
custom_moods: body
|
||||
.custom_moods
|
||||
.map(|f| f.into_iter().map(|pf| (pf.name, pf.value)).collect()),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use activitypub::ActivityPubRepository;
|
||||
use application::use_cases::profile::UploadConfig;
|
||||
use domain::ports::*;
|
||||
use std::sync::Arc;
|
||||
@@ -24,7 +23,7 @@ pub struct AppState {
|
||||
pub events: Arc<dyn EventPublisher>,
|
||||
pub outbox: Arc<dyn OutboxWriter>,
|
||||
pub federation: Arc<dyn FederationActionPort>,
|
||||
pub ap_repo: Arc<dyn ActivityPubRepository>,
|
||||
pub ap_repo: Arc<dyn FederationContentRepository>,
|
||||
pub remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>,
|
||||
pub federation_scheduler: Arc<dyn FederationSchedulerPort>,
|
||||
pub engagement: Arc<dyn EngagementRepository>,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use crate::state::AppState;
|
||||
use activitypub::{ActivityPubRepository, ActorApUrls, OutboxEntry};
|
||||
use application::use_cases::profile::UploadConfig;
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
AcceptNoteInput, ActorFederationUrls, FederationContentRepository, OutboxEntry,
|
||||
};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{AuthService, DataStream, GeneratedToken, MediaStore, PasswordHasher},
|
||||
@@ -34,7 +36,7 @@ impl PasswordHasher for NoOpHasher {
|
||||
pub struct NoOpApRepo;
|
||||
|
||||
#[async_trait]
|
||||
impl ActivityPubRepository for NoOpApRepo {
|
||||
impl FederationContentRepository for NoOpApRepo {
|
||||
async fn outbox_entries_for_actor(&self, _: &UserId) -> Result<Vec<OutboxEntry>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
@@ -60,13 +62,15 @@ impl ActivityPubRepository for NoOpApRepo {
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn accept_note(
|
||||
&self,
|
||||
_: activitypub::AcceptNoteInput<'_>,
|
||||
) -> Result<ThoughtId, DomainError> {
|
||||
async fn accept_note(&self, _: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError> {
|
||||
Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4()))
|
||||
}
|
||||
async fn apply_note_update(&self, _: &str, _: &str) -> Result<(), DomainError> {
|
||||
async fn apply_note_update(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: Option<serde_json::Value>,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn retract_note(&self, _: &str) -> Result<(), DomainError> {
|
||||
@@ -81,7 +85,10 @@ impl ActivityPubRepository for NoOpApRepo {
|
||||
async fn get_thought_ap_id(&self, _: &ThoughtId) -> Result<Option<String>, DomainError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn get_actor_ap_urls(&self, _: &UserId) -> Result<Option<ActorApUrls>, DomainError> {
|
||||
async fn get_actor_ap_urls(
|
||||
&self,
|
||||
_: &UserId,
|
||||
) -> Result<Option<ActorFederationUrls>, DomainError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn sync_remote_actor_to_user(&self, _: &str) -> Result<(), DomainError> {
|
||||
|
||||
@@ -13,7 +13,7 @@ application = { workspace = true }
|
||||
nats = { workspace = true }
|
||||
event-transport = { workspace = true }
|
||||
event-payload = { workspace = true }
|
||||
k-ap = { version = "0.3.1", registry = "gitea" }
|
||||
k-ap = { version = "0.4.0", registry = "gitea" }
|
||||
activitypub = { workspace = true }
|
||||
postgres = { workspace = true }
|
||||
postgres-federation = { workspace = true }
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
# Movies-Diary First-Class Integration
|
||||
|
||||
Since thoughts and movies-diary are both owned projects, movies-diary can be treated as a first-class citizen with deep, structured integration rather than a generic ActivityPub instance.
|
||||
|
||||
## Core idea
|
||||
|
||||
Add a custom ActivityPub `@context` extension to movies-diary's AP notes that carries structured movie review data. Thoughts understands this extension and renders movie review posts as rich cards instead of plain text. Movies-diary actor profiles in thoughts get a dedicated "Movie Diary" layout.
|
||||
|
||||
---
|
||||
|
||||
## Feature 1 — Custom AP Extension for Movie Reviews
|
||||
|
||||
### movies-diary side
|
||||
|
||||
Extend the AP Note with a `movies-diary` namespace in `@context`:
|
||||
|
||||
```json
|
||||
{
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
{
|
||||
"md": "https://movies.gabrielkaszewski.dev/ns#",
|
||||
"movieReview": "md:movieReview",
|
||||
"movieTitle": "md:movieTitle",
|
||||
"movieYear": "md:movieYear",
|
||||
"rating": "md:rating",
|
||||
"maxRating": "md:maxRating",
|
||||
"watchedAt": "md:watchedAt",
|
||||
"posterUrl": "md:posterUrl",
|
||||
"tmdbId": "md:tmdbId"
|
||||
}
|
||||
],
|
||||
"type": "Note",
|
||||
"movieReview": true,
|
||||
"movieTitle": "Eternals",
|
||||
"movieYear": 2021,
|
||||
"rating": 3,
|
||||
"maxRating": 5,
|
||||
"watchedAt": "2025-09-30",
|
||||
"posterUrl": "https://image.tmdb.org/t/p/w300/...",
|
||||
"tmdbId": 524434,
|
||||
"content": "<p>⭐⭐⭐ Eternals (2021) Watched: Sep 30, 2025</p>"
|
||||
}
|
||||
```
|
||||
|
||||
The `content` field keeps the plain-text fallback so the post still renders correctly in any standard AP client.
|
||||
|
||||
### thoughts side
|
||||
|
||||
When fetching remote notes in `fetch_outbox_page`, detect the extension fields and store the structured data alongside the note. This requires:
|
||||
|
||||
- A new `remote_note_meta` table (or a JSON column on `thoughts`) for: `movie_title`, `movie_year`, `rating`, `max_rating`, `watched_at`, `poster_url`, `tmdb_id`
|
||||
- A new domain model field or separate `MovieReviewMeta` struct
|
||||
- The thought card in the frontend checks for this metadata and renders a `MovieReviewCard` component instead of plain text
|
||||
|
||||
### `MovieReviewCard` component
|
||||
|
||||
Shows:
|
||||
- Movie poster (from `posterUrl`)
|
||||
- Title + year
|
||||
- Star rating (visual, not emoji)
|
||||
- Watched date
|
||||
- Optional review text (the `content` stripped of the auto-generated prefix)
|
||||
- Link to the movie on the user's movies-diary instance
|
||||
|
||||
---
|
||||
|
||||
## Feature 2 — Dedicated Movies-Diary Actor Profile
|
||||
|
||||
When viewing an actor profile from a movies-diary instance (detected by actor URL domain or a custom AP actor field), the profile page shows a "Movie Diary" layout instead of the generic remote actor profile.
|
||||
|
||||
### Detection
|
||||
|
||||
Add a custom field to movies-diary's AP `Person` object:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Person",
|
||||
"md:softwareName": "movies-diary",
|
||||
"md:instanceUrl": "https://movies.gabrielkaszewski.dev"
|
||||
}
|
||||
```
|
||||
|
||||
Thoughts checks for `md:softwareName = "movies-diary"` and switches to the dedicated layout.
|
||||
|
||||
### Movie Diary profile layout
|
||||
|
||||
- **Header**: same avatar/banner/bio/follow button as the generic profile
|
||||
- **Stats bar**: Total reviews · Watchlist size · Avg rating
|
||||
- **Recent reviews grid**: Movie poster cards (not a feed of text posts) — each shows poster, title, year, rating, watched date
|
||||
- **Tabs**: Recent Reviews | Watchlist | Following (other movie diary users)
|
||||
- **Watchlist tab**: Shows movies marked as "want to watch" (requires a custom AP Collection type: `md:Watchlist`)
|
||||
|
||||
### API
|
||||
|
||||
The movies-diary instance exposes custom AP endpoints that thoughts can call (since it owns both):
|
||||
|
||||
- `GET /ap/users/{username}/watchlist` — returns AP OrderedCollection of watchlist items (with `md:` fields)
|
||||
- `GET /ap/users/{username}/reviews?page=1` — returns AP OrderedCollectionPage of reviews (rich notes)
|
||||
|
||||
Thoughts fetches these when rendering the movie diary profile, similar to how it fetches the outbox.
|
||||
|
||||
---
|
||||
|
||||
## Implementation order (when ready)
|
||||
|
||||
1. Define and document the `md:` namespace schema in movies-diary
|
||||
2. Emit `md:` fields on movies-diary AP notes and Person objects
|
||||
3. Extend thoughts `fetch_outbox_page` to parse and store `md:` fields
|
||||
4. Build `MovieReviewCard` frontend component
|
||||
5. Add detection logic for movies-diary actors
|
||||
6. Build the dedicated Movie Diary profile layout + watchlist/reviews tabs
|
||||
7. Implement the custom AP endpoints on movies-diary side
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- The `content` fallback in AP notes ensures movies-diary posts remain readable in Mastodon, Pleroma, and any other standard client — the extension is additive
|
||||
- The `md:` namespace URL should resolve to a JSON-LD context document for proper AP compliance
|
||||
- Authentication between thoughts and movies-diary can use the existing AP HTTP signatures, so no separate auth system is needed
|
||||
- TMDB poster URLs may require a TMDB API key on movies-diary's side; thoughts just stores and displays the URL
|
||||
@@ -114,7 +114,7 @@ async function FeedPage({
|
||||
<header className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-shadow-sm">Your Feed</h1>
|
||||
</header>
|
||||
<ThoughtForm />
|
||||
<ThoughtForm currentUser={me} />
|
||||
|
||||
<div className="block lg:hidden space-y-6">{sidebar}</div>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export const metadata: Metadata = {
|
||||
import { redirect } from "next/navigation";
|
||||
import { getMe } from "@/lib/api";
|
||||
import { EditProfileForm } from "@/components/edit-profile-form";
|
||||
import { CustomMoodsEditor } from "@/components/custom-moods-editor";
|
||||
|
||||
export default async function EditProfilePage() {
|
||||
const token = (await cookies()).get("auth_token")?.value;
|
||||
@@ -32,6 +33,7 @@ export default async function EditProfilePage() {
|
||||
</p>
|
||||
</div>
|
||||
<EditProfileForm currentUser={me} token={token} />
|
||||
<CustomMoodsEditor initial={me.customMoods} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
99
thoughts-frontend/components/custom-moods-editor.tsx
Normal file
99
thoughts-frontend/components/custom-moods-editor.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { updateProfile, type ProfileField } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
|
||||
const MAX_MOODS = 8;
|
||||
|
||||
export function CustomMoodsEditor({
|
||||
initial,
|
||||
}: {
|
||||
initial: ProfileField[];
|
||||
}) {
|
||||
const { token } = useAuth();
|
||||
const [moods, setMoods] = useState<ProfileField[]>(initial);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const update = (i: number, key: "name" | "value", val: string) => {
|
||||
setMoods((prev) => prev.map((f, j) => (j === i ? { ...f, [key]: val } : f)));
|
||||
};
|
||||
|
||||
const add = () => {
|
||||
if (moods.length >= MAX_MOODS) return;
|
||||
setMoods((prev) => [...prev, { name: "", value: "" }]);
|
||||
};
|
||||
|
||||
const remove = (i: number) => {
|
||||
setMoods((prev) => prev.filter((_, j) => j !== i));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!token) return;
|
||||
const clean = moods.filter((f) => f.name.trim() || f.value.trim());
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateProfile({ customMoods: clean }, token);
|
||||
setMoods(clean);
|
||||
toast.success("Custom moods saved.");
|
||||
} catch {
|
||||
toast.error("Failed to save custom moods.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-effect glossy-effect bottom rounded-md shadow-fa-lg p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Custom moods</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add up to {MAX_MOODS} custom moods. These appear alongside the
|
||||
predefined moods when composing a thought.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{moods.map((f, i) => (
|
||||
<div key={i} className="flex gap-2 items-center">
|
||||
<Input
|
||||
value={f.name}
|
||||
onChange={(e) => update(i, "name", e.target.value)}
|
||||
placeholder="Mood name"
|
||||
className="max-w-[10rem] text-sm"
|
||||
/>
|
||||
<Input
|
||||
value={f.value}
|
||||
onChange={(e) => update(i, "value", e.target.value)}
|
||||
placeholder="Emoji"
|
||||
className="max-w-[5rem] text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => remove(i)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{moods.length < MAX_MOODS && (
|
||||
<Button variant="outline" size="sm" onClick={add}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Add mood
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={save} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -223,6 +223,11 @@ export function ThoughtCard({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(thought.mood || (meta?.mood as string | undefined)) && (
|
||||
<p className="text-xs text-muted-foreground italic mt-2">
|
||||
feeling {thought.mood || (meta?.mood as string)}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{token && (
|
||||
@@ -244,6 +249,7 @@ export function ThoughtCard({
|
||||
<ThoughtForm
|
||||
replyToId={thought.id}
|
||||
onSuccess={() => setIsReplyOpen(false)}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,45 +1,86 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
} from "@/components/ui/form";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { CreateThoughtSchema } from "@/lib/api"
|
||||
import { useAuth } from "@/hooks/use-auth"
|
||||
import { toast } from "sonner"
|
||||
import { Globe, Lock, Users } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { Confetti } from "./confetti"
|
||||
import { createThought } from "@/app/actions/thoughts"
|
||||
} from "@/components/ui/select";
|
||||
import { CreateThoughtSchema, type Me } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { toast } from "sonner";
|
||||
import { Globe, Lock, Users } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Confetti } from "./confetti";
|
||||
import { createThought } from "@/app/actions/thoughts";
|
||||
|
||||
const DEFAULT_MOODS = [
|
||||
"relaxed 😌",
|
||||
"happy 😊",
|
||||
"excited 🤩",
|
||||
"grateful 🙏",
|
||||
"inspired ✨",
|
||||
"thoughtful 🤔",
|
||||
"curious 🧐",
|
||||
"amused 😄",
|
||||
"proud 💪",
|
||||
"hopeful 🌟",
|
||||
"tired 😴",
|
||||
"stressed 😰",
|
||||
"anxious 😟",
|
||||
"sad 😢",
|
||||
"frustrated 😤",
|
||||
"angry 😠",
|
||||
"bored 😑",
|
||||
"confused 😕",
|
||||
"nostalgic 🥹",
|
||||
"silly 🤪",
|
||||
];
|
||||
|
||||
interface ThoughtFormProps {
|
||||
/** Set to the parent thought ID when composing a reply. */
|
||||
replyToId?: string
|
||||
replyToId?: string;
|
||||
/** Called after successful submit (e.g. close the reply panel). */
|
||||
onSuccess?: () => void
|
||||
onSuccess?: () => void;
|
||||
/** Whether to wrap in a Card. Defaults to true when no replyToId. */
|
||||
card?: boolean
|
||||
card?: boolean;
|
||||
currentUser?: Me | null;
|
||||
}
|
||||
|
||||
export function ThoughtForm({ replyToId, onSuccess, card = !replyToId }: ThoughtFormProps) {
|
||||
const { token } = useAuth()
|
||||
const [showConfetti, setShowConfetti] = useState(false)
|
||||
export function ThoughtForm({
|
||||
replyToId,
|
||||
onSuccess,
|
||||
card = !replyToId,
|
||||
currentUser,
|
||||
}: ThoughtFormProps) {
|
||||
const { token } = useAuth();
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
|
||||
const allMoods = [
|
||||
...DEFAULT_MOODS,
|
||||
...(currentUser?.customMoods ?? [])
|
||||
.filter(
|
||||
(m) =>
|
||||
!DEFAULT_MOODS.some((d) =>
|
||||
d.toLowerCase().startsWith(m.name.toLowerCase()),
|
||||
),
|
||||
)
|
||||
.map((m) => `${m.name} ${m.value}`),
|
||||
];
|
||||
|
||||
const form = useForm<z.infer<typeof CreateThoughtSchema>>({
|
||||
resolver: zodResolver(CreateThoughtSchema),
|
||||
@@ -48,21 +89,23 @@ export function ThoughtForm({ replyToId, onSuccess, card = !replyToId }: Thought
|
||||
visibility: "public",
|
||||
...(replyToId ? { inReplyToId: replyToId } : {}),
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof CreateThoughtSchema>) {
|
||||
if (!token) {
|
||||
toast.error("You must be logged in.")
|
||||
return
|
||||
toast.error("You must be logged in.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createThought(values)
|
||||
toast.success(replyToId ? "Reply posted!" : "Thought posted!")
|
||||
setShowConfetti(true)
|
||||
form.reset()
|
||||
onSuccess?.()
|
||||
await createThought(values);
|
||||
toast.success(replyToId ? "Reply posted!" : "Thought posted!");
|
||||
setShowConfetti(true);
|
||||
form.reset();
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
toast.error(replyToId ? "Failed to post reply." : "Failed to post thought.")
|
||||
toast.error(
|
||||
replyToId ? "Failed to post reply." : "Failed to post thought.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +119,9 @@ export function ThoughtForm({ replyToId, onSuccess, card = !replyToId }: Thought
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={replyToId ? "Post your reply..." : "What's on your mind?"}
|
||||
placeholder={
|
||||
replyToId ? "Post your reply..." : "What's on your mind?"
|
||||
}
|
||||
className={`resize-none ${replyToId ? "bg-white shadow-fa-sm" : ""}`}
|
||||
{...field}
|
||||
/>
|
||||
@@ -85,58 +130,110 @@ export function ThoughtForm({ replyToId, onSuccess, card = !replyToId }: Thought
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className={`flex ${replyToId ? "justify-end gap-2" : "justify-between items-center"}`}>
|
||||
<div
|
||||
className={`flex ${replyToId ? "justify-end gap-2" : "justify-between items-center"}`}
|
||||
>
|
||||
<div className="flex gap-2 flex-col md:flex-row">
|
||||
{!replyToId && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="visibility"
|
||||
render={({ field }) => (
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectTrigger className="w-[170px]">
|
||||
<SelectValue placeholder="Visibility" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="public">
|
||||
<div className="flex items-center gap-2"><Globe className="h-4 w-4" /> Public</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" /> Public
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="followers">
|
||||
<div className="flex items-center gap-2"><Users className="h-4 w-4" /> Followers</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4" /> Followers
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="unlisted">
|
||||
<div className="flex items-center gap-2"><Lock className="h-4 w-4" /> Unlisted</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="h-4 w-4" /> Unlisted
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="direct">
|
||||
<div className="flex items-center gap-2"><Lock className="h-4 w-4" /> Direct</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="h-4 w-4" /> Direct
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mood"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
onValueChange={(v) =>
|
||||
field.onChange(v === "__none__" ? undefined : v)
|
||||
}
|
||||
value={field.value ?? "__none__"}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-[170px]">
|
||||
<SelectValue placeholder="How are you feeling?" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No mood</SelectItem>
|
||||
{allMoods.map((mood) => (
|
||||
<SelectItem key={mood} value={mood}>
|
||||
{mood}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{replyToId && (
|
||||
<Button type="button" variant="ghost" onClick={() => onSuccess?.()}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onSuccess?.()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting
|
||||
? (replyToId ? "Replying..." : "Posting...")
|
||||
: (replyToId ? "Reply" : "Post Thought")}
|
||||
? replyToId
|
||||
? "Replying..."
|
||||
: "Posting..."
|
||||
: replyToId
|
||||
? "Reply"
|
||||
: "Post Thought"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Confetti fire={showConfetti} onComplete={() => setShowConfetti(false)} />
|
||||
{card
|
||||
? <Card><CardContent className="p-4">{inner}</CardContent></Card>
|
||||
: <div className="space-y-2 p-4">{inner}</div>
|
||||
}
|
||||
{card ? (
|
||||
<Card>
|
||||
<CardContent className="p-4">{inner}</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2 p-4">{inner}</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export const UserSchema = z.object({
|
||||
headerUrl: z.string().nullable(),
|
||||
customCss: z.string().nullable(),
|
||||
profileFields: z.array(ProfileFieldSchema).default([]),
|
||||
customMoods: z.array(ProfileFieldSchema).default([]),
|
||||
local: z.boolean(),
|
||||
isFollowedByViewer: z.boolean(),
|
||||
joinedAt: z.coerce.date().nullable(),
|
||||
@@ -55,6 +56,7 @@ export const ThoughtSchema = z.object({
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date().nullable(),
|
||||
noteExtensions: z.record(z.string(), z.unknown()).nullish(),
|
||||
mood: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const RegisterSchema = z.object({
|
||||
@@ -72,6 +74,7 @@ export const CreateThoughtSchema = z.object({
|
||||
content: z.string().min(1).max(128),
|
||||
visibility: z.enum(["public", "followers", "unlisted", "direct"]).optional(),
|
||||
inReplyToId: z.string().uuid().optional(),
|
||||
mood: z.string().max(64).optional(),
|
||||
});
|
||||
|
||||
export const UpdateProfileSchema = z.object({
|
||||
@@ -79,6 +82,7 @@ export const UpdateProfileSchema = z.object({
|
||||
bio: z.string().max(4000).optional(),
|
||||
customCss: z.string().optional(),
|
||||
profileFields: z.array(ProfileFieldSchema).max(4).optional(),
|
||||
customMoods: z.array(ProfileFieldSchema).max(8).optional(),
|
||||
});
|
||||
|
||||
export const SearchResultsSchema = z.object({
|
||||
@@ -121,6 +125,7 @@ export const ThoughtThreadSchema: z.ZodType<{
|
||||
createdAt: Date;
|
||||
updatedAt: Date | null;
|
||||
noteExtensions?: Record<string, unknown> | null;
|
||||
mood?: string | null;
|
||||
replies: ThoughtThread[];
|
||||
}> = z.object({
|
||||
id: z.string().uuid(),
|
||||
@@ -138,6 +143,7 @@ export const ThoughtThreadSchema: z.ZodType<{
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date().nullable(),
|
||||
noteExtensions: z.record(z.string(), z.unknown()).nullish(),
|
||||
mood: z.string().nullable().optional(),
|
||||
replies: z.lazy(() => z.array(ThoughtThreadSchema)),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user