From bb2001b9cc515e91dc7d9f41383c6fdd35664af4 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 17 Oct 2026 21:12:56 +0200 Subject: [PATCH 1/3] Add Spoiler Flag with veil, AP content-warning encoding, and full-stack plumbing Authors can flag a review's comment as containing spoilers; the comment is blurred behind a reveal control for every viewer except the author, omitted from RSS, and federates as containsSpoilers plus an AP summary content warning (ADR-0011) so Mastodon-class clients also collapse it. Flag follows the general-edit path (ADR-0001) across domain, both DB backends, REST, HTML templates, SPA (shadcn), TUI fixtures, and exports. --- CONTEXT.md | 70 +++-------------- README.md | 1 + crates/adapters/activitypub/src/objects.rs | 9 +++ .../src/remote_review_repository.rs | 1 + .../activitypub/src/review_handler.rs | 2 + .../adapters/activitypub/src/tests/objects.rs | 70 +++++++++++++++++ crates/adapters/adapter-common/src/lib.rs | 2 + crates/adapters/export/src/lib.rs | 8 +- crates/adapters/export/src/tests/lib.rs | 5 +- .../postgres-federation/src/review.rs | 10 ++- .../postgres-social/src/ap_content.rs | 13 +++- .../migrations/0030_review_spoilers.sql | 1 + crates/adapters/postgres/src/diary.rs | 20 +++-- crates/adapters/postgres/src/models.rs | 6 ++ crates/adapters/postgres/src/review.rs | 14 ++-- crates/adapters/rss/src/lib.rs | 10 ++- .../adapters/sqlite-federation/src/review.rs | 8 +- .../adapters/sqlite-social/src/ap_content.rs | 10 ++- .../migrations/0030_review_spoilers.sql | 1 + crates/adapters/sqlite/src/diary.rs | 16 ++-- crates/adapters/sqlite/src/models.rs | 6 ++ crates/adapters/sqlite/src/review.rs | 12 +-- .../templates/activity_feed.html | 8 ++ .../templates/embed_profile.html | 7 ++ .../templates/movie_detail.html | 8 ++ .../template-askama/templates/new_review.html | 3 + .../template-askama/templates/profile.html | 8 ++ crates/api-types/src/diary.rs | 4 + crates/api-types/src/movies.rs | 5 ++ crates/application/src/diary/commands.rs | 2 + crates/application/src/diary/edit_review.rs | 1 + crates/application/src/diary/review_logger.rs | 1 + .../src/diary/tests/delete_review.rs | 1 + .../src/diary/tests/edit_review.rs | 6 ++ .../src/diary/tests/get_user_feed.rs | 1 + .../application/src/diary/tests/log_review.rs | 3 + .../src/diary/tests/review_logger.rs | 7 ++ crates/application/src/import/execute.rs | 1 + .../application/src/integrations/confirm.rs | 1 + crates/domain/src/models/review.rs | 10 +++ crates/domain/src/models/tests/mod.rs | 3 + crates/domain/src/models/tests/review.rs | 18 +++++ .../src/services/tests/review_history.rs | 1 + crates/presentation/src/forms.rs | 6 ++ crates/presentation/src/handlers/diary.rs | 1 + crates/presentation/src/handlers/movies.rs | 2 + crates/presentation/src/mappers/movies.rs | 1 + crates/presentation/src/tests/forms.rs | 2 + crates/tui/src/app.rs | 2 + crates/tui/src/tests/app.rs | 5 ++ crates/tui/src/tests/client.rs | 2 + .../0011-spoiler-flag-federation-encoding.md | 9 +++ spa/src/components/community-reviews.tsx | 75 +++++++++++-------- spa/src/components/feed-tab.tsx | 3 + spa/src/components/movie-card.tsx | 42 +++++++---- spa/src/components/profile-view.tsx | 4 + spa/src/components/review-card.tsx | 28 ++++--- spa/src/components/review-detail-sheet.tsx | 15 +++- spa/src/components/review-form-fields.tsx | 17 +++++ spa/src/components/review-sheet.tsx | 9 +++ spa/src/components/spoiler-veil.tsx | 43 +++++++++++ spa/src/features/diary.ts | 2 + spa/src/features/movies.ts | 2 + spa/src/lib/api/common.ts | 1 + spa/src/locales/en.json | 4 + spa/src/routes/_app/movies.$id.tsx | 11 ++- 66 files changed, 513 insertions(+), 167 deletions(-) create mode 100644 crates/adapters/postgres/migrations/0030_review_spoilers.sql create mode 100644 crates/adapters/sqlite/migrations/0030_review_spoilers.sql create mode 100644 docs/adr/0011-spoiler-flag-federation-encoding.md create mode 100644 spa/src/components/spoiler-veil.tsx diff --git a/CONTEXT.md b/CONTEXT.md index 3e99773d..62631c60 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,69 +1,17 @@ # Movies Diary -A personal movie diary that tracks what you watch, when, and what you thought about it. Supports federation via ActivityPub. +A personal movie diary where users record each watching of a movie with a rating and an optional written review; reviews appear in diaries and social feeds shared with local and federated followers. ## Language -**Movie**: -A film in the catalog, identified by title and release year. Optionally linked to an external metadata provider (e.g. TMDb) for enrichment. One Movie record is shared across all users — "Blade Runner (1982)" exists once regardless of how many people review it. -_Avoid_: Film entry, title record - -**Person**: -Someone involved in making a movie — actor, director, crew member. Sourced from an external metadata provider and enriched with biographical data. Linked to Movies through cast/crew credits. Not a User — Person is movie-industry people only. -_Avoid_: Celebrity, artist, talent - **Review**: -A single record of watching a movie — captures the rating, optional comment, when it was watched, and how it was watched. -_Avoid_: Diary entry, watch, log entry +The record of a single watching of a movie: a rating, an optional written comment, when it was watched, and optionally how. +_Avoid_: Diary entry, post -**Rating**: -A 1–5 whole-star score given to a movie in a Review. No half-stars, no zero. -_Avoid_: Score, grade, stars (as a noun for the value itself) +**Diary Entry**: +A Review together with the Movie it is about, as it appears in a diary or a feed. +_Avoid_: Review (that is the record itself) -**WatchMedium**: -The channel through which a movie was watched: Cinema, Streaming, TV, PhysicalMedia, Download, MediaServer, or Other. -_Avoid_: Source, format, venue, platform - -**Watchlist**: -A user's collection of movies they intend to watch. Each item is a simple bookmark — no priority or ordering. A movie leaves the watchlist implicitly when reviewed, or explicitly when removed. -_Avoid_: Queue, backlog, to-watch list - -**Goal**: -A yearly target a user sets — e.g. "watch 50 movies in 2025." Progress is tracked automatically as reviews are logged. Currently only supports movie-count goals, but the model is designed for other goal types in the future. -_Avoid_: Challenge, resolution, target - -**WrapUp**: -A generated summary report of viewing activity over a date range — statistics, trends, highlights, top directors/actors/genres. Can be personal (one user) or global (all users). Generated asynchronously. Shown to users as "Year in Review." -_Avoid_: Stats page, recap, summary - -**Insight**: -A statement about a user's viewing behavior derived from their Reviews and linked Movie metadata, produced by correlation analysis. Always a comparison — relative to the user's own baseline (their average rating, their genre frequencies, their typical hours) — never a plain ranking or count. "You rate thrillers higher than your average" is an Insight; "thriller is your top genre" is a statistic. -_Avoid_: Correlation (implementation), fun fact, finding - -**User**: -A registered account with a username, email, and profile (display name, bio, avatar, banner). Can be Standard or Admin. -_Avoid_: Account, member, profile (as a synonym for the whole User) - -**SocialIdentity**: -The uniform identifier for anyone involved in a social interaction — either a local User or a remote federated actor. Social commands and queries operate on SocialIdentity so the domain never branches on local vs remote. -_Avoid_: Actor, participant, social user - -**Follow**: -A social relationship where one user subscribes to another's activity. Always requires acceptance by the target user. Works identically for local and federated (ActivityPub) users. Once accepted, the followed user's reviews appear in the follower's Feed. -_Avoid_: Subscribe, connect, friend - -**Feed**: -A chronological timeline of reviews from users you follow — both local and federated. The main social surface of the app. -_Avoid_: Timeline, activity stream, home - -**WatchEvent**: -An automatically detected viewing reported by an external source — currently Jellyfin and Plex via webhook, but conceptually any system that can report "this person watched this movie" (e.g. a cinema ticket service). Arrives in a pending state; the user confirms it (creating a Review) or dismisses it. -_Avoid_: Playback event, webhook event, auto-import - -**Import**: -Bulk ingestion of reviews from an external file — Letterboxd CSV, IMDb CSV, or a generic JSON format. The user uploads a file, column mappings are applied, and reviews are created in batch. -_Avoid_: Upload, migration, sync - -**ImportProfile**: -A saved set of column-to-field mappings for an Import. Reusable across imports and shareable between users. -_Avoid_: Template, mapping preset, import config +**Spoiler Flag**: +An author-set marker on a Review stating that its entire comment reveals plot details. +_Avoid_: Spoiler warning, spoiler tag, spoiler markup diff --git a/README.md b/README.md index dd1c817d..d09ccdaa 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Open `http://localhost:3000`. The HTTP server and background worker start togeth - Log movies with a TMDB/OMDb ID or manual title/year/director, with a 1–5 rating and optional watch medium (cinema, streaming, TV, physical media, download, media server) - Edit reviews after the fact — update rating, comment, date, or watch medium via partial PATCH; each watch is still a separate record (re-watches tracked) +- Spoiler Flag — authors mark a review as containing spoilers; the comment is veiled (blur + reveal) for every viewer except the author, withheld from RSS, and federates as `containsSpoilers` + an AP `summary` content warning so Mastodon-style clients also collapse it - Background poster fetching and storage (local filesystem or S3-compatible) - Movie enrichment via TMDb — full cast, crew, genres, keywords, runtime, budget/revenue, ratings; fetched automatically on movie discovery and refreshed every 30 days; exposed via `GET /api/v1/movies/{id}/profile` - Full-text search across movies and people via `GET /api/v1/search` — free-text query plus structured filters (genre, year, person, department, language); backed by SQLite FTS5 or PostgreSQL tsvector + GIN indexes diff --git a/crates/adapters/activitypub/src/objects.rs b/crates/adapters/activitypub/src/objects.rs index a36237b2..ea7a7445 100644 --- a/crates/adapters/activitypub/src/objects.rs +++ b/crates/adapters/activitypub/src/objects.rs @@ -60,6 +60,12 @@ pub struct ReviewObject { /// Discriminator so Movies Diary instances detect this as a review Note. #[serde(default)] pub(crate) review: bool, + /// Spoiler Flag. Movies-Diary peers veil the comment behind this boolean; + /// other fediverse apps render `summary` as a content warning (ADR-0011). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub(crate) contains_spoilers: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) summary: Option, #[serde(skip_serializing_if = "Vec::is_empty", default)] pub(crate) attachment: Vec, #[serde(default)] @@ -98,6 +104,7 @@ pub fn review_to_ap_object(review: &Review, input: ReviewApInput) -> ReviewObjec String::new() }; let watched_str = format!("Watched: {}", review.watched_at().format("%b %-d, %Y")); + let spoiler_flag = review.spoilers() && comment_text.is_some(); let content = match &comment_text { Some(c) => format!( "{} {}{}\n{}\n{}", @@ -145,6 +152,8 @@ pub fn review_to_ap_object(review: &Review, input: ReviewApInput) -> ReviewObjec watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc), watch_medium: review.watch_medium().map(|wm| wm.to_string()), review: true, + contains_spoilers: spoiler_flag, + summary: spoiler_flag.then(|| "Contains spoilers".to_string()), attachment, tag, to: vec![AS_PUBLIC.to_string()], diff --git a/crates/adapters/activitypub/src/remote_review_repository.rs b/crates/adapters/activitypub/src/remote_review_repository.rs index 87eeae16..682d4384 100644 --- a/crates/adapters/activitypub/src/remote_review_repository.rs +++ b/crates/adapters/activitypub/src/remote_review_repository.rs @@ -11,6 +11,7 @@ pub struct RemoteReviewUpdate<'a> { pub watched_at: NaiveDateTime, pub poster_url: Option<&'a str>, pub watch_medium: Option<&'a str>, + pub spoilers: bool, } #[async_trait] diff --git a/crates/adapters/activitypub/src/review_handler.rs b/crates/adapters/activitypub/src/review_handler.rs index 5c3325de..794bc8fb 100644 --- a/crates/adapters/activitypub/src/review_handler.rs +++ b/crates/adapters/activitypub/src/review_handler.rs @@ -157,6 +157,7 @@ impl ApObjectHandler for ReviewObjectHandler { actor_url: actor_url_str, }, watch_medium, + spoilers: obj.contains_spoilers, }); self.review_store @@ -214,6 +215,7 @@ impl ApObjectHandler for ReviewObjectHandler { watched_at: obj.watched_at.naive_utc(), poster_url: obj.poster_url.as_deref(), watch_medium: obj.watch_medium.as_deref(), + spoilers: obj.contains_spoilers, }) .await?; diff --git a/crates/adapters/activitypub/src/tests/objects.rs b/crates/adapters/activitypub/src/tests/objects.rs index 01165b66..49090e43 100644 --- a/crates/adapters/activitypub/src/tests/objects.rs +++ b/crates/adapters/activitypub/src/tests/objects.rs @@ -30,6 +30,7 @@ fn review_to_ap_object_includes_two_hashtags() { .unwrap(), source: ReviewSource::Local, watch_medium: None, + spoilers: false, }); let obj = review_to_ap_object( &review, @@ -69,6 +70,7 @@ fn review_to_ap_object_has_public_addressing() { .unwrap(), source: ReviewSource::Local, watch_medium: None, + spoilers: false, }); let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap(); let obj = review_to_ap_object( @@ -87,6 +89,74 @@ fn review_to_ap_object_has_public_addressing() { assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]); } +#[test] +fn review_to_ap_object_carries_spoiler_flag_and_summary() { + use chrono::NaiveDateTime; + use domain::{ + models::{PersistedReview, Review, ReviewSource}, + value_objects::{Comment, MovieId, Rating, ReviewId, UserId}, + }; + + let ts = NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(); + let review = Review::from_persistence(PersistedReview { + id: ReviewId::generate(), + movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()), + user_id: UserId::from_uuid(uuid::Uuid::new_v4()), + rating: Rating::new(4).unwrap(), + comment: Some(Comment::new("the mentor is the villain".into()).unwrap()), + watched_at: ts, + created_at: ts, + source: ReviewSource::Local, + watch_medium: None, + spoilers: true, + }); + let obj = review_to_ap_object( + &review, + ReviewApInput { + ap_id: "https://example.com/reviews/1".parse().unwrap(), + actor_url: "https://example.com/users/1".parse().unwrap(), + movie_title: "Dune".to_string(), + release_year: 2021, + external_metadata_id: None, + poster_url: None, + base_url: "https://example.com".to_string(), + }, + ); + assert!(obj.contains_spoilers); + assert_eq!(obj.summary.as_deref(), Some("Contains spoilers")); + let json = serde_json::to_value(&obj).unwrap(); + assert_eq!(json["containsSpoilers"], serde_json::Value::Bool(true)); + assert_eq!(json["summary"], "Contains spoilers"); + + // A flag without a comment never travels: there is nothing to veil. + let review_no_comment = Review::from_persistence(PersistedReview { + id: ReviewId::generate(), + movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()), + user_id: UserId::from_uuid(uuid::Uuid::new_v4()), + rating: Rating::new(4).unwrap(), + comment: None, + watched_at: ts, + created_at: ts, + source: ReviewSource::Local, + watch_medium: None, + spoilers: true, + }); + let obj2 = review_to_ap_object( + &review_no_comment, + ReviewApInput { + ap_id: "https://example.com/reviews/2".parse().unwrap(), + actor_url: "https://example.com/users/1".parse().unwrap(), + movie_title: "Dune".to_string(), + release_year: 2021, + external_metadata_id: None, + poster_url: None, + base_url: "https://example.com".to_string(), + }, + ); + assert!(!obj2.contains_spoilers); + assert!(obj2.summary.is_none()); +} + #[test] fn watchlist_to_ap_object_has_public_addressing() { let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap(); diff --git a/crates/adapters/adapter-common/src/lib.rs b/crates/adapters/adapter-common/src/lib.rs index 9a0eee62..f0b75266 100644 --- a/crates/adapters/adapter-common/src/lib.rs +++ b/crates/adapters/adapter-common/src/lib.rs @@ -89,6 +89,7 @@ pub fn review_row_to_domain( created_at: String, remote_actor_url: Option, watch_medium: Option, + spoilers: bool, ) -> Result { let id = ReviewId::from_uuid(parse_uuid(&id)?); let movie_id = MovieId::from_uuid(parse_uuid(&movie_id)?); @@ -112,6 +113,7 @@ pub fn review_row_to_domain( created_at, source, watch_medium, + spoilers, })) } diff --git a/crates/adapters/export/src/lib.rs b/crates/adapters/export/src/lib.rs index 516a1d98..44f4e62e 100644 --- a/crates/adapters/export/src/lib.rs +++ b/crates/adapters/export/src/lib.rs @@ -27,7 +27,7 @@ fn stream_csv( use futures::StreamExt; let header = futures::stream::once(async { Ok(Bytes::from_static( - b"title,year,director,rating,comment,watched_at,external_metadata_id\n", + b"title,year,director,rating,comment,watched_at,external_metadata_id,spoilers\n", )) }); let rows = entries.map(|r| r.map(|e| Bytes::from(csv_row(&e)))); @@ -80,9 +80,10 @@ fn csv_row(e: &DiaryEntry) -> String { .external_metadata_id() .map(|id| id.value().to_string()) .unwrap_or_default(); + let spoilers = e.review().spoilers(); format!( - "{},{},{},{},{},{},{}\n", - title, year, director, rating, comment, watched_at, ext_id + "{},{},{},{},{},{},{},{}\n", + title, year, director, rating, comment, watched_at, ext_id, spoilers ) } @@ -103,6 +104,7 @@ fn entry_to_json(e: &DiaryEntry) -> serde_json::Value { "comment": e.review().comment().map(|c| c.value().to_string()), "watched_at": e.review().watched_at().format("%Y-%m-%d").to_string(), "external_metadata_id": e.movie().external_metadata_id().map(|id| id.value().to_string()), + "spoilers": e.review().spoilers(), }) } diff --git a/crates/adapters/export/src/tests/lib.rs b/crates/adapters/export/src/tests/lib.rs index 013a5b21..1bda75bc 100644 --- a/crates/adapters/export/src/tests/lib.rs +++ b/crates/adapters/export/src/tests/lib.rs @@ -62,6 +62,7 @@ fn make_entry_full( .and_hms_opt(0, 0, 0) .unwrap(), None, + false, ) .unwrap(); DiaryEntry::new(movie, review) @@ -81,7 +82,7 @@ async fn csv_has_header_and_one_row() { collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Csv)).await; let text = String::from_utf8(bytes).unwrap(); assert!( - text.starts_with("title,year,director,rating,comment,watched_at,external_metadata_id\n") + text.starts_with("title,year,director,rating,comment,watched_at,external_metadata_id,spoilers\n") ); assert!(text.contains("Inception")); assert!(text.contains("2010")); @@ -149,7 +150,7 @@ async fn empty_entries_returns_csv_header_only() { let text = String::from_utf8(bytes).unwrap(); assert_eq!( text, - "title,year,director,rating,comment,watched_at,external_metadata_id\n" + "title,year,director,rating,comment,watched_at,external_metadata_id,spoilers\n" ); } diff --git a/crates/adapters/postgres-federation/src/review.rs b/crates/adapters/postgres-federation/src/review.rs index 644280b0..9772be39 100644 --- a/crates/adapters/postgres-federation/src/review.rs +++ b/crates/adapters/postgres-federation/src/review.rs @@ -46,8 +46,8 @@ impl RemoteReviewRepository for PostgresFederationRepository { let watched_at = datetime_to_str(review.watched_at()); let created_at = datetime_to_str(review.created_at()); sqlx::query( - "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id) - VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9) ON CONFLICT DO NOTHING", + "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id, spoilers) + VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9, $10) ON CONFLICT DO NOTHING", ) .bind(&id) .bind(&movie_id) @@ -58,6 +58,7 @@ impl RemoteReviewRepository for PostgresFederationRepository { .bind(&created_at) .bind(&actor_url) .bind(ap_id) + .bind(review.spoilers()) .execute(&self.pool) .await?; Ok(()) @@ -75,13 +76,14 @@ impl RemoteReviewRepository for PostgresFederationRepository { async fn update_remote_review(&self, u: RemoteReviewUpdate<'_>) -> Result<()> { let watched_at_str = datetime_to_str(&u.watched_at); sqlx::query( - "UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4 - WHERE ap_id = $5 AND remote_actor_url = $6", + "UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4, spoilers = $5 + WHERE ap_id = $6 AND remote_actor_url = $7", ) .bind(u.rating as i64) .bind(u.comment) .bind(&watched_at_str) .bind(u.watch_medium) + .bind(u.spoilers) .bind(u.ap_id) .bind(u.actor_url) .execute(&self.pool) diff --git a/crates/adapters/postgres-social/src/ap_content.rs b/crates/adapters/postgres-social/src/ap_content.rs index 72dc59b4..03444553 100644 --- a/crates/adapters/postgres-social/src/ap_content.rs +++ b/crates/adapters/postgres-social/src/ap_content.rs @@ -69,6 +69,7 @@ struct ReviewRow { created_at: String, remote_actor_url: Option, watch_medium: Option, + spoilers: bool, } impl ReviewRow { @@ -95,6 +96,7 @@ impl ReviewRow { created_at, source, watch_medium, + spoilers: self.spoilers, })) } } @@ -116,6 +118,7 @@ struct DiaryRow { created_at: String, remote_actor_url: Option, watch_medium: Option, + spoilers: bool, } impl DiaryRow { @@ -139,6 +142,7 @@ impl DiaryRow { created_at: self.created_at, remote_actor_url: self.remote_actor_url, watch_medium: self.watch_medium, + spoilers: self.spoilers, } .into_domain()?; Ok(DiaryEntry::new(movie, review)) @@ -226,7 +230,8 @@ impl LocalApContentQuery for PostgresApContentQuery { to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, r.remote_actor_url, - r.watch_medium + r.watch_medium, + r.spoilers FROM reviews r INNER JOIN movies m ON m.id = r.movie_id WHERE r.movie_id = $1 AND r.remote_actor_url IS NULL @@ -256,7 +261,8 @@ impl LocalApContentQuery for PostgresApContentQuery { to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, r.remote_actor_url, - r.watch_medium + r.watch_medium, + r.spoilers FROM reviews r INNER JOIN movies m ON m.id = r.movie_id WHERE r.user_id = $1 AND r.remote_actor_url IS NULL AND r.watched_at < $2::timestamptz @@ -276,7 +282,8 @@ impl LocalApContentQuery for PostgresApContentQuery { to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, r.remote_actor_url, - r.watch_medium + r.watch_medium, + r.spoilers FROM reviews r INNER JOIN movies m ON m.id = r.movie_id WHERE r.user_id = $1 AND r.remote_actor_url IS NULL diff --git a/crates/adapters/postgres/migrations/0030_review_spoilers.sql b/crates/adapters/postgres/migrations/0030_review_spoilers.sql new file mode 100644 index 00000000..41cd6e71 --- /dev/null +++ b/crates/adapters/postgres/migrations/0030_review_spoilers.sql @@ -0,0 +1 @@ +ALTER TABLE reviews ADD COLUMN spoilers BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/crates/adapters/postgres/src/diary.rs b/crates/adapters/postgres/src/diary.rs index 62ef34fd..9c14e8e1 100644 --- a/crates/adapters/postgres/src/diary.rs +++ b/crates/adapters/postgres/src/diary.rs @@ -56,7 +56,8 @@ impl PostgresDiaryRepository { to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, r.remote_actor_url, - r.watch_medium + r.watch_medium, + r.spoilers FROM reviews r INNER JOIN movies m ON m.id = r.movie_id ORDER BY {} @@ -90,7 +91,8 @@ impl PostgresDiaryRepository { to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, r.remote_actor_url, - r.watch_medium + r.watch_medium, + r.spoilers FROM reviews r INNER JOIN movies m ON m.id = r.movie_id WHERE r.movie_id = $1 @@ -178,7 +180,8 @@ impl PostgresDiaryRepository { to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, r.remote_actor_url, - r.watch_medium + r.watch_medium, + r.spoilers FROM reviews r INNER JOIN movies m ON m.id = r.movie_id WHERE r.user_id = $1{remote_clause}{search_clause} @@ -336,6 +339,7 @@ impl DiaryQuery for PostgresDiaryRepository { to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, r.remote_actor_url, r.watch_medium, + r.spoilers, CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url) ELSE COALESCE(u.email, r.user_id) END AS user_email FROM reviews r @@ -412,7 +416,8 @@ impl DiaryQuery for PostgresDiaryRepository { to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, remote_actor_url, - watch_medium + watch_medium, + spoilers FROM reviews WHERE movie_id = $1 ORDER BY watched_at ASC", ) .bind(&id_str) @@ -434,7 +439,8 @@ impl DiaryQuery for PostgresDiaryRepository { to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, r.remote_actor_url, - r.watch_medium + r.watch_medium, + r.spoilers FROM reviews r INNER JOIN movies m ON m.id = r.movie_id WHERE r.user_id = $1 @@ -461,7 +467,8 @@ impl DiaryQuery for PostgresDiaryRepository { to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, r.remote_actor_url, - r.watch_medium + r.watch_medium, + r.spoilers FROM reviews r INNER JOIN movies m ON m.id = r.movie_id WHERE r.user_id = $1 @@ -521,6 +528,7 @@ impl DiaryQuery for PostgresDiaryRepository { to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, r.remote_actor_url, r.watch_medium, + r.spoilers, CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url) ELSE COALESCE(u.email, r.user_id) END AS user_email FROM reviews r diff --git a/crates/adapters/postgres/src/models.rs b/crates/adapters/postgres/src/models.rs index 0ce3005e..fd098df8 100644 --- a/crates/adapters/postgres/src/models.rs +++ b/crates/adapters/postgres/src/models.rs @@ -77,6 +77,7 @@ pub(crate) struct ReviewRow { pub created_at: String, pub remote_actor_url: Option, pub watch_medium: Option, + pub spoilers: bool, } impl ReviewRow { @@ -91,6 +92,7 @@ impl ReviewRow { self.created_at, self.remote_actor_url, self.watch_medium, + self.spoilers, ) } } @@ -112,6 +114,7 @@ pub(crate) struct DiaryRow { pub created_at: String, pub remote_actor_url: Option, pub watch_medium: Option, + pub spoilers: bool, } impl DiaryRow { @@ -134,6 +137,7 @@ impl DiaryRow { self.created_at, self.remote_actor_url, self.watch_medium, + self.spoilers, )?; Ok(DiaryEntry::new(movie, review)) } @@ -156,6 +160,7 @@ pub(crate) struct FeedRow { pub created_at: String, pub remote_actor_url: Option, pub watch_medium: Option, + pub spoilers: bool, pub user_email: String, } @@ -179,6 +184,7 @@ impl FeedRow { self.created_at, self.remote_actor_url, self.watch_medium, + self.spoilers, )?; let diary = DiaryEntry::new(movie, review); Ok(FeedEntry::new(diary, self.user_email)) diff --git a/crates/adapters/postgres/src/review.rs b/crates/adapters/postgres/src/review.rs index ab2996bc..050187a2 100644 --- a/crates/adapters/postgres/src/review.rs +++ b/crates/adapters/postgres/src/review.rs @@ -36,8 +36,8 @@ impl ReviewRepository for PostgresReviewRepository { }; sqlx::query( - "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium) - VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9)", + "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers) + VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9, $10)", ) .bind(&id) .bind(&movie_id) @@ -48,6 +48,7 @@ impl ReviewRepository for PostgresReviewRepository { .bind(&created_at) .bind(&remote_actor_url) .bind(review.watch_medium().map(|wm| wm.to_string())) + .bind(review.spoilers()) .execute(&self.pool) .await .map_err(adapter_common::map_sqlx_error)?; @@ -62,7 +63,8 @@ impl ReviewRepository for PostgresReviewRepository { to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, remote_actor_url, - watch_medium + watch_medium, + spoilers FROM reviews WHERE id = $1", ) .bind(&id) @@ -81,12 +83,13 @@ impl ReviewRepository for PostgresReviewRepository { let watch_medium = review.watch_medium().map(|wm| wm.to_string()); sqlx::query( - "UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4 WHERE id = $5", + "UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4, spoilers = $5 WHERE id = $6", ) .bind(rating) .bind(&comment) .bind(&watched_at) .bind(&watch_medium) + .bind(review.spoilers()) .bind(&id) .execute(&self.pool) .await @@ -112,7 +115,8 @@ impl ReviewRepository for PostgresReviewRepository { to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, remote_actor_url, - watch_medium + watch_medium, + spoilers FROM reviews WHERE user_id = $1 ORDER BY watched_at DESC", ) .bind(&uid) diff --git a/crates/adapters/rss/src/lib.rs b/crates/adapters/rss/src/lib.rs index caff37f1..b9490715 100644 --- a/crates/adapters/rss/src/lib.rs +++ b/crates/adapters/rss/src/lib.rs @@ -22,9 +22,13 @@ impl RssFeedRenderer for RssAdapter { e.movie().title().value(), e.movie().release_year().value() ); - let description = match e.review().comment() { - Some(c) => format!("{}/5 — {}", e.review().rating().value(), c.value()), - None => format!("{}/5", e.review().rating().value()), + let description = match (e.review().comment(), e.review().spoilers()) { + (Some(_), true) => format!( + "{}/5 — contains spoilers", + e.review().rating().value() + ), + (Some(c), false) => format!("{}/5 — {}", e.review().rating().value(), c.value()), + (None, _) => format!("{}/5", e.review().rating().value()), }; let pub_date = e .review() diff --git a/crates/adapters/sqlite-federation/src/review.rs b/crates/adapters/sqlite-federation/src/review.rs index 90511f43..ebbfd300 100644 --- a/crates/adapters/sqlite-federation/src/review.rs +++ b/crates/adapters/sqlite-federation/src/review.rs @@ -46,8 +46,8 @@ impl RemoteReviewRepository for SqliteFederationRepository { let watched_at = datetime_to_str(review.watched_at()); let created_at = datetime_to_str(review.created_at()); sqlx::query( - "INSERT OR IGNORE INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT OR IGNORE INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id, spoilers) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&id) .bind(&movie_id) @@ -58,6 +58,7 @@ impl RemoteReviewRepository for SqliteFederationRepository { .bind(&created_at) .bind(&actor_url) .bind(ap_id) + .bind(review.spoilers()) .execute(&self.pool) .await?; Ok(()) @@ -75,13 +76,14 @@ impl RemoteReviewRepository for SqliteFederationRepository { async fn update_remote_review(&self, u: RemoteReviewUpdate<'_>) -> Result<()> { let watched_at_str = datetime_to_str(&u.watched_at); sqlx::query( - "UPDATE reviews SET rating = ?, comment = ?, watched_at = ?, watch_medium = ? + "UPDATE reviews SET rating = ?, comment = ?, watched_at = ?, watch_medium = ?, spoilers = ? WHERE ap_id = ? AND remote_actor_url = ?", ) .bind(u.rating as i64) .bind(u.comment) .bind(&watched_at_str) .bind(u.watch_medium) + .bind(u.spoilers) .bind(u.ap_id) .bind(u.actor_url) .execute(&self.pool) diff --git a/crates/adapters/sqlite-social/src/ap_content.rs b/crates/adapters/sqlite-social/src/ap_content.rs index ae5df121..aeb412f9 100644 --- a/crates/adapters/sqlite-social/src/ap_content.rs +++ b/crates/adapters/sqlite-social/src/ap_content.rs @@ -69,6 +69,7 @@ struct ReviewRow { created_at: String, remote_actor_url: Option, watch_medium: Option, + spoilers: bool, } impl ReviewRow { @@ -95,6 +96,7 @@ impl ReviewRow { created_at, source, watch_medium, + spoilers: self.spoilers, })) } } @@ -116,6 +118,7 @@ struct DiaryRow { created_at: String, remote_actor_url: Option, watch_medium: Option, + spoilers: bool, } impl DiaryRow { @@ -139,6 +142,7 @@ impl DiaryRow { created_at: self.created_at, remote_actor_url: self.remote_actor_url, watch_medium: self.watch_medium, + spoilers: self.spoilers, } .into_domain()?; Ok(DiaryEntry::new(movie, review)) @@ -212,7 +216,7 @@ impl LocalApContentQuery for SqliteApContentQuery { let mid = movie_id.value().to_string(); let rows = sqlx::query_as::<_, DiaryRow>( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers FROM reviews r INNER JOIN movies m ON m.id = r.movie_id WHERE r.movie_id = ? AND r.remote_actor_url IS NULL @@ -238,7 +242,7 @@ impl LocalApContentQuery for SqliteApContentQuery { let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string(); sqlx::query_as::<_, DiaryRow>( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers FROM reviews r INNER JOIN movies m ON m.id = r.movie_id WHERE r.user_id = ? AND r.remote_actor_url IS NULL AND r.watched_at < ? @@ -254,7 +258,7 @@ impl LocalApContentQuery for SqliteApContentQuery { } else { sqlx::query_as::<_, DiaryRow>( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers FROM reviews r INNER JOIN movies m ON m.id = r.movie_id WHERE r.user_id = ? AND r.remote_actor_url IS NULL diff --git a/crates/adapters/sqlite/migrations/0030_review_spoilers.sql b/crates/adapters/sqlite/migrations/0030_review_spoilers.sql new file mode 100644 index 00000000..2bbc0661 --- /dev/null +++ b/crates/adapters/sqlite/migrations/0030_review_spoilers.sql @@ -0,0 +1 @@ +ALTER TABLE reviews ADD COLUMN spoilers INTEGER NOT NULL DEFAULT 0; diff --git a/crates/adapters/sqlite/src/diary.rs b/crates/adapters/sqlite/src/diary.rs index b44570fe..26788b96 100644 --- a/crates/adapters/sqlite/src/diary.rs +++ b/crates/adapters/sqlite/src/diary.rs @@ -52,7 +52,7 @@ impl SqliteDiaryRepository { }; let sql = format!( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers FROM reviews r INNER JOIN movies m ON m.id = r.movie_id ORDER BY {} @@ -82,7 +82,7 @@ impl SqliteDiaryRepository { }; let sql = format!( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers FROM reviews r INNER JOIN movies m ON m.id = r.movie_id WHERE r.movie_id = ? @@ -158,7 +158,7 @@ impl SqliteDiaryRepository { }; let sql = format!( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers FROM reviews r INNER JOIN movies m ON m.id = r.movie_id WHERE r.user_id = ?{remote_clause}{search_clause} @@ -305,7 +305,7 @@ impl DiaryQuery for SqliteDiaryRepository { let select_sql = format!( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, + r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers, CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url) ELSE COALESCE(u.email, r.user_id) END AS user_email FROM reviews r @@ -378,7 +378,7 @@ impl DiaryQuery for SqliteDiaryRepository { .into_domain()?; let viewings = sqlx::query_as::<_, ReviewRow>( - "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium + "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC", ) .bind(&id_str) @@ -396,7 +396,7 @@ impl DiaryQuery for SqliteDiaryRepository { let uid = user_id.value().to_string(); let rows = sqlx::query_as::<_, DiaryRow>( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers FROM reviews r INNER JOIN movies m ON m.id = r.movie_id WHERE r.user_id = ? @@ -419,7 +419,7 @@ impl DiaryQuery for SqliteDiaryRepository { Box::pin(async_stream::stream! { let mut rows = sqlx::query_as::<_, DiaryRow>( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers FROM reviews r INNER JOIN movies m ON m.id = r.movie_id WHERE r.user_id = ? @@ -476,7 +476,7 @@ impl DiaryQuery for SqliteDiaryRepository { let rows = sqlx::query_as::<_, FeedRow>( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, + r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers, CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url) ELSE COALESCE(u.email, r.user_id) END AS user_email FROM reviews r diff --git a/crates/adapters/sqlite/src/models.rs b/crates/adapters/sqlite/src/models.rs index 186cbaa1..e4b8a5da 100644 --- a/crates/adapters/sqlite/src/models.rs +++ b/crates/adapters/sqlite/src/models.rs @@ -81,6 +81,7 @@ pub(crate) struct ReviewRow { pub created_at: String, pub remote_actor_url: Option, pub watch_medium: Option, + pub spoilers: bool, } impl ReviewRow { @@ -95,6 +96,7 @@ impl ReviewRow { self.created_at, self.remote_actor_url, self.watch_medium, + self.spoilers, ) } } @@ -116,6 +118,7 @@ pub(crate) struct DiaryRow { pub created_at: String, pub remote_actor_url: Option, pub watch_medium: Option, + pub spoilers: bool, } impl DiaryRow { @@ -138,6 +141,7 @@ impl DiaryRow { self.created_at, self.remote_actor_url, self.watch_medium, + self.spoilers, )?; Ok(DiaryEntry::new(movie, review)) } @@ -189,6 +193,7 @@ pub(crate) struct FeedRow { pub created_at: String, pub remote_actor_url: Option, pub watch_medium: Option, + pub spoilers: bool, pub user_email: String, } @@ -212,6 +217,7 @@ impl FeedRow { self.created_at, self.remote_actor_url, self.watch_medium, + self.spoilers, )?; let diary = DiaryEntry::new(movie, review); Ok(FeedEntry::new(diary, self.user_email)) diff --git a/crates/adapters/sqlite/src/review.rs b/crates/adapters/sqlite/src/review.rs index 2582e6b7..4768a496 100644 --- a/crates/adapters/sqlite/src/review.rs +++ b/crates/adapters/sqlite/src/review.rs @@ -36,8 +36,8 @@ impl ReviewRepository for SqliteReviewRepository { }; sqlx::query( - "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&id) .bind(&movie_id) @@ -48,6 +48,7 @@ impl ReviewRepository for SqliteReviewRepository { .bind(&created_at) .bind(&remote_actor_url) .bind(review.watch_medium().map(|wm| wm.to_string())) + .bind(review.spoilers()) .execute(&self.pool) .await .map_err(adapter_common::map_sqlx_error)?; @@ -58,7 +59,7 @@ impl ReviewRepository for SqliteReviewRepository { async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError> { let id = review_id.value().to_string(); sqlx::query_as::<_, ReviewRow>( - "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium + "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers FROM reviews WHERE id = ?", ) .bind(&id) @@ -77,12 +78,13 @@ impl ReviewRepository for SqliteReviewRepository { let watch_medium = review.watch_medium().map(|wm| wm.to_string()); sqlx::query( - "UPDATE reviews SET rating = ?, comment = ?, watched_at = ?, watch_medium = ? WHERE id = ?", + "UPDATE reviews SET rating = ?, comment = ?, watched_at = ?, watch_medium = ?, spoilers = ? WHERE id = ?", ) .bind(rating) .bind(&comment) .bind(&watched_at) .bind(&watch_medium) + .bind(review.spoilers()) .bind(&id) .execute(&self.pool) .await @@ -104,7 +106,7 @@ impl ReviewRepository for SqliteReviewRepository { async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result, DomainError> { let uid = user_id.value().to_string(); sqlx::query_as::<_, ReviewRow>( - "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium + "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers FROM reviews WHERE user_id = ? ORDER BY watched_at DESC", ) .bind(&uid) diff --git a/crates/adapters/template-askama/templates/activity_feed.html b/crates/adapters/template-askama/templates/activity_feed.html index 83436e3d..97ce7042 100644 --- a/crates/adapters/template-askama/templates/activity_feed.html +++ b/crates/adapters/template-askama/templates/activity_feed.html @@ -45,9 +45,17 @@ {% if let Some(wm) = entry.review().watch_medium() %} {{ wm.label() }} {% endif %} + {% let author = ctx.is_current_user(entry.review().user_id().value()) %} {% if let Some(comment) = entry.review().comment() %} + {% if !author && entry.review().spoilers() %} +
+ Contains spoilers + {{ comment.value() }} +
+ {% else %}
{{ comment.value() }}
{% endif %} + {% endif %}
{% match entry.review().source() %} {% when ReviewSource::Remote with { actor_url } %} diff --git a/crates/adapters/template-askama/templates/embed_profile.html b/crates/adapters/template-askama/templates/embed_profile.html index de8dfdda..839a105a 100644 --- a/crates/adapters/template-askama/templates/embed_profile.html +++ b/crates/adapters/template-askama/templates/embed_profile.html @@ -152,8 +152,15 @@ {% endfor %}
{% if let Some(comment) = entry.review().comment() %} + {% if entry.review().spoilers() %} +
+ Contains spoilers + {{ comment.value() }} +
+ {% else %}
{{ comment.value() }}
{% endif %} + {% endif %}
{{ entry.review().watched_at().format("%Y-%m-%d") }}
diff --git a/crates/adapters/template-askama/templates/movie_detail.html b/crates/adapters/template-askama/templates/movie_detail.html index 19ed138e..ab5606f1 100644 --- a/crates/adapters/template-askama/templates/movie_detail.html +++ b/crates/adapters/template-askama/templates/movie_detail.html @@ -129,9 +129,17 @@ ★ {% endfor %} + {% let author = ctx.is_current_user(entry.review().user_id().value()) %} {% if let Some(comment) = entry.review().comment() %} + {% if !author && entry.review().spoilers() %} +
+ Contains spoilers + {{ comment.value() }} +
+ {% else %}
{{ comment.value() }}
{% endif %} + {% endif %}
{% match entry.review().source() %} {% when ReviewSource::Remote with { actor_url } %} diff --git a/crates/adapters/template-askama/templates/new_review.html b/crates/adapters/template-askama/templates/new_review.html index 6df1a795..9ac200a9 100644 --- a/crates/adapters/template-askama/templates/new_review.html +++ b/crates/adapters/template-askama/templates/new_review.html @@ -35,6 +35,9 @@ Comment
+