Compare commits

...

14 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
65 changed files with 1041 additions and 570 deletions

View File

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

9
Cargo.lock generated
View File

@@ -273,13 +273,13 @@ dependencies = [
name = "application" name = "application"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"activitypub",
"async-trait", "async-trait",
"bytes", "bytes",
"chrono", "chrono",
"domain", "domain",
"futures", "futures",
"hex", "hex",
"serde_json",
"sha2", "sha2",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
@@ -2017,9 +2017,9 @@ dependencies = [
[[package]] [[package]]
name = "k-ap" name = "k-ap"
version = "0.3.1" version = "0.4.0"
source = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/" source = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/"
checksum = "f73de37ac4feab6d7b78e73c60acbb07933c2be58dcbb12e8a34201f66e0480d" checksum = "ccaa914953bfd45ea206e11826da8f61ce1fbe02f8fe0622880527046ad6ae24"
dependencies = [ dependencies = [
"activitypub_federation", "activitypub_federation",
"anyhow", "anyhow",
@@ -2604,7 +2604,6 @@ dependencies = [
name = "presentation" name = "presentation"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"activitypub",
"api-types", "api-types",
"application", "application",
"async-trait", "async-trait",
@@ -4571,7 +4570,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]

View File

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

View File

@@ -16,13 +16,33 @@ fmt-check:
clippy: clippy:
cargo clippy -- -D warnings cargo clippy -- -D warnings
# Run the test suite. # Run the full test suite (requires DATABASE_URL).
test: test:
cargo 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. # Apply fmt + clippy auto-fixes in one shot.
fix: fix:
cargo fmt cargo fmt
cargo clippy --fix --allow-dirty --allow-staged 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 - JWT authentication (Bearer token) with API key support for third-party clients
- OpenAPI documentation at `/docs` (Swagger UI) and `/scalar` (Scalar) - OpenAPI documentation at `/docs` (Swagger UI) and `/scalar` (Scalar)
- Full-text search over thoughts and users via PostgreSQL trigram indexes - 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 - Home feed, public feed, and per-user thought timelines
- Rate limiting and registration control - 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+) - Rust stable (1.80+)
- PostgreSQL 15+ - PostgreSQL 15+
- NATS with JetStream (optional — see [Without NATS](#without-nats)) - 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 ## Environment Variables
@@ -103,7 +115,7 @@ Copy `.env.example` to `.env` and fill in your values.
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `HOST` | `0.0.0.0` | Interface to bind | | `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 | | `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` | | `CORS_ORIGINS` | `*` | Comma-separated allowed origins for CORS, e.g. `https://app.example.com` |
| `RATE_LIMIT` | disabled | Max requests per minute per IP | | `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_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 | | `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 ## 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 ```bash
# API server (runs migrations automatically on startup) # API server (runs migrations automatically on startup)
cargo run -p bootstrap cargo run -p bootstrap
@@ -136,14 +182,20 @@ Both processes share the same PostgreSQL database. The worker is optional but re
## Test ## Test
```bash ```bash
# Unit tests — no database required # Unit tests only — no database required
cargo test -p application make test-unit
# Full workspace (requires DATABASE_URL pointing to a running PostgreSQL) # Integration tests — requires DATABASE_URL pointing to a running PostgreSQL
cargo test --workspace 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 ## API
@@ -156,18 +208,7 @@ Interactive API documentation is available at runtime:
## Frontend ## Frontend
The Next.js frontend lives in `thoughts-frontend/`. It requires two environment variables: 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.
```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
```
## Docker ## Docker
@@ -203,12 +244,12 @@ docker build -t thoughts-frontend \
docker run -p 3000:3000 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. `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 ```bash
docker compose up make up # or: docker compose up --build
``` ```
Services: Services:
@@ -225,7 +266,7 @@ Services:
Contributions are welcome. A few guidelines: 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. - **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. - **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`). - **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 DATABASE_URL: postgres://postgres:postgres@postgres:5432/thoughts
JWT_SECRET: change-me-in-production JWT_SECRET: change-me-in-production
BASE_URL: http://localhost:8000 BASE_URL: http://localhost:8000
PORT: 8000
NATS_URL: nats://nats:4222 NATS_URL: nats://nats:4222
RUST_LOG: info RUST_LOG: info
STORAGE_BACKEND: local STORAGE_BACKEND: local

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
k-ap = { version = "0.3.1", registry = "gitea" } k-ap = { version = "0.4.0", registry = "gitea" }
domain = { workspace = true } domain = { workspace = true }
url = { workspace = true } url = { workspace = true }
serde = { 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(""); let obj_type = object.get("type").and_then(|v| v.as_str()).unwrap_or("");
match obj_type { match obj_type {
"Note" | "Article" | "Page" => { "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(()); return Ok(());
}; };
self.repo self.repo
.apply_note_update(ap_id.as_str(), &note.content) .apply_note_update(ap_id.as_str(), &note.content, note_extensions)
.await .await
.map_err(|e| anyhow!("{e}")) .map_err(|e| anyhow!("{e}"))
} }

View File

@@ -4,6 +4,9 @@ pub mod port;
pub mod service; pub mod service;
pub mod urls; 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 handler::ThoughtsObjectHandler;
pub use note::ThoughtNote; pub use note::ThoughtNote;
pub use port::{ pub use port::{
@@ -43,7 +46,8 @@ pub async fn build_ap_service(
.object_handler(cfg.ap_handler) .object_handler(cfg.ap_handler)
.allow_registration(cfg.allow_registration) .allow_registration(cfg.allow_registration)
.software_name("thoughts") .software_name("thoughts")
.debug(cfg.debug); .debug(cfg.debug)
.signed_fetch_actor_id(INSTANCE_ACTOR_ID);
if let Some(publisher) = cfg.event_publisher { if let Some(publisher) = cfg.event_publisher {
builder = builder.event_publisher(publisher); builder = builder.event_publisher(publisher);
} }

View File

@@ -1,171 +1,5 @@
use async_trait::async_trait; pub use domain::ports::{
use domain::{ AcceptNoteInput, ActorFederationUrls as ActorApUrls,
errors::DomainError, FederationBroadcastPort as OutboundFederationPort,
models::thought::Thought, FederationContentRepository as ActivityPubRepository, OutboxEntry,
value_objects::{ThoughtId, UserId, Username},
}; };
pub struct AcceptNoteInput<'a> {
pub ap_id: &'a str,
pub author_id: &'a UserId,
pub content: &'a str,
pub published: chrono::DateTime<chrono::Utc>,
pub sensitive: bool,
pub content_warning: Option<String>,
pub visibility: &'a str,
pub in_reply_to: Option<&'a str>,
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(); .collect();
note["tag"] = serde_json::json!(ap_tags); 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 note
} }
@@ -114,7 +124,7 @@ fn k_ap_actor_to_domain(a: k_ap::RemoteActor) -> DomainRemoteActor {
display_name: a.display_name, display_name: a.display_name,
avatar_url: a.avatar_url, avatar_url: a.avatar_url,
outbox_url: a.outbox_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, bio: a.bio,
banner_url: a.banner_url, banner_url: a.banner_url,
also_known_as: a.also_known_as, 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( async fn resolve_actor_profiles_from_urls(
urls: Vec<String>, urls: Vec<String>,
) -> Vec<domain::models::actor_connection_summary::ActorConnectionSummary> { ) -> 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 domain::value_objects::{ThoughtId, UserId};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
type CallLog = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
struct SpyTransport { struct SpyTransport {
calls: Arc<Mutex<Vec<(String, Vec<u8>)>>>, calls: CallLog,
} }
impl SpyTransport { impl SpyTransport {
fn new() -> (Self, Arc<Mutex<Vec<(String, Vec<u8>)>>>) { fn new() -> (Self, CallLog) {
let calls = Arc::new(Mutex::new(vec![])); let calls = Arc::new(Mutex::new(vec![]));
( (
Self { Self {

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
k-ap = { version = "0.3.1", registry = "gitea" } k-ap = { version = "0.4.0", registry = "gitea" }
sqlx = { workspace = true } sqlx = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }

View File

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

View File

@@ -59,6 +59,7 @@ struct RemoteActorRow {
followers_url: Option<String>, followers_url: Option<String>,
following_url: Option<String>, following_url: Option<String>,
also_known_as: Option<Vec<String>>, also_known_as: Option<Vec<String>>,
last_fetched_at: Option<chrono::DateTime<chrono::Utc>>,
} }
fn map_remote_actor(r: RemoteActorRow) -> RemoteActor { fn map_remote_actor(r: RemoteActorRow) -> RemoteActor {
@@ -75,6 +76,7 @@ fn map_remote_actor(r: RemoteActorRow) -> RemoteActor {
followers_url: r.followers_url, followers_url: r.followers_url,
following_url: r.following_url, following_url: r.following_url,
also_known_as: r.also_known_as.unwrap_or_default(), also_known_as: r.also_known_as.unwrap_or_default(),
fetched_at: r.last_fetched_at,
} }
} }
@@ -174,7 +176,7 @@ impl FollowRepository for PgFederationRepository {
"SELECT f.remote_actor_url AS url, f.status, "SELECT f.remote_actor_url AS url, f.status,
COALESCE(r.handle,'') AS handle, COALESCE(r.inbox_url,'') AS inbox_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.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 FROM federation_followers f
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
WHERE f.local_user_id=$1 AND f.status='accepted'", WHERE f.local_user_id=$1 AND f.status='accepted'",
@@ -209,7 +211,7 @@ impl FollowRepository for PgFederationRepository {
"SELECT f.remote_actor_url AS url, f.status, "SELECT f.remote_actor_url AS url, f.status,
COALESCE(r.handle,'') AS handle, COALESCE(r.inbox_url,'') AS inbox_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.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 FROM federation_followers f
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
WHERE f.local_user_id=$1 AND f.status='accepted' WHERE f.local_user_id=$1 AND f.status='accepted'
@@ -261,7 +263,7 @@ impl FollowRepository for PgFederationRepository {
sqlx::query_as::<_, RemoteActorRow>( sqlx::query_as::<_, RemoteActorRow>(
"SELECT f.remote_actor_url AS url, COALESCE(r.handle,'') AS handle, "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, 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 FROM federation_followers f
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
WHERE f.local_user_id=$1 AND f.status='accepted' WHERE f.local_user_id=$1 AND f.status='accepted'
@@ -305,7 +307,7 @@ impl FollowRepository for PgFederationRepository {
sqlx::query_as::<_, RemoteActorRow>( sqlx::query_as::<_, RemoteActorRow>(
"SELECT f.remote_actor_url AS url, COALESCE(r.handle,'') AS handle, "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, 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 FROM federation_followers f
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
WHERE f.local_user_id=$1 AND f.status='pending'", WHERE f.local_user_id=$1 AND f.status='pending'",
@@ -389,7 +391,7 @@ impl FollowRepository for PgFederationRepository {
sqlx::query_as::<_, RemoteActorRow>( sqlx::query_as::<_, RemoteActorRow>(
"SELECT f.remote_actor_url AS url, COALESCE(r.handle,'') AS handle, "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, 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 FROM federation_following f
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
WHERE f.local_user_id=$1", WHERE f.local_user_id=$1",
@@ -410,7 +412,7 @@ impl FollowRepository for PgFederationRepository {
sqlx::query_as::<_, RemoteActorRow>( sqlx::query_as::<_, RemoteActorRow>(
"SELECT f.remote_actor_url AS url, COALESCE(r.handle,'') AS handle, "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, 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 FROM federation_following f
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
WHERE f.local_user_id=$1 WHERE f.local_user_id=$1
@@ -585,7 +587,7 @@ impl ActorRepository for PgFederationRepository {
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> { async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
sqlx::query_as::<_, RemoteActorRow>( sqlx::query_as::<_, RemoteActorRow>(
"SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url, "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", FROM remote_actors WHERE url=$1",
) )
.bind(actor_url) .bind(actor_url)
@@ -832,3 +834,92 @@ impl ApUserRepository for PgApUserRepository {
Ok(n as usize) 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, user::User,
}, },
ports::SearchPort, 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; use sqlx::PgPool;
pub struct PgSearchRepository { pub struct PgSearchRepository {
@@ -34,25 +34,16 @@ struct FeedRow {
sensitive: bool, sensitive: bool,
t_local: bool, t_local: bool,
thought_created_at: DateTime<Utc>, thought_created_at: DateTime<Utc>,
updated_at: Option<DateTime<Utc>>, thought_updated_at: Option<DateTime<Utc>>,
author_id: uuid::Uuid, note_extensions: Option<serde_json::Value>,
username: String, mood: Option<String>,
email: String, #[sqlx(flatten)]
password_hash: String, author: postgres::user::UserRow,
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>,
like_count: i64, like_count: i64,
boost_count: i64, boost_count: i64,
reply_count: i64, reply_count: i64,
liked_by_viewer: bool, liked_by_viewer: bool,
boosted_by_viewer: bool, boosted_by_viewer: bool,
note_extensions: Option<serde_json::Value>,
} }
fn feed_select(viewer: Option<uuid::Uuid>) -> String { 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.id AS thought_id, t.user_id AS t_user_id, t.content,\n\
t.in_reply_to_id,\n\ t.in_reply_to_id,\n\
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,\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\ t.created_at AS thought_created_at, t.updated_at AS thought_updated_at, t.note_extensions, t.mood,\n\
u.id AS author_id, u.username, u.email, u.password_hash,\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,\n\ u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.custom_moods,\n\
u.local AS author_local,\n\ u.local,\n\
u.created_at AS author_created_at, u.updated_at AS author_updated_at,\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 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 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\ (SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count,\n\
@@ -92,24 +83,11 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
sensitive: r.sensitive, sensitive: r.sensitive,
local: r.t_local, local: r.t_local,
created_at: r.thought_created_at, created_at: r.thought_created_at,
updated_at: r.updated_at, updated_at: r.thought_updated_at,
note_extensions: r.note_extensions, note_extensions: r.note_extensions,
mood: r.mood,
}; };
let author = User { let author = User::from(r.author);
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,
profile_fields: vec![],
local: r.author_local,
created_at: r.author_created_at,
updated_at: r.author_updated_at,
};
Ok(FeedEntry { Ok(FeedEntry {
thought, thought,
author, author,
@@ -190,7 +168,7 @@ impl SearchPort for PgSearchRepository {
ORDER BY similarity(username || ' ' || COALESCE(display_name,''), $1) DESC ORDER BY similarity(username || ' ' || COALESCE(display_name,''), $1) DESC
LIMIT $2 OFFSET $3" LIMIT $2 OFFSET $3"
); );
let rows = sqlx::query_as::<_, UserRow>(&sql) let rows = sqlx::query_as::<_, postgres::user::UserRow>(&sql)
.bind(query) .bind(query)
.bind(page.limit()) .bind(page.limit())
.bind(page.offset()) .bind(page.offset())

View File

@@ -5,6 +5,7 @@ use domain::{
user::User, user::User,
}, },
ports::{SearchPort, ThoughtRepository, UserWriter}, 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) { 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, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
trepo.save(&t).await.unwrap(); trepo.save(&t).await.unwrap();
(u, t) (u, t)

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, created_at: self.created_at,
updated_at: self.updated_at, updated_at: self.updated_at,
note_extensions: None, note_extensions: None,
mood: None,
}, },
author_username: Username::from_trusted(self.username), author_username: Username::from_trusted(self.username),
} }
@@ -269,13 +270,19 @@ impl ActivityPubRepository for PgActivityPubRepository {
Ok(ThoughtId::from_uuid(row.0)) 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(); let capped: String = new_content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
sqlx::query( 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(ap_id)
.bind(&capped) .bind(&capped)
.bind(&note_extensions)
.execute(&self.pool) .execute(&self.pool)
.await .await
.into_domain() .into_domain()

View File

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

View File

@@ -7,6 +7,7 @@ use domain::{
user::User, user::User,
}, },
ports::{FeedOptions, FeedQuery, FeedRequest, ThoughtRepository, UserWriter}, 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) { 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, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
trepo.save(&t).await.unwrap(); trepo.save(&t).await.unwrap();
(u, t) (u, t)

View File

@@ -120,7 +120,7 @@ impl FollowRepository for PgFollowRepository {
.into_domain()?; .into_domain()?;
let rows = sqlx::query_as::<_, crate::user::UserRow>( let rows = sqlx::query_as::<_, crate::user::UserRow>(
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.profile_fields,u.local,u.created_at,u.updated_at "SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.profile_fields,u.custom_moods,u.local,u.created_at,u.updated_at
FROM users u JOIN follows f ON f.follower_id=u.id FROM users u JOIN follows f ON f.follower_id=u.id
WHERE f.following_id=$1 AND f.state='accepted' WHERE f.following_id=$1 AND f.state='accepted'
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3" ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
@@ -154,7 +154,7 @@ impl FollowRepository for PgFollowRepository {
.into_domain()?; .into_domain()?;
let rows = sqlx::query_as::<_, crate::user::UserRow>( let rows = sqlx::query_as::<_, crate::user::UserRow>(
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.profile_fields,u.local,u.created_at,u.updated_at "SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.profile_fields,u.custom_moods,u.local,u.created_at,u.updated_at
FROM users u JOIN follows f ON f.following_id=u.id FROM users u JOIN follows f ON f.following_id=u.id
WHERE f.follower_id=$1 AND f.state='accepted' WHERE f.follower_id=$1 AND f.state='accepted'
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3" 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>( let rows = sqlx::query_as::<_, crate::user::UserRow>(
"SELECT u.id, u.username, u.email, u.password_hash, u.display_name, u.bio, "SELECT u.id, u.username, u.email, u.password_hash, u.display_name, u.bio,
u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.local, u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.custom_moods, u.local,
u.created_at, u.updated_at u.created_at, u.updated_at
FROM users u FROM users u
JOIN follows f1 JOIN follows f1

View File

@@ -37,6 +37,7 @@ async fn attach_and_list(pool: sqlx::PgPool) {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
trepo.save(&t).await.unwrap(); trepo.save(&t).await.unwrap();
let repo = PgTagRepository::new(pool); 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, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
trepo.save(&t).await.unwrap(); trepo.save(&t).await.unwrap();
(user, t) (user, t)

View File

@@ -35,6 +35,7 @@ pub(crate) struct ThoughtRow {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: Option<DateTime<Utc>>, pub updated_at: Option<DateTime<Utc>>,
pub note_extensions: Option<serde_json::Value>, pub note_extensions: Option<serde_json::Value>,
pub mood: Option<String>,
} }
impl TryFrom<ThoughtRow> for Thought { impl TryFrom<ThoughtRow> for Thought {
@@ -52,19 +53,20 @@ impl TryFrom<ThoughtRow> for Thought {
created_at: r.created_at, created_at: r.created_at,
updated_at: r.updated_at, updated_at: r.updated_at,
note_extensions: r.note_extensions, note_extensions: r.note_extensions,
mood: r.mood,
}) })
} }
} }
const THOUGHT_SELECT: &str = 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] #[async_trait]
impl ThoughtRepository for PgThoughtRepository { impl ThoughtRepository for PgThoughtRepository {
async fn save(&self, t: &Thought) -> Result<(), DomainError> { async fn save(&self, t: &Thought) -> Result<(), DomainError> {
sqlx::query( sqlx::query(
"INSERT INTO thoughts(id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at) "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) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT(id) DO UPDATE SET content=EXCLUDED.content,updated_at=NOW()" ON CONFLICT(id) DO UPDATE SET content=EXCLUDED.content,updated_at=NOW()"
) )
.bind(t.id.as_uuid()) .bind(t.id.as_uuid())
@@ -76,6 +78,7 @@ impl ThoughtRepository for PgThoughtRepository {
.bind(t.sensitive) .bind(t.sensitive)
.bind(t.local) .bind(t.local)
.bind(t.created_at) .bind(t.created_at)
.bind(&t.mood)
.execute(&self.pool) .execute(&self.pool)
.await .await
.into_domain() .into_domain()
@@ -119,11 +122,11 @@ impl ThoughtRepository for PgThoughtRepository {
sqlx::query_as::<_, ThoughtRow>( sqlx::query_as::<_, ThoughtRow>(
"WITH RECURSIVE thread AS ( "WITH RECURSIVE thread AS (
SELECT id,user_id,content,in_reply_to_id, 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 FROM thoughts WHERE id = $1
UNION ALL UNION ALL
SELECT t.id,t.user_id,t.content,t.in_reply_to_id, 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 FROM thoughts t JOIN thread ON t.in_reply_to_id = thread.id
) )
SELECT * FROM thread ORDER BY created_at ASC", 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, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
repo.save(&t).await.unwrap(); repo.save(&t).await.unwrap();
let found = repo.find_by_id(&t.id).await.unwrap().unwrap(); let found = repo.find_by_id(&t.id).await.unwrap().unwrap();
@@ -33,6 +34,7 @@ async fn delete_thought(pool: sqlx::PgPool) {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
repo.save(&t).await.unwrap(); repo.save(&t).await.unwrap();
repo.delete(&t.id, &user.id).await.unwrap(); repo.delete(&t.id, &user.id).await.unwrap();
@@ -52,6 +54,7 @@ async fn delete_wrong_owner_returns_not_found(pool: sqlx::PgPool) {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
repo.save(&t).await.unwrap(); repo.save(&t).await.unwrap();
let err = repo.delete(&t.id, &bob.id).await.unwrap_err(); let err = repo.delete(&t.id, &bob.id).await.unwrap_err();
@@ -70,6 +73,7 @@ async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
let reply = Thought::new_local(NewThought { let reply = Thought::new_local(NewThought {
id: ThoughtId::new(), id: ThoughtId::new(),
@@ -79,6 +83,7 @@ async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
repo.save(&root).await.unwrap(); repo.save(&root).await.unwrap();
repo.save(&reply).await.unwrap(); repo.save(&reply).await.unwrap();

View File

@@ -54,7 +54,7 @@ impl TopFriendRepository for PgTopFriendRepository {
let rows = sqlx::query_as::<_, TopFriendRow>( let rows = sqlx::query_as::<_, TopFriendRow>(
"SELECT tf.user_id AS tf_user_id, tf.friend_id, tf.position, "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.id, u.username, u.email, u.password_hash, u.display_name, u.bio,
u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.local, u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.custom_moods, u.local,
u.created_at, u.updated_at u.created_at, u.updated_at
FROM top_friends tf JOIN users u ON u.id=tf.friend_id FROM top_friends tf JOIN users u ON u.id=tf.friend_id
WHERE tf.user_id=$1 ORDER BY tf.position", WHERE tf.user_id=$1 ORDER BY tf.position",

View File

@@ -32,6 +32,7 @@ pub struct UserRow {
pub header_url: Option<String>, pub header_url: Option<String>,
pub custom_css: Option<String>, pub custom_css: Option<String>,
pub profile_fields: Option<serde_json::Value>, pub profile_fields: Option<serde_json::Value>,
pub custom_moods: Option<serde_json::Value>,
pub local: bool, pub local: bool,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
@@ -50,6 +51,7 @@ impl From<UserRow> for User {
header_url: r.header_url, header_url: r.header_url,
custom_css: r.custom_css, custom_css: r.custom_css,
profile_fields: crate::jsonb::parse_name_value(r.profile_fields), profile_fields: crate::jsonb::parse_name_value(r.profile_fields),
custom_moods: crate::jsonb::parse_name_value(r.custom_moods),
local: r.local, local: r.local,
created_at: r.created_at, created_at: r.created_at,
updated_at: r.updated_at, updated_at: r.updated_at,
@@ -59,7 +61,7 @@ impl From<UserRow> for User {
pub const USER_SELECT: &str = pub const USER_SELECT: &str =
"SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,\ "SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,\
custom_css,profile_fields,local,created_at,updated_at FROM users"; custom_css,profile_fields,custom_moods,local,created_at,updated_at FROM users";
#[async_trait] #[async_trait]
impl UserReader for PgUserRepository { impl UserReader for PgUserRepository {
@@ -225,15 +227,17 @@ impl UserReader for PgUserRepository {
impl UserWriter for PgUserRepository { impl UserWriter for PgUserRepository {
async fn save(&self, user: &User) -> Result<(), DomainError> { async fn save(&self, user: &User) -> Result<(), DomainError> {
let profile_fields_json = crate::jsonb::serialize_name_value(&user.profile_fields); 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( sqlx::query(
"INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,profile_fields,local,created_at,updated_at) "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) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
username=EXCLUDED.username, email=EXCLUDED.email, username=EXCLUDED.username, email=EXCLUDED.email,
password_hash=EXCLUDED.password_hash, display_name=EXCLUDED.display_name, password_hash=EXCLUDED.password_hash, display_name=EXCLUDED.display_name,
bio=EXCLUDED.bio, avatar_url=EXCLUDED.avatar_url, bio=EXCLUDED.bio, avatar_url=EXCLUDED.avatar_url,
header_url=EXCLUDED.header_url, custom_css=EXCLUDED.custom_css, header_url=EXCLUDED.header_url, custom_css=EXCLUDED.custom_css,
profile_fields=EXCLUDED.profile_fields, profile_fields=EXCLUDED.profile_fields,
custom_moods=EXCLUDED.custom_moods,
local=EXCLUDED.local, local=EXCLUDED.local,
updated_at=NOW()" updated_at=NOW()"
) )
@@ -247,6 +251,7 @@ impl UserWriter for PgUserRepository {
.bind(&user.header_url) .bind(&user.header_url)
.bind(&user.custom_css) .bind(&user.custom_css)
.bind(&profile_fields_json) .bind(&profile_fields_json)
.bind(&custom_moods_json)
.bind(user.local) .bind(user.local)
.bind(user.created_at) .bind(user.created_at)
.bind(user.updated_at) .bind(user.updated_at)
@@ -276,6 +281,10 @@ impl UserWriter for PgUserRepository {
.profile_fields .profile_fields
.as_ref() .as_ref()
.map(|f| crate::jsonb::serialize_name_value(f)); .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( sqlx::query(
"UPDATE users SET \ "UPDATE users SET \
display_name = COALESCE($2, display_name), \ display_name = COALESCE($2, display_name), \
@@ -284,6 +293,7 @@ impl UserWriter for PgUserRepository {
header_url = COALESCE($5, header_url), \ header_url = COALESCE($5, header_url), \
custom_css = COALESCE($6, custom_css), \ custom_css = COALESCE($6, custom_css), \
profile_fields = COALESCE($7, profile_fields), \ profile_fields = COALESCE($7, profile_fields), \
custom_moods = COALESCE($8, custom_moods), \
updated_at = NOW() \ updated_at = NOW() \
WHERE id = $1", WHERE id = $1",
) )
@@ -294,6 +304,7 @@ impl UserWriter for PgUserRepository {
.bind(input.header_url) .bind(input.header_url)
.bind(input.custom_css) .bind(input.custom_css)
.bind(profile_fields_json) .bind(profile_fields_json)
.bind(custom_moods_json)
.execute(&self.pool) .execute(&self.pool)
.await .await
.into_domain() .into_domain()

View File

@@ -31,6 +31,7 @@ pub struct CreateThoughtRequest {
pub visibility: Option<String>, pub visibility: Option<String>,
pub content_warning: Option<String>, pub content_warning: Option<String>,
pub sensitive: Option<bool>, pub sensitive: Option<bool>,
pub mood: Option<String>,
} }
#[derive(Deserialize, utoipa::ToSchema)] #[derive(Deserialize, utoipa::ToSchema)]
@@ -48,6 +49,7 @@ pub struct UpdateProfileRequest {
pub header_url: Option<String>, pub header_url: Option<String>,
pub custom_css: Option<String>, pub custom_css: Option<String>,
pub profile_fields: Option<Vec<crate::responses::ProfileField>>, pub profile_fields: Option<Vec<crate::responses::ProfileField>>,
pub custom_moods: Option<Vec<crate::responses::ProfileField>>,
} }
#[derive(Deserialize, utoipa::ToSchema)] #[derive(Deserialize, utoipa::ToSchema)]

View File

@@ -20,6 +20,7 @@ pub struct UserResponse {
pub header_url: Option<String>, pub header_url: Option<String>,
pub custom_css: Option<String>, pub custom_css: Option<String>,
pub profile_fields: Vec<ProfileField>, pub profile_fields: Vec<ProfileField>,
pub custom_moods: Vec<ProfileField>,
pub local: bool, pub local: bool,
pub is_followed_by_viewer: bool, pub is_followed_by_viewer: bool,
#[serde(rename = "joinedAt")] #[serde(rename = "joinedAt")]
@@ -48,6 +49,8 @@ pub struct ThoughtResponse {
pub updated_at: Option<DateTime<Utc>>, pub updated_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub note_extensions: Option<serde_json::Value>, pub note_extensions: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mood: Option<String>,
} }
#[derive(Serialize, utoipa::ToSchema)] #[derive(Serialize, utoipa::ToSchema)]

View File

@@ -5,7 +5,6 @@ edition = "2021"
[dependencies] [dependencies]
domain = { workspace = true } domain = { workspace = true }
activitypub = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
@@ -19,5 +18,6 @@ bytes = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] }
domain = { workspace = true, features = ["test-helpers"] } domain = { workspace = true, features = ["test-helpers"] }
serde_json = { workspace = true }

View File

@@ -1,9 +1,8 @@
use activitypub::{ActivityPubRepository, OutboundFederationPort};
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::thought::Visibility, models::thought::Visibility,
ports::{ThoughtRepository, UserReader}, ports::{FederationBroadcastPort, FederationContentRepository, ThoughtRepository, UserReader},
value_objects::ThoughtId, value_objects::ThoughtId,
}; };
use std::sync::Arc; use std::sync::Arc;
@@ -15,9 +14,9 @@ fn should_broadcast(t: &domain::models::thought::Thought) -> bool {
pub struct FederationEventService { pub struct FederationEventService {
pub thoughts: Arc<dyn ThoughtRepository>, pub thoughts: Arc<dyn ThoughtRepository>,
pub users: Arc<dyn UserReader>, pub users: Arc<dyn UserReader>,
pub ap: Arc<dyn OutboundFederationPort>, pub ap: Arc<dyn FederationBroadcastPort>,
pub base_url: String, pub base_url: String,
pub ap_repo: Arc<dyn ActivityPubRepository>, pub ap_repo: Arc<dyn FederationContentRepository>,
} }
impl FederationEventService { impl FederationEventService {

View File

@@ -1,7 +1,7 @@
use super::*; use super::*;
use crate::testing::TestApRepo; use crate::testing::TestApRepo;
use activitypub::{ActorApUrls, OutboundFederationPort};
use async_trait::async_trait; use async_trait::async_trait;
use domain::ports::{ActorFederationUrls, FederationBroadcastPort};
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
@@ -27,7 +27,7 @@ struct SpyPort {
} }
#[async_trait] #[async_trait]
impl OutboundFederationPort for SpyPort { impl FederationBroadcastPort for SpyPort {
async fn broadcast_create( async fn broadcast_create(
&self, &self,
_: &UserId, _: &UserId,
@@ -100,6 +100,7 @@ fn local_thought(author_id: UserId) -> Thought {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}) })
} }
@@ -283,6 +284,7 @@ async fn direct_thought_created_does_not_broadcast() {
visibility: Visibility::Direct, visibility: Visibility::Direct,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
store.users.lock().unwrap().push(alice.clone()); store.users.lock().unwrap().push(alice.clone());
store.thoughts.lock().unwrap().push(thought.clone()); store.thoughts.lock().unwrap().push(thought.clone());
@@ -312,6 +314,7 @@ async fn followers_only_thought_does_not_broadcast_publicly() {
visibility: Visibility::Followers, visibility: Visibility::Followers,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
store.users.lock().unwrap().push(alice.clone()); store.users.lock().unwrap().push(alice.clone());
store.thoughts.lock().unwrap().push(thought.clone()); store.thoughts.lock().unwrap().push(thought.clone());
@@ -479,7 +482,7 @@ async fn like_added_local_user_remote_thought_broadcasts_like() {
let ap_repo = TestApRepo::new(store.clone()); let ap_repo = TestApRepo::new(store.clone());
ap_repo.actor_ap_urls.lock().unwrap().insert( ap_repo.actor_ap_urls.lock().unwrap().insert(
author.id.clone(), author.id.clone(),
ActorApUrls { ActorFederationUrls {
ap_id: "https://mastodon.social/users/author".into(), ap_id: "https://mastodon.social/users/author".into(),
inbox_url: "https://mastodon.social/users/author/inbox".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, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
store.thoughts.lock().unwrap().push(thought.clone()); store.thoughts.lock().unwrap().push(thought.clone());
let svc = NotificationEventService { let svc = NotificationEventService {
@@ -62,6 +63,7 @@ async fn self_like_creates_no_notification() {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
store.thoughts.lock().unwrap().push(thought.clone()); store.thoughts.lock().unwrap().push(thought.clone());
let svc = NotificationEventService { let svc = NotificationEventService {
@@ -111,6 +113,7 @@ async fn reply_creates_notification_for_original_author() {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
store.thoughts.lock().unwrap().push(original.clone()); store.thoughts.lock().unwrap().push(original.clone());
let svc = NotificationEventService { let svc = NotificationEventService {
@@ -141,6 +144,7 @@ async fn self_reply_creates_no_notification() {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
store.thoughts.lock().unwrap().push(original.clone()); store.thoughts.lock().unwrap().push(original.clone());
let svc = NotificationEventService { let svc = NotificationEventService {
@@ -169,6 +173,7 @@ async fn self_boost_creates_no_notification() {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
store.thoughts.lock().unwrap().push(thought.clone()); store.thoughts.lock().unwrap().push(thought.clone());
let svc = NotificationEventService { 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 async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::user::User, models::user::User,
ports::{AcceptNoteInput, ActorFederationUrls, FederationContentRepository, OutboxEntry},
testing::TestStore, testing::TestStore,
value_objects::{Email, PasswordHash, ThoughtId, UserId, Username}, value_objects::{Email, ThoughtId, UserId, Username},
}; };
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -14,8 +13,8 @@ use std::sync::{Arc, Mutex};
#[derive(Default, Clone)] #[derive(Default, Clone)]
pub struct TestApRepo { pub struct TestApRepo {
pub inner: TestStore, pub inner: TestStore,
/// UserId → ActorApUrls (for get_actor_ap_urls) /// UserId → ActorFederationUrls (for get_actor_ap_urls)
pub actor_ap_urls: Arc<Mutex<HashMap<UserId, ActorApUrls>>>, pub actor_ap_urls: Arc<Mutex<HashMap<UserId, ActorFederationUrls>>>,
} }
impl TestApRepo { impl TestApRepo {
@@ -28,7 +27,7 @@ impl TestApRepo {
} }
#[async_trait] #[async_trait]
impl ActivityPubRepository for TestApRepo { impl FederationContentRepository for TestApRepo {
async fn outbox_entries_for_actor( async fn outbox_entries_for_actor(
&self, &self,
_uid: &UserId, _uid: &UserId,
@@ -63,21 +62,11 @@ impl ActivityPubRepository for TestApRepo {
let handle = url::Url::parse(actor_ap_url) let handle = url::Url::parse(actor_ap_url)
.map(|u| u.path().trim_start_matches('/').replace('/', "_")) .map(|u| u.path().trim_start_matches('/').replace('/', "_"))
.unwrap_or_else(|_| format!("remote_{}", &uid.to_string()[..8])); .unwrap_or_else(|_| format!("remote_{}", &uid.to_string()[..8]));
let user = User { let user = User::new_remote(
id: uid.clone(), uid.clone(),
username: Username::from_trusted(handle), Username::from_trusted(handle),
email: Email::from_trusted(format!("{}@remote", uid)), Email::from_trusted(format!("{}@remote", uid)),
password_hash: PasswordHash("".into()), );
display_name: None,
bio: None,
avatar_url: None,
header_url: None,
custom_css: None,
profile_fields: vec![],
local: false,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
self.inner.users.lock().unwrap().push(user); self.inner.users.lock().unwrap().push(user);
self.inner self.inner
.actor_ap_ids .actor_ap_ids
@@ -94,13 +83,15 @@ impl ActivityPubRepository for TestApRepo {
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
Ok(()) Ok(())
} }
async fn accept_note( async fn accept_note(&self, _input: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError> {
&self,
_input: activitypub::AcceptNoteInput<'_>,
) -> Result<ThoughtId, DomainError> {
Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4())) 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(()) Ok(())
} }
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> { async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {
@@ -134,7 +125,7 @@ impl ActivityPubRepository for TestApRepo {
async fn get_actor_ap_urls( async fn get_actor_ap_urls(
&self, &self,
user_id: &UserId, user_id: &UserId,
) -> Result<Option<ActorApUrls>, DomainError> { ) -> Result<Option<ActorFederationUrls>, DomainError> {
Ok(self.actor_ap_urls.lock().unwrap().get(user_id).cloned()) 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> { 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::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
@@ -8,9 +7,10 @@ use domain::{
remote_actor::RemoteActor, remote_actor::RemoteActor,
}, },
ports::{ ports::{
EventPublisher, FederationActionPort, FederationFollowPort, FederationFollowRequestPort, EventPublisher, FederationActionPort, FederationContentRepository, FederationFollowPort,
FederationSchedulerPort, FeedOptions, FeedQuery, FeedRepository, FeedRequest, FederationFollowRequestPort, FederationSchedulerPort, FeedOptions, FeedQuery,
FollowRepository, RemoteActorConnectionRepository, UserReader, UserWriter, FeedRepository, FeedRequest, FollowRepository, RemoteActorConnectionRepository, UserReader,
UserWriter,
}, },
value_objects::UserId, value_objects::UserId,
}; };
@@ -119,7 +119,7 @@ pub async fn remove_remote_following(
pub async fn get_remote_actor_posts( pub async fn get_remote_actor_posts(
federation: &dyn FederationActionPort, federation: &dyn FederationActionPort,
ap_repo: &dyn ActivityPubRepository, ap_repo: &dyn FederationContentRepository,
feed: &dyn FeedRepository, feed: &dyn FeedRepository,
scheduler: &dyn FederationSchedulerPort, scheduler: &dyn FederationSchedulerPort,
handle: &str, handle: &str,

View File

@@ -2,6 +2,9 @@ const MAX_TOP_FRIENDS: usize = 8;
const MAX_PROFILE_FIELDS: usize = 4; const MAX_PROFILE_FIELDS: usize = 4;
const MAX_FIELD_NAME_LEN: usize = 64; const MAX_FIELD_NAME_LEN: usize = 64;
const MAX_FIELD_VALUE_LEN: usize = 256; 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 bytes::Bytes;
use domain::{ use domain::{
@@ -72,6 +75,20 @@ pub async fn update_profile(
} }
} }
} }
if let Some(ref moods) = input.custom_moods {
if moods.len() > MAX_CUSTOM_MOODS {
return Err(DomainError::InvalidInput(format!(
"custom moods: max {MAX_CUSTOM_MOODS}"
)));
}
for (label, emoji) in moods {
if label.len() > MAX_MOOD_LABEL_LEN || emoji.len() > MAX_MOOD_EMOJI_LEN {
return Err(DomainError::InvalidInput(
"custom mood label or emoji too long".into(),
));
}
}
}
users.update_profile(user_id, input).await?; users.update_profile(user_id, input).await?;
events events
.publish(&DomainEvent::ProfileUpdated { .publish(&DomainEvent::ProfileUpdated {

View File

@@ -34,6 +34,7 @@ async fn like_and_unlike() {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
})); }));
like_thought(&store, &store, &alice.id, &tid).await.unwrap(); like_thought(&store, &store, &alice.id, &tid).await.unwrap();
assert_eq!(store.likes.lock().unwrap().len(), 1); assert_eq!(store.likes.lock().unwrap().len(), 1);

View File

@@ -26,6 +26,7 @@ pub struct CreateThoughtInput {
pub visibility: Option<String>, pub visibility: Option<String>,
pub content_warning: Option<String>, pub content_warning: Option<String>,
pub sensitive: bool, pub sensitive: bool,
pub mood: Option<String>,
} }
pub struct CreateThoughtOutput { pub struct CreateThoughtOutput {
pub thought: Thought, pub thought: Thought,
@@ -39,6 +40,11 @@ pub async fn create_thought(
outbox: &dyn OutboxWriter, outbox: &dyn OutboxWriter,
input: CreateThoughtInput, input: CreateThoughtInput,
) -> Result<CreateThoughtOutput, DomainError> { ) -> 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 content = Content::new_local(input.content)?;
let visibility = match input.visibility.as_deref() { let visibility = match input.visibility.as_deref() {
Some("followers") => Visibility::Followers, Some("followers") => Visibility::Followers,
@@ -54,6 +60,7 @@ pub async fn create_thought(
visibility, visibility,
content_warning: input.content_warning, content_warning: input.content_warning,
sensitive: input.sensitive, sensitive: input.sensitive,
mood: input.mood,
}); });
thoughts.save(&thought).await?; thoughts.save(&thought).await?;

View File

@@ -22,6 +22,7 @@ fn input(uid: UserId) -> CreateThoughtInput {
visibility: None, visibility: None,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
} }
} }
@@ -207,6 +208,7 @@ async fn create_reply_sets_in_reply_to_id() {
visibility: None, visibility: None,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}, },
) )
.await .await
@@ -243,6 +245,7 @@ fn make_thought(user_id: UserId) -> Thought {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}) })
} }
@@ -295,6 +298,7 @@ async fn get_thread_views_batches_correctly() {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
<TestStore as ThoughtRepository>::save(&store, &reply) <TestStore as ThoughtRepository>::save(&store, &reply)
.await .await

View File

@@ -14,7 +14,7 @@ postgres = { workspace = true }
postgres-search = { workspace = true } postgres-search = { workspace = true }
postgres-federation = { workspace = true } postgres-federation = { workspace = true }
activitypub = { 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 } serde_json = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
nats = { workspace = true } nats = { workspace = true }

View File

@@ -31,12 +31,12 @@ impl Config {
Self { Self {
database_url: std::env::var("DATABASE_URL").expect("DATABASE_URL is required"), database_url: std::env::var("DATABASE_URL").expect("DATABASE_URL is required"),
jwt_secret: std::env::var("JWT_SECRET").expect("JWT_SECRET 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(), nats_url: std::env::var("NATS_URL").ok(),
port: std::env::var("PORT") port: std::env::var("PORT")
.ok() .ok()
.and_then(|p| p.parse().ok()) .and_then(|p| p.parse().ok())
.unwrap_or(3000), .unwrap_or(8000),
allow_registration: std::env::var("ALLOW_REGISTRATION") allow_registration: std::env::var("ALLOW_REGISTRATION")
.map(|v| v == "true") .map(|v| v == "true")
.unwrap_or(true), .unwrap_or(true),

View File

@@ -22,6 +22,7 @@ pub struct Thought {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: Option<DateTime<Utc>>, pub updated_at: Option<DateTime<Utc>>,
pub note_extensions: Option<serde_json::Value>, pub note_extensions: Option<serde_json::Value>,
pub mood: Option<String>,
} }
impl Visibility { impl Visibility {
@@ -55,6 +56,7 @@ pub struct NewThought {
pub visibility: Visibility, pub visibility: Visibility,
pub content_warning: Option<String>, pub content_warning: Option<String>,
pub sensitive: bool, pub sensitive: bool,
pub mood: Option<String>,
} }
impl Thought { impl Thought {
@@ -71,6 +73,7 @@ impl Thought {
created_at: Utc::now(), created_at: Utc::now(),
updated_at: None, updated_at: None,
note_extensions: None, note_extensions: None,
mood: p.mood,
} }
} }
} }

View File

@@ -9,6 +9,7 @@ pub struct UpdateProfileInput {
pub header_url: Option<String>, pub header_url: Option<String>,
pub custom_css: Option<String>, pub custom_css: Option<String>,
pub profile_fields: Option<Vec<(String, String)>>, pub profile_fields: Option<Vec<(String, String)>>,
pub custom_moods: Option<Vec<(String, String)>>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -23,6 +24,7 @@ pub struct User {
pub header_url: Option<String>, pub header_url: Option<String>,
pub custom_css: Option<String>, pub custom_css: Option<String>,
pub profile_fields: Vec<(String, String)>, pub profile_fields: Vec<(String, String)>,
pub custom_moods: Vec<(String, String)>,
pub local: bool, pub local: bool,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
@@ -47,9 +49,30 @@ impl User {
header_url: None, header_url: None,
custom_css: None, custom_css: None,
profile_fields: vec![], profile_fields: vec![],
custom_moods: vec![],
local: true, local: true,
created_at: now, created_at: now,
updated_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, page: u32,
) -> Result<(), DomainError>; ) -> 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] [dependencies]
domain = { workspace = true } domain = { workspace = true }
activitypub = { workspace = true }
application = { workspace = true } application = { workspace = true }
api-types = { workspace = true } api-types = { workspace = true }
axum = { workspace = true } axum = { workspace = true }

View File

@@ -5,6 +5,7 @@ use api_types::{
}; };
use application::use_cases::auth::{login, register, LoginInput, RegisterInput}; use application::use_cases::auth::{login, register, LoginInput, RegisterInput};
use axum::{http::StatusCode, response::IntoResponse, Json}; use axum::{http::StatusCode, response::IntoResponse, Json};
use domain::models::feed::UserSummary;
use domain::ports::{AuthService, EventPublisher, PasswordHasher, UserRepository}; use domain::ports::{AuthService, EventPublisher, PasswordHasher, UserRepository};
deps_struct!(AuthDeps { deps_struct!(AuthDeps {
@@ -31,12 +32,37 @@ pub fn to_user_response(u: &domain::models::user::User) -> UserResponse {
value: v.clone(), value: v.clone(),
}) })
.collect(), .collect(),
custom_moods: u
.custom_moods
.iter()
.map(|(n, v)| ProfileField {
name: n.clone(),
value: v.clone(),
})
.collect(),
local: u.local, local: u.local,
is_followed_by_viewer: false, is_followed_by_viewer: false,
created_at: u.created_at, 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( #[utoipa::path(
post, path = "/auth/register", post, path = "/auth/register",
request_body = RegisterRequest, request_body = RegisterRequest,

View File

@@ -4,7 +4,6 @@ use crate::{
handlers::feed::to_thought_response, handlers::feed::to_thought_response,
state::AppState, state::AppState,
}; };
use activitypub::ActivityPubRepository;
use api_types::{ use api_types::{
requests::PaginationQuery, requests::PaginationQuery,
responses::{ responses::{
@@ -18,6 +17,7 @@ use axum::{
extract::{Path, Query}, extract::{Path, Query},
Json, Json,
}; };
use domain::ports::FederationContentRepository;
use domain::{ use domain::{
models::feed::PageParams, models::feed::PageParams,
ports::{ ports::{
@@ -29,7 +29,7 @@ use std::sync::Arc;
pub struct FederationActorsDeps { pub struct FederationActorsDeps {
pub federation: Arc<dyn FederationActionPort>, pub federation: Arc<dyn FederationActionPort>,
pub ap_repo: Arc<dyn ActivityPubRepository>, pub ap_repo: Arc<dyn FederationContentRepository>,
pub feed: Arc<dyn FeedRepository>, pub feed: Arc<dyn FeedRepository>,
pub federation_scheduler: Arc<dyn FederationSchedulerPort>, pub federation_scheduler: Arc<dyn FederationSchedulerPort>,
pub remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>, 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, created_at: e.thought.created_at,
updated_at: e.thought.updated_at, updated_at: e.thought.updated_at,
note_extensions: e.thought.note_extensions.clone(), 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, visibility: body.visibility,
content_warning: body.content_warning, content_warning: body.content_warning,
sensitive: body.sensitive.unwrap_or(false), sensitive: body.sensitive.unwrap_or(false),
mood: body.mood,
}, },
) )
.await?; .await?;

View File

@@ -1,7 +1,7 @@
use crate::{ use crate::{
errors::ApiError, errors::ApiError,
extractors::{AuthUser, Deps, FromAppState, OptionalAuthUser}, extractors::{AuthUser, Deps, FromAppState, OptionalAuthUser},
handlers::auth::to_user_response, handlers::auth::{to_summary_response, to_user_response},
state::AppState, state::AppState,
}; };
use api_types::{ use api_types::{
@@ -117,6 +117,9 @@ pub async fn patch_profile(
profile_fields: body profile_fields: body
.profile_fields .profile_fields
.map(|f| f.into_iter().map(|pf| (pf.name, pf.value)).collect()), .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?; .await?;
@@ -200,23 +203,7 @@ pub async fn get_users(
} }
let result = list_users(&*d.users, page_params).await?; let result = list_users(&*d.users, page_params).await?;
let items: Vec<UserResponse> = result let items: Vec<UserResponse> = result.items.iter().map(to_summary_response).collect();
.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,
profile_fields: vec![],
local: true,
is_followed_by_viewer: false,
created_at: chrono::Utc::now(),
})
.collect();
Ok(Json(PagedResponse { Ok(Json(PagedResponse {
items, items,
total: result.total, total: result.total,

View File

@@ -1,4 +1,3 @@
use activitypub::ActivityPubRepository;
use application::use_cases::profile::UploadConfig; use application::use_cases::profile::UploadConfig;
use domain::ports::*; use domain::ports::*;
use std::sync::Arc; use std::sync::Arc;
@@ -24,7 +23,7 @@ pub struct AppState {
pub events: Arc<dyn EventPublisher>, pub events: Arc<dyn EventPublisher>,
pub outbox: Arc<dyn OutboxWriter>, pub outbox: Arc<dyn OutboxWriter>,
pub federation: Arc<dyn FederationActionPort>, 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 remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>,
pub federation_scheduler: Arc<dyn FederationSchedulerPort>, pub federation_scheduler: Arc<dyn FederationSchedulerPort>,
pub engagement: Arc<dyn EngagementRepository>, pub engagement: Arc<dyn EngagementRepository>,

View File

@@ -1,7 +1,9 @@
use crate::state::AppState; use crate::state::AppState;
use activitypub::{ActivityPubRepository, ActorApUrls, OutboxEntry};
use application::use_cases::profile::UploadConfig; use application::use_cases::profile::UploadConfig;
use async_trait::async_trait; use async_trait::async_trait;
use domain::ports::{
AcceptNoteInput, ActorFederationUrls, FederationContentRepository, OutboxEntry,
};
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
ports::{AuthService, DataStream, GeneratedToken, MediaStore, PasswordHasher}, ports::{AuthService, DataStream, GeneratedToken, MediaStore, PasswordHasher},
@@ -34,7 +36,7 @@ impl PasswordHasher for NoOpHasher {
pub struct NoOpApRepo; pub struct NoOpApRepo;
#[async_trait] #[async_trait]
impl ActivityPubRepository for NoOpApRepo { impl FederationContentRepository for NoOpApRepo {
async fn outbox_entries_for_actor(&self, _: &UserId) -> Result<Vec<OutboxEntry>, DomainError> { async fn outbox_entries_for_actor(&self, _: &UserId) -> Result<Vec<OutboxEntry>, DomainError> {
Ok(vec![]) Ok(vec![])
} }
@@ -60,13 +62,15 @@ impl ActivityPubRepository for NoOpApRepo {
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
Ok(()) Ok(())
} }
async fn accept_note( async fn accept_note(&self, _: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError> {
&self,
_: activitypub::AcceptNoteInput<'_>,
) -> Result<ThoughtId, DomainError> {
Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4())) 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(()) Ok(())
} }
async fn retract_note(&self, _: &str) -> Result<(), DomainError> { 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> { async fn get_thought_ap_id(&self, _: &ThoughtId) -> Result<Option<String>, DomainError> {
Ok(None) 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) Ok(None)
} }
async fn sync_remote_actor_to_user(&self, _: &str) -> Result<(), DomainError> { async fn sync_remote_actor_to_user(&self, _: &str) -> Result<(), DomainError> {

View File

@@ -13,7 +13,7 @@ application = { workspace = true }
nats = { workspace = true } nats = { workspace = true }
event-transport = { workspace = true } event-transport = { workspace = true }
event-payload = { 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 } activitypub = { workspace = true }
postgres = { workspace = true } postgres = { workspace = true }
postgres-federation = { 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 files (can opt-in for committing if needed)
.env* .env*
!.env.example
# vercel # vercel
.vercel .vercel

View File

@@ -114,7 +114,7 @@ async function FeedPage({
<header className="mb-6"> <header className="mb-6">
<h1 className="text-3xl font-bold text-shadow-sm">Your Feed</h1> <h1 className="text-3xl font-bold text-shadow-sm">Your Feed</h1>
</header> </header>
<ThoughtForm /> <ThoughtForm currentUser={me} />
<div className="block lg:hidden space-y-6">{sidebar}</div> <div className="block lg:hidden space-y-6">{sidebar}</div>

View File

@@ -9,6 +9,7 @@ export const metadata: Metadata = {
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getMe } from "@/lib/api"; import { getMe } from "@/lib/api";
import { EditProfileForm } from "@/components/edit-profile-form"; import { EditProfileForm } from "@/components/edit-profile-form";
import { CustomMoodsEditor } from "@/components/custom-moods-editor";
export default async function EditProfilePage() { export default async function EditProfilePage() {
const token = (await cookies()).get("auth_token")?.value; const token = (await cookies()).get("auth_token")?.value;
@@ -32,6 +33,7 @@ export default async function EditProfilePage() {
</p> </p>
</div> </div>
<EditProfileForm currentUser={me} token={token} /> <EditProfileForm currentUser={me} token={token} />
<CustomMoodsEditor initial={me.customMoods} />
</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_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

@@ -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> </CardContent>
{token && ( {token && (
@@ -244,6 +249,7 @@ export function ThoughtCard({
<ThoughtForm <ThoughtForm
replyToId={thought.id} replyToId={thought.id}
onSuccess={() => setIsReplyOpen(false)} onSuccess={() => setIsReplyOpen(false)}
currentUser={currentUser}
/> />
</div> </div>
)} )}

View File

@@ -1,45 +1,86 @@
"use client" "use client";
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod" import { z } from "zod";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card";
import { import {
Form, Form,
FormField, FormField,
FormItem, FormItem,
FormControl, FormControl,
FormMessage, FormMessage,
} from "@/components/ui/form" } from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select";
import { CreateThoughtSchema } from "@/lib/api" import { CreateThoughtSchema, type Me } from "@/lib/api";
import { useAuth } from "@/hooks/use-auth" import { useAuth } from "@/hooks/use-auth";
import { toast } from "sonner" import { toast } from "sonner";
import { Globe, Lock, Users } from "lucide-react" import { Globe, Lock, Users } from "lucide-react";
import { useState } from "react" import { useState } from "react";
import { Confetti } from "./confetti" import { Confetti } from "./confetti";
import { createThought } from "@/app/actions/thoughts" 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 { interface ThoughtFormProps {
/** Set to the parent thought ID when composing a reply. */ /** Set to the parent thought ID when composing a reply. */
replyToId?: string replyToId?: string;
/** Called after successful submit (e.g. close the reply panel). */ /** 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. */ /** 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) { export function ThoughtForm({
const { token } = useAuth() replyToId,
const [showConfetti, setShowConfetti] = useState(false) 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>>({ const form = useForm<z.infer<typeof CreateThoughtSchema>>({
resolver: zodResolver(CreateThoughtSchema), resolver: zodResolver(CreateThoughtSchema),
@@ -48,21 +89,23 @@ export function ThoughtForm({ replyToId, onSuccess, card = !replyToId }: Thought
visibility: "public", visibility: "public",
...(replyToId ? { inReplyToId: replyToId } : {}), ...(replyToId ? { inReplyToId: replyToId } : {}),
}, },
}) });
async function onSubmit(values: z.infer<typeof CreateThoughtSchema>) { async function onSubmit(values: z.infer<typeof CreateThoughtSchema>) {
if (!token) { if (!token) {
toast.error("You must be logged in.") toast.error("You must be logged in.");
return return;
} }
try { try {
await createThought(values) await createThought(values);
toast.success(replyToId ? "Reply posted!" : "Thought posted!") toast.success(replyToId ? "Reply posted!" : "Thought posted!");
setShowConfetti(true) setShowConfetti(true);
form.reset() form.reset();
onSuccess?.() onSuccess?.();
} catch { } 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> <FormItem>
<FormControl> <FormControl>
<Textarea <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" : ""}`} className={`resize-none ${replyToId ? "bg-white shadow-fa-sm" : ""}`}
{...field} {...field}
/> />
@@ -85,58 +130,110 @@ export function ThoughtForm({ replyToId, onSuccess, card = !replyToId }: Thought
</FormItem> </FormItem>
)} )}
/> />
<div className={`flex ${replyToId ? "justify-end gap-2" : "justify-between items-center"}`}> <div
{!replyToId && ( 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}
>
<FormControl>
<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>
</SelectItem>
<SelectItem value="followers">
<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>
</SelectItem>
<SelectItem value="direct">
<div className="flex items-center gap-2">
<Lock className="h-4 w-4" /> Direct
</div>
</SelectItem>
</SelectContent>
</Select>
)}
/>
)}
<FormField <FormField
control={form.control} control={form.control}
name="visibility" name="mood"
render={({ field }) => ( render={({ field }) => (
<Select onValueChange={field.onChange} defaultValue={field.value}> <Select
onValueChange={(v) =>
field.onChange(v === "__none__" ? undefined : v)
}
value={field.value ?? "__none__"}
>
<FormControl> <FormControl>
<SelectTrigger className="w-[150px]"> <SelectTrigger className="w-[170px]">
<SelectValue placeholder="Visibility" /> <SelectValue placeholder="How are you feeling?" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="public"> <SelectItem value="__none__">No mood</SelectItem>
<div className="flex items-center gap-2"><Globe className="h-4 w-4" /> Public</div> {allMoods.map((mood) => (
</SelectItem> <SelectItem key={mood} value={mood}>
<SelectItem value="followers"> {mood}
<div className="flex items-center gap-2"><Users className="h-4 w-4" /> Followers</div> </SelectItem>
</SelectItem> ))}
<SelectItem value="unlisted">
<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>
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
/> />
)} {replyToId && (
{replyToId && ( <Button
<Button type="button" variant="ghost" onClick={() => onSuccess?.()}> type="button"
Cancel variant="ghost"
</Button> onClick={() => onSuccess?.()}
)} >
Cancel
</Button>
)}
</div>
<Button type="submit" disabled={form.formState.isSubmitting}> <Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting {form.formState.isSubmitting
? (replyToId ? "Replying..." : "Posting...") ? replyToId
: (replyToId ? "Reply" : "Post Thought")} ? "Replying..."
: "Posting..."
: replyToId
? "Reply"
: "Post Thought"}
</Button> </Button>
</div> </div>
</form> </form>
</Form> </Form>
) );
return ( return (
<> <>
<Confetti fire={showConfetti} onComplete={() => setShowConfetti(false)} /> <Confetti fire={showConfetti} onComplete={() => setShowConfetti(false)} />
{card {card ? (
? <Card><CardContent className="p-4">{inner}</CardContent></Card> <Card>
: <div className="space-y-2 p-4">{inner}</div> <CardContent className="p-4">{inner}</CardContent>
} </Card>
) : (
<div className="space-y-2 p-4">{inner}</div>
)}
</> </>
) );
} }

View File

@@ -16,6 +16,7 @@ export const UserSchema = z.object({
headerUrl: z.string().nullable(), headerUrl: z.string().nullable(),
customCss: z.string().nullable(), customCss: z.string().nullable(),
profileFields: z.array(ProfileFieldSchema).default([]), profileFields: z.array(ProfileFieldSchema).default([]),
customMoods: z.array(ProfileFieldSchema).default([]),
local: z.boolean(), local: z.boolean(),
isFollowedByViewer: z.boolean(), isFollowedByViewer: z.boolean(),
joinedAt: z.coerce.date().nullable(), joinedAt: z.coerce.date().nullable(),
@@ -55,6 +56,7 @@ export const ThoughtSchema = z.object({
createdAt: z.coerce.date(), createdAt: z.coerce.date(),
updatedAt: z.coerce.date().nullable(), updatedAt: z.coerce.date().nullable(),
noteExtensions: z.record(z.string(), z.unknown()).nullish(), noteExtensions: z.record(z.string(), z.unknown()).nullish(),
mood: z.string().nullable().optional(),
}); });
export const RegisterSchema = z.object({ export const RegisterSchema = z.object({
@@ -72,6 +74,7 @@ export const CreateThoughtSchema = z.object({
content: z.string().min(1).max(128), content: z.string().min(1).max(128),
visibility: z.enum(["public", "followers", "unlisted", "direct"]).optional(), visibility: z.enum(["public", "followers", "unlisted", "direct"]).optional(),
inReplyToId: z.string().uuid().optional(), inReplyToId: z.string().uuid().optional(),
mood: z.string().max(64).optional(),
}); });
export const UpdateProfileSchema = z.object({ export const UpdateProfileSchema = z.object({
@@ -79,6 +82,7 @@ export const UpdateProfileSchema = z.object({
bio: z.string().max(4000).optional(), bio: z.string().max(4000).optional(),
customCss: z.string().optional(), customCss: z.string().optional(),
profileFields: z.array(ProfileFieldSchema).max(4).optional(), profileFields: z.array(ProfileFieldSchema).max(4).optional(),
customMoods: z.array(ProfileFieldSchema).max(8).optional(),
}); });
export const SearchResultsSchema = z.object({ export const SearchResultsSchema = z.object({
@@ -121,6 +125,7 @@ export const ThoughtThreadSchema: z.ZodType<{
createdAt: Date; createdAt: Date;
updatedAt: Date | null; updatedAt: Date | null;
noteExtensions?: Record<string, unknown> | null; noteExtensions?: Record<string, unknown> | null;
mood?: string | null;
replies: ThoughtThread[]; replies: ThoughtThread[];
}> = z.object({ }> = z.object({
id: z.string().uuid(), id: z.string().uuid(),
@@ -138,6 +143,7 @@ export const ThoughtThreadSchema: z.ZodType<{
createdAt: z.coerce.date(), createdAt: z.coerce.date(),
updatedAt: z.coerce.date().nullable(), updatedAt: z.coerce.date().nullable(),
noteExtensions: z.record(z.string(), z.unknown()).nullish(), noteExtensions: z.record(z.string(), z.unknown()).nullish(),
mood: z.string().nullable().optional(),
replies: z.lazy(() => z.array(ThoughtThreadSchema)), replies: z.lazy(() => z.array(ThoughtThreadSchema)),
}); });