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.
This commit is contained in:
@@ -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<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub(crate) attachment: Vec<ApAttachment>,
|
||||
#[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()],
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user