From 5097c91261a0f36c2f8cfd3fb4041c81468facb2 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 24 May 2026 04:29:04 +0200 Subject: [PATCH] feat: store AP note extensions in JSONB and render movies-diary posts as rich cards --- crates/adapters/activitypub/src/handler.rs | 75 +++++++++++++ crates/adapters/activitypub/src/port.rs | 1 + crates/adapters/postgres-search/Cargo.toml | 1 + crates/adapters/postgres-search/src/lib.rs | 4 +- .../migrations/012_note_extensions.sql | 1 + .../adapters/postgres/src/activitypub/mod.rs | 8 +- .../postgres/src/activitypub/tests.rs | 2 + crates/adapters/postgres/src/feed/mod.rs | 3 + crates/adapters/postgres/src/thought/mod.rs | 8 +- crates/api-types/Cargo.toml | 7 +- crates/api-types/src/responses.rs | 2 + crates/domain/Cargo.toml | 1 + crates/domain/src/models/thought.rs | 2 + crates/presentation/src/handlers/feed.rs | 1 + thoughts-frontend/components/movie-card.tsx | 105 ++++++++++++++++++ thoughts-frontend/components/thought-card.tsx | 12 ++ thoughts-frontend/lib/api.ts | 3 + 17 files changed, 227 insertions(+), 9 deletions(-) create mode 100644 crates/adapters/postgres/migrations/012_note_extensions.sql create mode 100644 thoughts-frontend/components/movie-card.tsx diff --git a/crates/adapters/activitypub/src/handler.rs b/crates/adapters/activitypub/src/handler.rs index 91164cf..101bf22 100644 --- a/crates/adapters/activitypub/src/handler.rs +++ b/crates/adapters/activitypub/src/handler.rs @@ -14,6 +14,36 @@ use domain::ports::{EventPublisher, TagRepository}; use domain::value_objects::UserId; use k_ap::ApObjectHandler; +fn extract_note_extensions(obj: &serde_json::Value) -> Option { + const STANDARD: &[&str] = &[ + "type", + "id", + "attributedTo", + "content", + "published", + "to", + "cc", + "inReplyTo", + "sensitive", + "summary", + "tag", + "url", + "@context", + "mediaType", + ]; + let extensions: serde_json::Map = obj + .as_object()? + .iter() + .filter(|(k, _)| !STANDARD.contains(&k.as_str())) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + if extensions.is_empty() { + None + } else { + Some(serde_json::Value::Object(extensions)) + } +} + pub struct ThoughtsObjectHandler { repo: Arc, urls: ThoughtsUrls, @@ -118,6 +148,7 @@ impl ApObjectHandler for ThoughtsObjectHandler { actor_url: &Url, object: serde_json::Value, ) -> Result<()> { + let note_extensions = extract_note_extensions(&object); let note: ThoughtNote = serde_json::from_value(object)?; let author_id = self .repo @@ -153,6 +184,7 @@ impl ApObjectHandler for ThoughtsObjectHandler { content_warning: note.summary, visibility, in_reply_to: note.in_reply_to.as_ref().map(|u| u.as_str()), + note_extensions, }) .await .map_err(|e| anyhow!("{e}"))?; @@ -408,3 +440,46 @@ impl ApObjectHandler for ThoughtsObjectHandler { .map_err(|e| anyhow!("{e}")) } } + +#[cfg(test)] +mod extract_tests { + use super::extract_note_extensions; + + #[test] + fn extracts_non_standard_fields() { + let obj = serde_json::json!({ + "type": "Note", + "id": "https://example.com/notes/1", + "content": "hello", + "published": "2025-01-01T00:00:00Z", + "movieTitle": "Dune", + "rating": 5, + "posterUrl": "https://example.com/poster.jpg" + }); + let ext = extract_note_extensions(&obj).unwrap(); + assert_eq!(ext["movieTitle"], "Dune"); + assert_eq!(ext["rating"], 5); + assert_eq!(ext["posterUrl"], "https://example.com/poster.jpg"); + assert!(ext.get("type").is_none()); + assert!(ext.get("content").is_none()); + assert!(ext.get("id").is_none()); + } + + #[test] + fn returns_none_for_standard_only_note() { + let obj = serde_json::json!({ + "type": "Note", + "content": "hello", + "published": "2025-01-01T00:00:00Z", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "tag": [] + }); + assert!(extract_note_extensions(&obj).is_none()); + } + + #[test] + fn returns_none_for_non_object() { + let obj = serde_json::json!("not an object"); + assert!(extract_note_extensions(&obj).is_none()); + } +} diff --git a/crates/adapters/activitypub/src/port.rs b/crates/adapters/activitypub/src/port.rs index 810bae8..abb59b6 100644 --- a/crates/adapters/activitypub/src/port.rs +++ b/crates/adapters/activitypub/src/port.rs @@ -14,6 +14,7 @@ pub struct AcceptNoteInput<'a> { pub content_warning: Option, pub visibility: &'a str, pub in_reply_to: Option<&'a str>, + pub note_extensions: Option, } /// AP-protocol endpoints for a locally-stored user (local or interned remote). diff --git a/crates/adapters/postgres-search/Cargo.toml b/crates/adapters/postgres-search/Cargo.toml index 4d4c0bb..dedc4e1 100644 --- a/crates/adapters/postgres-search/Cargo.toml +++ b/crates/adapters/postgres-search/Cargo.toml @@ -10,6 +10,7 @@ sqlx = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } async-trait = { workspace = true } +serde_json = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["full"] } diff --git a/crates/adapters/postgres-search/src/lib.rs b/crates/adapters/postgres-search/src/lib.rs index 26a0209..5e3febb 100644 --- a/crates/adapters/postgres-search/src/lib.rs +++ b/crates/adapters/postgres-search/src/lib.rs @@ -52,6 +52,7 @@ struct FeedRow { reply_count: i64, liked_by_viewer: bool, boosted_by_viewer: bool, + note_extensions: Option, } fn feed_select(viewer: Option) -> String { @@ -67,7 +68,7 @@ fn feed_select(viewer: Option) -> String { t.id AS thought_id, t.user_id AS t_user_id, t.content,\n\ t.in_reply_to_id,\n\ t.visibility, t.content_warning, t.sensitive, t.local AS t_local,\n\ - t.created_at AS thought_created_at, t.updated_at,\n\ + t.created_at AS thought_created_at, t.updated_at, t.note_extensions,\n\ u.id AS author_id, u.username, u.email, u.password_hash,\n\ u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css,\n\ u.local AS author_local,\n\ @@ -92,6 +93,7 @@ fn row_to_entry(r: FeedRow, viewer: Option) -> Result (None, None), }; sqlx::query( - "INSERT INTO thoughts(id,user_id,content,ap_id,visibility,sensitive,local,content_warning,created_at,in_reply_to_id,in_reply_to_url) - VALUES($1,$2,$3,$4,$8,$5,false,$6,$7,$9,$10) ON CONFLICT(ap_id) DO NOTHING", + "INSERT INTO thoughts(id,user_id,content,ap_id,visibility,sensitive,local,content_warning,created_at,in_reply_to_id,in_reply_to_url,note_extensions) + VALUES($1,$2,$3,$4,$8,$5,false,$6,$7,$9,$10,$11) ON CONFLICT(ap_id) DO NOTHING", ) .bind(uuid::Uuid::new_v4()) .bind(author_id.as_uuid()) @@ -249,6 +252,7 @@ impl ActivityPubRepository for PgActivityPubRepository { .bind(visibility) .bind(in_reply_to_id) .bind(&in_reply_to_url) + .bind(note_extensions) .execute(&self.pool) .await .into_domain()?; diff --git a/crates/adapters/postgres/src/activitypub/tests.rs b/crates/adapters/postgres/src/activitypub/tests.rs index dfeaff7..751d8e7 100644 --- a/crates/adapters/postgres/src/activitypub/tests.rs +++ b/crates/adapters/postgres/src/activitypub/tests.rs @@ -25,6 +25,7 @@ async fn accept_and_retract_note(pool: sqlx::PgPool) { content_warning: None, visibility: "public", in_reply_to: None, + note_extensions: None, }) .await .unwrap(); @@ -55,6 +56,7 @@ async fn accept_note_returns_thought_id(pool: sqlx::PgPool) { content_warning: None, visibility: "public", in_reply_to: None, + note_extensions: None, }) .await .unwrap(); diff --git a/crates/adapters/postgres/src/feed/mod.rs b/crates/adapters/postgres/src/feed/mod.rs index a4016fc..d0e884f 100644 --- a/crates/adapters/postgres/src/feed/mod.rs +++ b/crates/adapters/postgres/src/feed/mod.rs @@ -35,6 +35,7 @@ struct FeedRow { t_local: bool, thought_created_at: DateTime, updated_at: Option>, + note_extensions: Option, author_id: uuid::Uuid, username: String, email: String, @@ -82,6 +83,7 @@ fn feed_select(viewer: Option) -> String { t.in_reply_to_id, t.visibility, t.content_warning, t.sensitive, t.local AS t_local, t.created_at AS thought_created_at, t.updated_at, + t.note_extensions, u.id AS author_id, CASE WHEN NOT u.local AND ra.handle IS NOT NULL AND ra.handle != '' THEN '@' || ra.handle || @@ -118,6 +120,7 @@ fn row_to_entry(r: FeedRow, viewer: Option) -> Result, pub updated_at: Option>, + pub note_extensions: Option, } impl TryFrom for Thought { @@ -50,12 +51,13 @@ impl TryFrom for Thought { local: r.local, created_at: r.created_at, updated_at: r.updated_at, + note_extensions: r.note_extensions, }) } } const THOUGHT_SELECT: &str = - "SELECT id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at,updated_at FROM thoughts"; + "SELECT id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at,updated_at,note_extensions FROM thoughts"; #[async_trait] impl ThoughtRepository for PgThoughtRepository { @@ -117,11 +119,11 @@ impl ThoughtRepository for PgThoughtRepository { sqlx::query_as::<_, ThoughtRow>( "WITH RECURSIVE thread AS ( SELECT id,user_id,content,in_reply_to_id, - visibility,content_warning,sensitive,local,created_at,updated_at + visibility,content_warning,sensitive,local,created_at,updated_at,note_extensions FROM thoughts WHERE id = $1 UNION ALL SELECT t.id,t.user_id,t.content,t.in_reply_to_id, - t.visibility,t.content_warning,t.sensitive,t.local,t.created_at,t.updated_at + t.visibility,t.content_warning,t.sensitive,t.local,t.created_at,t.updated_at,t.note_extensions FROM thoughts t JOIN thread ON t.in_reply_to_id = thread.id ) SELECT * FROM thread ORDER BY created_at ASC", diff --git a/crates/api-types/Cargo.toml b/crates/api-types/Cargo.toml index 10b1a48..1583c17 100644 --- a/crates/api-types/Cargo.toml +++ b/crates/api-types/Cargo.toml @@ -4,7 +4,8 @@ version = "0.1.0" edition = "2021" [dependencies] -serde = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } utoipa = { version = "5.5.0", features = ["uuid", "chrono"] } diff --git a/crates/api-types/src/responses.rs b/crates/api-types/src/responses.rs index fa6e9b8..6d68117 100644 --- a/crates/api-types/src/responses.rs +++ b/crates/api-types/src/responses.rs @@ -45,6 +45,8 @@ pub struct ThoughtResponse { pub boosted_by_viewer: bool, pub created_at: DateTime, pub updated_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub note_extensions: Option, } #[derive(Serialize, utoipa::ToSchema)] diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml index a6bab93..8728c60 100644 --- a/crates/domain/Cargo.toml +++ b/crates/domain/Cargo.toml @@ -12,6 +12,7 @@ thiserror = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } futures = { workspace = true } url = { workspace = true } bytes = { workspace = true } diff --git a/crates/domain/src/models/thought.rs b/crates/domain/src/models/thought.rs index 03f854b..d6a8e9b 100644 --- a/crates/domain/src/models/thought.rs +++ b/crates/domain/src/models/thought.rs @@ -21,6 +21,7 @@ pub struct Thought { pub local: bool, pub created_at: DateTime, pub updated_at: Option>, + pub note_extensions: Option, } impl Visibility { @@ -69,6 +70,7 @@ impl Thought { local: true, created_at: Utc::now(), updated_at: None, + note_extensions: None, } } } diff --git a/crates/presentation/src/handlers/feed.rs b/crates/presentation/src/handlers/feed.rs index 9917968..ed07d53 100644 --- a/crates/presentation/src/handlers/feed.rs +++ b/crates/presentation/src/handlers/feed.rs @@ -48,6 +48,7 @@ pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtRespon boosted_by_viewer: e.viewer.as_ref().map(|v| v.boosted).unwrap_or(false), created_at: e.thought.created_at, updated_at: e.thought.updated_at, + note_extensions: e.thought.note_extensions.clone(), } } diff --git a/thoughts-frontend/components/movie-card.tsx b/thoughts-frontend/components/movie-card.tsx new file mode 100644 index 0000000..a657657 --- /dev/null +++ b/thoughts-frontend/components/movie-card.tsx @@ -0,0 +1,105 @@ +import { User } from "@/lib/api"; + +function isSafeImageUrl(url: string): boolean { + try { + const u = new URL(url); + return u.protocol === "https:" || u.protocol === "http:"; + } catch { + return false; + } +} + +interface MovieMeta { + movieTitle: string; + releaseYear?: number; + posterUrl?: string | null; + rating?: number; + comment?: string | null; + watchedAt?: string | null; + watchlistEntry?: boolean; +} + +interface MovieCardProps { + meta: MovieMeta; + author: User; + createdAt: Date; +} + +function StarRating({ rating, max = 5 }: { rating: number; max?: number }) { + return ( +
+ {Array.from({ length: max }).map((_, i) => ( + + โ˜… + + ))} +
+ ); +} + +export function MovieCard({ meta, author, createdAt }: MovieCardProps) { + const isWatchlist = meta.watchlistEntry === true; + const year = meta.releaseYear ? ` (${meta.releaseYear})` : ""; + const watchedDate = meta.watchedAt + ? new Date(meta.watchedAt).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }) + : null; + + return ( +
+
+ {/* Poster */} +
+ {meta.posterUrl && isSafeImageUrl(meta.posterUrl) ? ( + {meta.movieTitle} + ) : ( +
+ No poster +
+ )} +
+ + {/* Info */} +
+

+ {meta.movieTitle} + {year && {year}} +

+ + {isWatchlist ? ( +

๐Ÿ“‹ Want to watch

+ ) : ( + <> + {meta.rating !== undefined && ( +
+ +
+ )} + {watchedDate && ( +

Watched {watchedDate}

+ )} + + )} + + {meta.comment && ( +

{meta.comment}

+ )} +
+
+ + {/* Footer */} +
+ @{author.username} + ยท + {createdAt.toLocaleDateString()} +
+
+ ); +} diff --git a/thoughts-frontend/components/thought-card.tsx b/thoughts-frontend/components/thought-card.tsx index aaad4b6..bf49ae5 100644 --- a/thoughts-frontend/components/thought-card.tsx +++ b/thoughts-frontend/components/thought-card.tsx @@ -37,6 +37,7 @@ import { Trash2, } from "lucide-react"; import { ThoughtForm } from "@/components/thought-form"; +import { MovieCard } from "@/components/movie-card"; import Link from "next/link"; import { cn } from "@/lib/utils"; @@ -74,6 +75,17 @@ export function ThoughtCard({ const isAuthor = currentUser?.username === thought.author.username; + const meta = thought.noteExtensions as Record | null | undefined; + if (meta?.movieTitle) { + return ( + [0]["meta"]} + author={thought.author} + createdAt={new Date(thought.createdAt)} + /> + ); + } + const handleDelete = async () => { setIsAlertOpen(false); setDeletingState("shaking"); diff --git a/thoughts-frontend/lib/api.ts b/thoughts-frontend/lib/api.ts index 73e2220..f83d737 100644 --- a/thoughts-frontend/lib/api.ts +++ b/thoughts-frontend/lib/api.ts @@ -52,6 +52,7 @@ export const ThoughtSchema = z.object({ boostedByViewer: z.boolean(), createdAt: z.coerce.date(), updatedAt: z.coerce.date().nullable(), + noteExtensions: z.record(z.string(), z.unknown()).nullish(), }); export const RegisterSchema = z.object({ @@ -116,6 +117,7 @@ export const ThoughtThreadSchema: z.ZodType<{ boostedByViewer: boolean; createdAt: Date; updatedAt: Date | null; + noteExtensions?: Record | null; replies: ThoughtThread[]; }> = z.object({ id: z.string().uuid(), @@ -132,6 +134,7 @@ export const ThoughtThreadSchema: z.ZodType<{ boostedByViewer: z.boolean(), createdAt: z.coerce.date(), updatedAt: z.coerce.date().nullable(), + noteExtensions: z.record(z.string(), z.unknown()).nullish(), replies: z.lazy(() => z.array(ThoughtThreadSchema)), });