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