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