Compare commits

..

16 Commits

Author SHA1 Message Date
8f69cfb011 refactor: improve code formatting and structure in ThoughtForm component
All checks were successful
lint / lint (push) Successful in 14m0s
test / unit (push) Successful in 15m54s
2026-06-05 17:47:15 +02:00
9aea5c1bd9 docs: rewrite architecture diagram as mermaid
All checks were successful
lint / lint (push) Successful in 14m6s
test / unit (push) Successful in 15m45s
2026-06-04 23:48:49 +02:00
4d6df1ea60 docs: add architecture diagram
Some checks failed
test / unit (push) Has been cancelled
lint / lint (push) Has been cancelled
2026-06-04 23:48:04 +02:00
5a65fda0bc refactor: move federation port types from adapter to domain
Some checks failed
test / unit (push) Has been cancelled
lint / lint (push) Has been cancelled
ActivityPubRepository→FederationContentRepository,
OutboundFederationPort→FederationBroadcastPort,
ActorApUrls→ActorFederationUrls.

Removes activitypub dep from application and presentation crates.
Adapter re-exports old names as aliases for backward compat.
Also fixes count_users test broken by instance actor migration.
2026-06-04 23:44:01 +02:00
6dbd4dafdc fix: persist note_extensions on AP Update activity
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
on_update was discarding custom fields (posterUrl, movieTitle, etc),
so remote notes from movies-diary lost posters after Update delivery.
2026-06-04 23:28:58 +02:00
90d13c883b feat: replace instance actor decorator with real DB row
All checks were successful
lint / lint (push) Successful in 14m31s
test / unit (push) Successful in 15m58s
Migration inserts a service actor with a well-known UUID. Removes the
InstanceActorUserRepo wrapper — the real ApUserRepository finds it.
2026-05-30 03:16:01 +02:00
0c8fa01ab9 feat: add instance actor for signed fetch (Secure Mode support)
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
2026-05-30 03:11:15 +02:00
78daca0377 chore: upgrade k-ap to 0.4.0
Some checks failed
test / unit (push) Has been cancelled
lint / lint (push) Has been cancelled
Map new fetched_at field in RemoteActor, read last_fetched_at from DB.
2026-05-30 03:00:43 +02:00
3357484bbf fix: extract CallLog type alias to satisfy clippy type_complexity
All checks were successful
lint / lint (push) Successful in 14m20s
test / unit (push) Successful in 16m15s
2026-05-29 21:07:48 +02:00
442a61bbdb feat: add optional mood to thoughts with custom moods support
Some checks failed
lint / lint (push) Failing after 9m28s
test / unit (push) Successful in 16m8s
Mood is an optional label+emoji string (e.g. "relaxed 😌") on thoughts.
Users can define up to 8 custom moods in profile settings.
Mood federates via AP Note JSON and displays on thought cards.
2026-05-29 15:38:35 +02:00
be27fe04e2 docs: sync README with actual features, fix default port to 8000
Some checks failed
lint / lint (push) Failing after 9m25s
test / unit (push) Successful in 16m42s
- Add missing features: profile fields, custom CSS, visibility
  levels, CW/sensitive, feed sort/filter, popular tags, account
  migration
- Fix top friends limit: 5→8
- Default PORT 3000→8000 in code, README, and .env.example
- Deduplicate frontend env docs, update contributing section
2026-05-29 14:31:17 +02:00
6040cf1e53 docs: remove outdated Movies-Diary integration documentation
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
2026-05-29 14:29:44 +02:00
0b74344efe docs: fix DX for new contributors
Some checks failed
test / unit (push) Has been cancelled
lint / lint (push) Has been cancelled
- Fix port mismatch: Dockerfile EXPOSE 8000, .env.example PORT=8000,
  compose.yml gets explicit PORT=8000
- Add thoughts-frontend/.env.example with all required vars
- Document NEXT_PUBLIC_FEDIVERSE_DOMAIN in README
- Document private cargo registry (k-ap on Gitea)
- Add local dev workflow: make dev-infra → cargo run → bun dev
- Split make targets: test-unit (no DB), test-integration, up
2026-05-29 14:27:42 +02:00
6d0b1a3121 refactor: eliminate User/UserResponse struct literals, add AP user tests
- Feed/search adapters use #[sqlx(flatten)] UserRow instead of
  inline User construction — single point of change when User
  gains fields
- User::new_remote constructor replaces struct literal in testing
- to_summary_response replaces inline UserResponse in get_users
- 5 integration tests for PgApUserRepository (find, count,
  profile_fields→attachment)
2026-05-29 14:17:41 +02:00
020a79704f refactor: dedup JSONB name/value helpers, add profile fields validation
Some checks failed
lint / lint (push) Failing after 9m29s
test / unit (push) Has been cancelled
Extract parse/serialize into postgres::jsonb, used by user,
remote_actor, and postgres-federation. Validate profile fields
in update_profile use case (max 4, name≤64, value≤256).
2026-05-29 13:59:39 +02:00
805bd9534f feat: add profile fields for local users
DB→domain→API→AP→frontend end-to-end. Fields stored as
JSONB, exposed via PATCH /users/me, serialized as AP
PropertyValue attachment. Editor in federation settings,
display on profile card.
2026-05-29 13:54:25 +02:00
72 changed files with 1273 additions and 604 deletions

View File

@@ -9,7 +9,7 @@ BASE_URL=http://localhost:3000
# Optional
HOST=0.0.0.0
PORT=3000
PORT=8000
# CORS — comma-separated allowed origins, or * for permissive (default: *)
CORS_ORIGINS=*

164
ARCHITECTURE.md Normal file
View 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

10
Cargo.lock generated
View File

@@ -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",
@@ -2554,6 +2554,7 @@ dependencies = [
"async-trait",
"chrono",
"k-ap",
"serde_json",
"sqlx",
"tokio",
"tracing",
@@ -2603,7 +2604,6 @@ dependencies = [
name = "presentation"
version = "0.1.0"
dependencies = [
"activitypub",
"api-types",
"application",
"async-trait",
@@ -4570,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]]

View File

@@ -52,7 +52,7 @@ WORKDIR /app
COPY --from=builder /build/target/release/thoughts ./thoughts
COPY --from=builder /build/target/release/thoughts-worker ./thoughts-worker
EXPOSE 3000
EXPOSE 8000
ENV RUST_LOG=info

View File

@@ -16,13 +16,33 @@ fmt-check:
clippy:
cargo clippy -- -D warnings
# Run the test suite.
# Run the full test suite (requires DATABASE_URL).
test:
cargo test
# Unit tests only — no database required.
test-unit:
cargo test -p domain -p application -p api-types -p activitypub
# Integration tests only — requires DATABASE_URL.
test-integration:
cargo test -p postgres -p postgres-federation -p postgres-search -p presentation
# Apply fmt + clippy auto-fixes in one shot.
fix:
cargo fmt
cargo clippy --fix --allow-dirty --allow-staged
.PHONY: check fmt fmt-check clippy test fix
# Start infra (Postgres + NATS) for local development.
dev-infra:
docker compose up postgres nats -d
# Stop infra.
dev-infra-down:
docker compose down
# Full Docker stack.
up:
docker compose up --build
.PHONY: check fmt fmt-check clippy test test-unit test-integration fix dev-infra dev-infra-down up

View File

@@ -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
@@ -85,6 +92,11 @@ Users can upload avatar and banner images via `PUT /users/me/avatar` and `PUT /u
- Rust stable (1.80+)
- PostgreSQL 15+
- NATS with JetStream (optional — see [Without NATS](#without-nats))
- Docker & Docker Compose (for the easiest local setup)
### Private cargo registry
The `k-ap` crate (ActivityPub protocol library) is hosted on a private Gitea registry configured in `.cargo/config.toml`. To build the project you need read access to `git.gabrielkaszewski.dev`. If you're contributing and don't have access, open an issue and I'll sort it out.
## Environment Variables
@@ -103,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 |
@@ -121,8 +133,42 @@ Copy `.env.example` to `.env` and fill in your values.
| `UPLOAD_MAX_BYTES` | `5242880` | Max upload size in bytes (default 5 MiB) |
| `UPLOAD_ALLOWED_TYPES` | `image/jpeg,image/png,image/gif,image/webp,image/avif` | Comma-separated allowed MIME types |
### Frontend environment
Copy `thoughts-frontend/.env.example` to `thoughts-frontend/.env.local` and adjust:
| Variable | Description |
|---|---|
| `NEXT_PUBLIC_API_URL` | API URL for client-side (browser) requests, e.g. `http://localhost:8000` |
| `NEXT_PUBLIC_SERVER_SIDE_API_URL` | API URL for SSR requests — same as above locally, or `http://api:8000` inside Docker |
| `NEXT_PUBLIC_FEDIVERSE_DOMAIN` | (Optional) Domain shown on profile fediverse handles, e.g. `yourinstance.example.com` |
## Run
### Local development (recommended)
Start only the infrastructure containers (Postgres + NATS), then run the Rust backend and Next.js frontend natively for fast iteration:
```bash
# 1. Start Postgres + NATS
make dev-infra
# 2. Copy and fill in env files
cp .env.example .env
cp thoughts-frontend/.env.example thoughts-frontend/.env.local
# 3. API server (runs migrations automatically on startup)
cargo run -p bootstrap
# 4. Event worker (separate terminal, optional)
cargo run -p worker
# 5. Frontend (separate terminal)
cd thoughts-frontend && bun install && bun dev
```
### Bare metal
```bash
# API server (runs migrations automatically on startup)
cargo run -p bootstrap
@@ -136,14 +182,20 @@ Both processes share the same PostgreSQL database. The worker is optional but re
## Test
```bash
# Unit tests — no database required
cargo test -p application
# Unit tests only — no database required
make test-unit
# Full workspace (requires DATABASE_URL pointing to a running PostgreSQL)
cargo test --workspace
# Integration tests — requires DATABASE_URL pointing to a running PostgreSQL
make test-integration
# Everything (unit + integration)
make test
# Full check suite: fmt + clippy + tests
make check
```
The `application` crate contains unit tests for all event services and use cases backed by in-memory fakes from `domain`'s `test-helpers` feature. These are the fastest feedback loop for business logic.
`make test-unit` runs domain, application, api-types, and activitypub tests using in-memory fakes — the fastest feedback loop for business logic. `make test-integration` runs the adapter crates against a live PostgreSQL.
## API
@@ -156,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
@@ -203,12 +244,12 @@ docker build -t thoughts-frontend \
docker run -p 3000:3000 thoughts-frontend
```
### Local development stack
### Full Docker stack
`compose.yml` spins up the full stack: PostgreSQL, NATS (with JetStream and monitoring on port 8222), the API server, the event worker, and the frontend.
```bash
docker compose up
make up # or: docker compose up --build
```
Services:
@@ -225,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`).

View File

@@ -30,6 +30,7 @@ services:
DATABASE_URL: postgres://postgres:postgres@postgres:5432/thoughts
JWT_SECRET: change-me-in-production
BASE_URL: http://localhost:8000
PORT: 8000
NATS_URL: nats://nats:4222
RUST_LOG: info
STORAGE_BACKEND: local

View File

@@ -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 }

View File

@@ -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(), &note.content)
.apply_note_update(ap_id.as_str(), &note.content, note_extensions)
.await
.map_err(|e| anyhow!("{e}"))
}

View File

@@ -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);
}

View File

@@ -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>;
}

View File

@@ -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> {

View File

@@ -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 {

View File

@@ -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 }
@@ -12,6 +12,7 @@ tracing = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
url = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }

View File

@@ -0,0 +1 @@
../postgres/migrations

View File

@@ -13,8 +13,9 @@ impl<T> IntoAnyhow<T> for std::result::Result<T, sqlx::Error> {
}
use k_ap::{
ActivityRepository, ActorRepository, ApActorType, ApUser, ApUserRepository, BlockedDomain,
BlocklistRepository, FollowRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
ActivityRepository, ActorRepository, ApActorType, ApProfileField, ApUser, ApUserRepository,
BlockedDomain, BlocklistRepository, FollowRepository, Follower, FollowerStatus,
FollowingStatus, RemoteActor,
};
// ── PostgresFederationRepository ─────────────────────────────────────────────
@@ -58,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 {
@@ -74,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,
}
}
@@ -173,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'",
@@ -208,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'
@@ -260,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'
@@ -304,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'",
@@ -388,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",
@@ -409,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
@@ -584,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)
@@ -751,6 +754,7 @@ struct UserRow {
avatar_url: Option<String>,
header_url: Option<String>,
also_known_as: Option<String>,
profile_fields: Option<serde_json::Value>,
}
pub struct PgApUserRepository {
@@ -767,6 +771,19 @@ impl PgApUserRepository {
let profile_url = url::Url::parse(&format!("{}/users/{}", self.base_url, r.id)).ok();
let avatar_url = r.avatar_url.and_then(|u| url::Url::parse(&u).ok());
let banner_url = r.header_url.and_then(|u| url::Url::parse(&u).ok());
let attachment = r
.profile_fields
.and_then(|v| v.as_array().cloned())
.map(|arr| {
arr.into_iter()
.filter_map(|item| {
let name = item.get("name")?.as_str()?.to_string();
let value = item.get("value")?.as_str()?.to_string();
Some(ApProfileField { name, value })
})
.collect()
})
.unwrap_or_default();
ApUser {
id: r.id,
username: r.username,
@@ -776,7 +793,7 @@ impl PgApUserRepository {
banner_url,
also_known_as: r.also_known_as.into_iter().collect(),
profile_url,
attachment: vec![],
attachment,
manually_approves_followers: true,
discoverable: true,
actor_type: ApActorType::default(),
@@ -789,7 +806,7 @@ impl PgApUserRepository {
impl ApUserRepository for PgApUserRepository {
async fn find_by_id(&self, id: uuid::Uuid) -> Result<Option<ApUser>> {
let row = sqlx::query_as::<_, UserRow>(
"SELECT id,username,display_name,bio,avatar_url,header_url,also_known_as FROM users WHERE id=$1 AND local=true",
"SELECT id,username,display_name,bio,avatar_url,header_url,also_known_as,profile_fields FROM users WHERE id=$1 AND local=true",
)
.bind(id)
.fetch_optional(&self.pool)
@@ -800,7 +817,7 @@ impl ApUserRepository for PgApUserRepository {
async fn find_by_username(&self, username: &str) -> Result<Option<ApUser>> {
let row = sqlx::query_as::<_, UserRow>(
"SELECT id,username,display_name,bio,avatar_url,header_url,also_known_as FROM users WHERE username=$1 AND local=true",
"SELECT id,username,display_name,bio,avatar_url,header_url,also_known_as,profile_fields FROM users WHERE username=$1 AND local=true",
)
.bind(username)
.fetch_optional(&self.pool)
@@ -817,3 +834,92 @@ impl ApUserRepository for PgApUserRepository {
Ok(n as usize)
}
}
#[cfg(test)]
mod tests {
use super::*;
use k_ap::ApUserRepository;
async fn seed_local_user(pool: &PgPool, username: &str) -> uuid::Uuid {
let id = uuid::Uuid::new_v4();
sqlx::query(
"INSERT INTO users (id,username,email,password_hash,local,created_at,updated_at)
VALUES ($1,$2,$3,'h',true,NOW(),NOW())",
)
.bind(id)
.bind(username)
.bind(format!("{username}@test.com"))
.execute(pool)
.await
.unwrap();
id
}
#[sqlx::test(migrations = "./migrations")]
async fn find_by_id_returns_local_user(pool: PgPool) {
let id = seed_local_user(&pool, "alice").await;
let repo = PgApUserRepository::new(pool, "https://example.com".into());
let user = repo.find_by_id(id).await.unwrap().unwrap();
assert_eq!(user.username, "alice");
assert!(user.attachment.is_empty());
}
#[sqlx::test(migrations = "./migrations")]
async fn find_by_username_returns_local_user(pool: PgPool) {
seed_local_user(&pool, "bob").await;
let repo = PgApUserRepository::new(pool, "https://example.com".into());
let user = repo.find_by_username("bob").await.unwrap().unwrap();
assert_eq!(user.username, "bob");
}
#[sqlx::test(migrations = "./migrations")]
async fn find_by_id_returns_none_for_missing(pool: PgPool) {
let repo = PgApUserRepository::new(pool, "https://example.com".into());
let result = repo.find_by_id(uuid::Uuid::new_v4()).await.unwrap();
assert!(result.is_none());
}
#[sqlx::test(migrations = "./migrations")]
async fn profile_fields_map_to_attachment(pool: PgPool) {
let id = seed_local_user(&pool, "carol").await;
let fields = serde_json::json!([
{"name": "Website", "value": "https://carol.dev"},
{"name": "Pronouns", "value": "she/her"}
]);
sqlx::query("UPDATE users SET profile_fields = $2 WHERE id = $1")
.bind(id)
.bind(&fields)
.execute(&pool)
.await
.unwrap();
let repo = PgApUserRepository::new(pool, "https://example.com".into());
let user = repo.find_by_id(id).await.unwrap().unwrap();
assert_eq!(user.attachment.len(), 2);
assert_eq!(user.attachment[0].name, "Website");
assert_eq!(user.attachment[0].value, "https://carol.dev");
assert_eq!(user.attachment[1].name, "Pronouns");
assert_eq!(user.attachment[1].value, "she/her");
}
#[sqlx::test(migrations = "./migrations")]
async fn count_users_counts_local_only(pool: PgPool) {
seed_local_user(&pool, "local1").await;
seed_local_user(&pool, "local2").await;
sqlx::query(
"INSERT INTO users (id,username,email,password_hash,local,created_at,updated_at)
VALUES ($1,'remote','r@r.com','h',false,NOW(),NOW())",
)
.bind(uuid::Uuid::new_v4())
.execute(&pool)
.await
.unwrap();
let repo = PgApUserRepository::new(pool, "https://example.com".into());
// 2 seeded local users + 1 instance actor from migration 022
assert_eq!(repo.count_users().await.unwrap(), 3);
}
}

View File

@@ -9,9 +9,9 @@ use domain::{
user::User,
},
ports::SearchPort,
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
value_objects::{Content, ThoughtId, UserId},
};
use postgres::user::{UserRow, USER_SELECT};
use postgres::user::USER_SELECT;
use sqlx::PgPool;
pub struct PgSearchRepository {
@@ -34,25 +34,16 @@ struct FeedRow {
sensitive: bool,
t_local: bool,
thought_created_at: DateTime<Utc>,
updated_at: Option<DateTime<Utc>>,
author_id: uuid::Uuid,
username: String,
email: String,
password_hash: String,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
author_local: bool,
author_created_at: DateTime<Utc>,
author_updated_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,
boost_count: i64,
reply_count: i64,
liked_by_viewer: bool,
boosted_by_viewer: bool,
note_extensions: Option<serde_json::Value>,
}
fn feed_select(viewer: Option<uuid::Uuid>) -> String {
@@ -68,11 +59,11 @@ 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, t.note_extensions,\n\
u.id AS author_id, u.username, u.email, u.password_hash,\n\
u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css,\n\
u.local AS author_local,\n\
u.created_at AS author_created_at, u.updated_at AS author_updated_at,\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, 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\
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,\n\
(SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count,\n\
@@ -92,23 +83,11 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
sensitive: r.sensitive,
local: r.t_local,
created_at: r.thought_created_at,
updated_at: r.updated_at,
updated_at: r.thought_updated_at,
note_extensions: r.note_extensions,
mood: r.mood,
};
let author = User {
id: UserId::from_uuid(r.author_id),
username: Username::from_trusted(r.username),
email: Email::from_trusted(r.email),
password_hash: PasswordHash(r.password_hash),
display_name: r.display_name,
bio: r.bio,
avatar_url: r.avatar_url,
header_url: r.header_url,
custom_css: r.custom_css,
local: r.author_local,
created_at: r.author_created_at,
updated_at: r.author_updated_at,
};
let author = User::from(r.author);
Ok(FeedEntry {
thought,
author,
@@ -189,7 +168,7 @@ impl SearchPort for PgSearchRepository {
ORDER BY similarity(username || ' ' || COALESCE(display_name,''), $1) DESC
LIMIT $2 OFFSET $3"
);
let rows = sqlx::query_as::<_, UserRow>(&sql)
let rows = sqlx::query_as::<_, postgres::user::UserRow>(&sql)
.bind(query)
.bind(page.limit())
.bind(page.offset())

View File

@@ -5,6 +5,7 @@ use domain::{
user::User,
},
ports::{SearchPort, ThoughtRepository, UserWriter},
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
};
async fn seed_thought(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
@@ -26,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)

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_fields JSONB DEFAULT '[]'::jsonb;

View File

@@ -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;

View 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;

View File

@@ -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(&note_extensions)
.execute(&self.pool)
.await
.into_domain()

View File

@@ -10,7 +10,7 @@ use domain::{
user::User,
},
ports::{FeedOptions, FeedRepository, FeedRequest, FeedScope, FeedSort},
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
value_objects::{Content, ThoughtId, UserId},
};
use sqlx::PgPool;
@@ -34,20 +34,11 @@ struct FeedRow {
sensitive: bool,
t_local: bool,
thought_created_at: DateTime<Utc>,
updated_at: Option<DateTime<Utc>>,
thought_updated_at: Option<DateTime<Utc>>,
note_extensions: Option<serde_json::Value>,
author_id: uuid::Uuid,
username: String,
email: String,
password_hash: String,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
author_local: bool,
author_created_at: DateTime<Utc>,
author_updated_at: DateTime<Utc>,
mood: Option<String>,
#[sqlx(flatten)]
author: crate::user::UserRow,
like_count: i64,
boost_count: i64,
reply_count: i64,
@@ -66,23 +57,11 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
sensitive: r.sensitive,
local: r.t_local,
created_at: r.thought_created_at,
updated_at: r.updated_at,
updated_at: r.thought_updated_at,
note_extensions: r.note_extensions,
mood: r.mood,
};
let author = User {
id: UserId::from_uuid(r.author_id),
username: Username::from_trusted(r.username),
email: Email::from_trusted(r.email),
password_hash: PasswordHash(r.password_hash),
display_name: r.display_name,
bio: r.bio,
avatar_url: r.avatar_url,
header_url: r.header_url,
custom_css: r.custom_css,
local: r.author_local,
created_at: r.author_created_at,
updated_at: r.author_updated_at,
};
let author = User::from(r.author);
Ok(FeedEntry {
thought,
author,
@@ -134,9 +113,9 @@ impl<'a> FeedSqlBuilder<'a> {
t.id AS thought_id, t.user_id AS t_user_id, t.content,
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,
t.note_extensions,
u.id AS author_id,
t.created_at AS thought_created_at, t.updated_at AS thought_updated_at,
t.note_extensions, t.mood,
u.id,
CASE WHEN NOT u.local AND ra.handle IS NOT NULL AND ra.handle != ''
THEN '@' || ra.handle ||
CASE WHEN ra.handle NOT LIKE '%@%'
@@ -147,9 +126,9 @@ 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.local AS author_local,
u.created_at AS author_created_at, u.updated_at AS author_updated_at,
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,
COALESCE(b_agg.cnt, 0) AS boost_count,
COALESCE(r_agg.cnt, 0) AS reply_count,

View File

@@ -7,6 +7,7 @@ use domain::{
user::User,
},
ports::{FeedOptions, FeedQuery, FeedRequest, ThoughtRepository, UserWriter},
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
};
async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
@@ -27,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)

View File

@@ -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.local,u.ap_id,u.inbox_url,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.local,u.ap_id,u.inbox_url,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.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

View File

@@ -0,0 +1,20 @@
pub fn parse_name_value(v: Option<serde_json::Value>) -> Vec<(String, String)> {
v.and_then(|v| v.as_array().cloned())
.map(|arr| {
arr.into_iter()
.filter_map(|item| {
let name = item.get("name")?.as_str()?.to_string();
let value = item.get("value")?.as_str()?.to_string();
Some((name, value))
})
.collect()
})
.unwrap_or_default()
}
pub fn serialize_name_value(fields: &[(String, String)]) -> serde_json::Value {
fields
.iter()
.map(|(n, v)| serde_json::json!({"name": n, "value": v}))
.collect()
}

View File

@@ -8,6 +8,7 @@ pub mod engagement;
pub mod failed_event;
pub mod feed;
pub mod follow;
pub(crate) mod jsonb;
pub mod like;
pub mod notification;
pub mod outbox;

View File

@@ -23,11 +23,7 @@ impl RemoteActorRepository for PgRemoteActorRepository {
} else {
Some(a.also_known_as.iter().map(|s| s.as_str()).collect())
};
let attachment_json: serde_json::Value = a
.attachment
.iter()
.map(|(n, v)| serde_json::json!({"name": n, "value": v}))
.collect();
let attachment_json = crate::jsonb::serialize_name_value(&a.attachment);
sqlx::query(
"INSERT INTO remote_actors(url,handle,display_name,avatar_url,last_fetched_at,
bio,banner_url,outbox_url,followers_url,following_url,also_known_as,attachment)
@@ -101,19 +97,7 @@ impl RemoteActorRepository for PgRemoteActorRepository {
following_url: r.following_url,
inbox_url: r.inbox_url,
shared_inbox_url: r.shared_inbox_url,
attachment: r
.attachment
.and_then(|v| v.as_array().cloned())
.map(|arr| {
arr.into_iter()
.filter_map(|item| {
let name = item.get("name")?.as_str()?.to_string();
let value = item.get("value")?.as_str()?.to_string();
Some((name, value))
})
.collect()
})
.unwrap_or_default(),
attachment: crate::jsonb::parse_name_value(r.attachment),
})
})
}

View File

@@ -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);

View File

@@ -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)

View File

@@ -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",

View File

@@ -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();

View File

@@ -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.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",

View File

@@ -31,6 +31,8 @@ pub struct UserRow {
pub avatar_url: Option<String>,
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>,
@@ -48,6 +50,8 @@ impl From<UserRow> for User {
avatar_url: r.avatar_url,
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,
@@ -57,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,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 {
@@ -222,14 +226,18 @@ impl UserReader for PgUserRepository {
#[async_trait]
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,local,created_at,updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
"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()"
)
@@ -242,6 +250,8 @@ impl UserWriter for PgUserRepository {
.bind(&user.avatar_url)
.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)
@@ -267,6 +277,14 @@ impl UserWriter for PgUserRepository {
user_id: &UserId,
input: UpdateProfileInput,
) -> Result<(), DomainError> {
let profile_fields_json: Option<serde_json::Value> = input
.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), \
@@ -274,6 +292,8 @@ impl UserWriter for PgUserRepository {
avatar_url = COALESCE($4, avatar_url), \
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",
)
@@ -283,6 +303,8 @@ impl UserWriter for PgUserRepository {
.bind(input.avatar_url)
.bind(input.header_url)
.bind(input.custom_css)
.bind(profile_fields_json)
.bind(custom_moods_json)
.execute(&self.pool)
.await
.into_domain()

View File

@@ -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)]
@@ -47,6 +48,8 @@ pub struct UpdateProfileRequest {
pub avatar_url: Option<String>,
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)]

View File

@@ -1,5 +1,5 @@
use chrono::{DateTime, Utc};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Serialize, utoipa::ToSchema)]
@@ -19,6 +19,8 @@ pub struct UserResponse {
pub avatar_url: Option<String>,
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")]
@@ -47,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)]
@@ -105,7 +109,7 @@ pub struct CreatedApiKeyResponse {
pub key: String,
}
#[derive(Serialize, Clone, utoipa::ToSchema)]
#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ProfileField {
pub name: String,

View File

@@ -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 }

View File

@@ -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 {

View File

@@ -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(),
},

View File

@@ -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 {

View File

@@ -1,11 +1,10 @@
/// 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, PasswordHash, ThoughtId, UserId, Username},
value_objects::{Email, ThoughtId, UserId, Username},
};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
@@ -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,
@@ -63,20 +62,11 @@ impl ActivityPubRepository for TestApRepo {
let handle = url::Url::parse(actor_ap_url)
.map(|u| u.path().trim_start_matches('/').replace('/', "_"))
.unwrap_or_else(|_| format!("remote_{}", &uid.to_string()[..8]));
let user = User {
id: uid.clone(),
username: Username::from_trusted(handle),
email: Email::from_trusted(format!("{}@remote", uid)),
password_hash: PasswordHash("".into()),
display_name: None,
bio: None,
avatar_url: None,
header_url: None,
custom_css: None,
local: false,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let user = User::new_remote(
uid.clone(),
Username::from_trusted(handle),
Email::from_trusted(format!("{}@remote", uid)),
);
self.inner.users.lock().unwrap().push(user);
self.inner
.actor_ap_ids
@@ -93,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> {
@@ -133,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> {

View File

@@ -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,

View File

@@ -1,4 +1,10 @@
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::{
@@ -55,6 +61,34 @@ pub async fn update_profile(
user_id: &UserId,
input: UpdateProfileInput,
) -> Result<(), DomainError> {
if let Some(ref fields) = input.profile_fields {
if fields.len() > MAX_PROFILE_FIELDS {
return Err(DomainError::InvalidInput(format!(
"profile fields: max {MAX_PROFILE_FIELDS}"
)));
}
for (name, value) in fields {
if name.len() > MAX_FIELD_NAME_LEN || value.len() > MAX_FIELD_VALUE_LEN {
return Err(DomainError::InvalidInput(
"profile field name or value too long".into(),
));
}
}
}
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 {

View File

@@ -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);

View File

@@ -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?;

View File

@@ -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

View File

@@ -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 }

View File

@@ -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),

View File

@@ -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,
}
}
}

View File

@@ -8,6 +8,8 @@ pub struct UpdateProfileInput {
pub avatar_url: Option<String>,
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)]
@@ -21,6 +23,8 @@ pub struct User {
pub avatar_url: Option<String>,
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>,
@@ -44,9 +48,31 @@ impl User {
avatar_url: None,
header_url: None,
custom_css: None,
profile_fields: vec![],
custom_moods: vec![],
local: true,
created_at: now,
updated_at: now,
}
}
pub fn new_remote(id: UserId, username: Username, email: Email) -> Self {
let now = Utc::now();
Self {
id,
username,
email,
password_hash: PasswordHash(String::new()),
display_name: None,
bio: None,
avatar_url: None,
header_url: None,
custom_css: None,
profile_fields: vec![],
custom_moods: vec![],
local: false,
created_at: now,
updated_at: now,
}
}
}

View File

@@ -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>;
}

View File

@@ -5,7 +5,6 @@ edition = "2021"
[dependencies]
domain = { workspace = true }
activitypub = { workspace = true }
application = { workspace = true }
api-types = { workspace = true }
axum = { workspace = true }

View File

@@ -1,10 +1,11 @@
use crate::{deps_struct, errors::ApiError, extractors::Deps};
use api_types::{
requests::{LoginRequest, RegisterRequest},
responses::{AuthResponse, ErrorResponse, UserResponse},
responses::{AuthResponse, ErrorResponse, ProfileField, UserResponse},
};
use application::use_cases::auth::{login, register, LoginInput, RegisterInput};
use axum::{http::StatusCode, response::IntoResponse, Json};
use domain::models::feed::UserSummary;
use domain::ports::{AuthService, EventPublisher, PasswordHasher, UserRepository};
deps_struct!(AuthDeps {
@@ -23,12 +24,45 @@ pub fn to_user_response(u: &domain::models::user::User) -> UserResponse {
avatar_url: u.avatar_url.clone(),
header_url: u.header_url.clone(),
custom_css: u.custom_css.clone(),
profile_fields: u
.profile_fields
.iter()
.map(|(n, v)| ProfileField {
name: n.clone(),
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,
}
}
pub fn to_summary_response(u: &UserSummary) -> UserResponse {
UserResponse {
id: u.id.as_uuid(),
username: u.username.clone(),
display_name: u.display_name.clone(),
bio: u.bio.clone(),
avatar_url: u.avatar_url.clone(),
header_url: None,
custom_css: None,
profile_fields: vec![],
custom_moods: vec![],
local: true,
is_followed_by_viewer: false,
created_at: chrono::Utc::now(),
}
}
#[utoipa::path(
post, path = "/auth/register",
request_body = RegisterRequest,

View File

@@ -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>,

View File

@@ -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(),
}
}

View File

@@ -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?;

View File

@@ -1,7 +1,7 @@
use crate::{
errors::ApiError,
extractors::{AuthUser, Deps, FromAppState, OptionalAuthUser},
handlers::auth::to_user_response,
handlers::auth::{to_summary_response, to_user_response},
state::AppState,
};
use api_types::{
@@ -114,6 +114,12 @@ pub async fn patch_profile(
avatar_url: body.avatar_url,
header_url: body.header_url,
custom_css: body.custom_css,
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?;
@@ -197,22 +203,7 @@ pub async fn get_users(
}
let result = list_users(&*d.users, page_params).await?;
let items: Vec<UserResponse> = result
.items
.iter()
.map(|u| UserResponse {
id: u.id.as_uuid(),
username: u.username.clone(),
display_name: u.display_name.clone(),
bio: u.bio.clone(),
avatar_url: u.avatar_url.clone(),
header_url: None,
custom_css: None,
local: true,
is_followed_by_viewer: false,
created_at: chrono::Utc::now(),
})
.collect();
let items: Vec<UserResponse> = result.items.iter().map(to_summary_response).collect();
Ok(Json(PagedResponse {
items,
total: result.total,

View File

@@ -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>,

View File

@@ -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> {

View File

@@ -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 }

View File

@@ -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

View File

@@ -0,0 +1,6 @@
# API endpoints
NEXT_PUBLIC_API_URL=http://localhost:8000 # client-side (browser) requests
NEXT_PUBLIC_SERVER_SIDE_API_URL=http://localhost:8000 # SSR requests (use http://api:8000 inside Docker)
# Fediverse handle display (optional)
# NEXT_PUBLIC_FEDIVERSE_DOMAIN=yourinstance.example.com

View File

@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel

View File

@@ -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>

View File

@@ -1,7 +1,9 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getMe } from "@/lib/api";
import { FederationPanel } from "@/components/federation/federation-panel";
import { MigrationSettings } from "@/components/federation/migration-settings";
import { ProfileFieldsEditor } from "@/components/profile-fields-editor";
export default async function FederationSettingsPage() {
const token = (await cookies()).get("auth_token")?.value;
@@ -9,6 +11,8 @@ export default async function FederationSettingsPage() {
redirect("/login");
}
const me = await getMe(token);
return (
<div className="space-y-6">
<div className="glass-effect glossy-effect bottom rounded-md shadow-fa-lg p-4">
@@ -18,6 +22,7 @@ export default async function FederationSettingsPage() {
other instances.
</p>
</div>
<ProfileFieldsEditor initial={me.profileFields} />
<FederationPanel />
<MigrationSettings />
</div>

View File

@@ -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>
);
}

View File

@@ -224,6 +224,27 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
{user.bio}
</p>
{user.profileFields.length > 0 && (
<div className="mt-4 space-y-0 text-sm">
{user.profileFields.map((field) => (
<div
key={field.name}
className="grid grid-cols-[minmax(0,5rem)_1fr] gap-2 border-t py-1"
>
<span
className="font-medium text-muted-foreground truncate"
title={field.name}
>
{field.name}
</span>
<span className="break-all min-w-0">
{field.value}
</span>
</div>
))}
</div>
)}
{isOwnProfile && (
<div
id="profile-card__stats"

View 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>
);
}

View 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_FIELDS = 4;
export function ProfileFieldsEditor({
initial,
}: {
initial: ProfileField[];
}) {
const { token } = useAuth();
const [fields, setFields] = useState<ProfileField[]>(initial);
const [saving, setSaving] = useState(false);
const update = (i: number, key: "name" | "value", val: string) => {
setFields((prev) => prev.map((f, j) => (j === i ? { ...f, [key]: val } : f)));
};
const add = () => {
if (fields.length >= MAX_FIELDS) return;
setFields((prev) => [...prev, { name: "", value: "" }]);
};
const remove = (i: number) => {
setFields((prev) => prev.filter((_, j) => j !== i));
};
const save = async () => {
if (!token) return;
const clean = fields.filter((f) => f.name.trim() || f.value.trim());
setSaving(true);
try {
await updateProfile({ profileFields: clean }, token);
setFields(clean);
toast.success("Profile fields saved.");
} catch {
toast.error("Failed to save profile fields.");
} 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">Profile fields</h3>
<p className="text-sm text-muted-foreground">
Add up to {MAX_FIELDS} custom fields visible on your profile and
across the fediverse (e.g. Website, Pronouns).
</p>
</div>
<div className="space-y-2">
{fields.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="Label"
className="max-w-[8rem] text-sm"
/>
<Input
value={f.value}
onChange={(e) => update(i, "value", e.target.value)}
placeholder="Value"
className="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">
{fields.length < MAX_FIELDS && (
<Button variant="outline" size="sm" onClick={add}>
<Plus className="h-4 w-4 mr-1" /> Add field
</Button>
)}
<Button size="sm" onClick={save} disabled={saving}>
{saving ? "Saving…" : "Save"}
</Button>
</div>
</div>
);
}

View File

@@ -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>
)}

View File

@@ -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>
)}
</>
)
);
}

View File

@@ -1,6 +1,12 @@
import { cache } from "react";
import { z } from "zod";
export const ProfileFieldSchema = z.object({
name: z.string(),
value: z.string(),
});
export type ProfileField = z.infer<typeof ProfileFieldSchema>;
export const UserSchema = z.object({
id: z.string().uuid(),
username: z.string(),
@@ -9,6 +15,8 @@ export const UserSchema = z.object({
avatarUrl: z.string().nullable(),
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(),
@@ -16,12 +24,6 @@ export const UserSchema = z.object({
export const MeSchema = UserSchema;
export const ProfileFieldSchema = z.object({
name: z.string(),
value: z.string(),
});
export type ProfileField = z.infer<typeof ProfileFieldSchema>;
export const RemoteActorSchema = z.object({
handle: z.string(),
displayName: z.string().nullable(),
@@ -54,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({
@@ -71,12 +74,15 @@ 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({
displayName: z.string().max(50).optional(),
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({
@@ -119,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(),
@@ -136,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)),
});