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:
2026-10-17 21:12:56 +02:00
parent 9dd9df1f73
commit bb2001b9cc
66 changed files with 513 additions and 167 deletions

View File

@@ -26,6 +26,7 @@ pub struct PersistedReview {
pub created_at: NaiveDateTime,
pub source: ReviewSource,
pub watch_medium: Option<WatchMedium>,
pub spoilers: bool,
}
#[derive(Clone, Debug, Default)]
@@ -34,6 +35,7 @@ pub struct ReviewEdit {
pub comment: Option<Option<Comment>>,
pub watched_at: Option<NaiveDateTime>,
pub watch_medium: Option<Option<WatchMedium>>,
pub spoilers: Option<bool>,
}
#[derive(Clone, Debug)]
@@ -47,6 +49,7 @@ pub struct Review {
created_at: NaiveDateTime,
source: ReviewSource,
watch_medium: Option<WatchMedium>,
spoilers: bool,
}
impl Review {
@@ -57,6 +60,7 @@ impl Review {
comment: Option<Comment>,
watched_at: NaiveDateTime,
watch_medium: Option<WatchMedium>,
spoilers: bool,
) -> Result<Self, DomainError> {
Ok(Self {
id: ReviewId::generate(),
@@ -68,6 +72,7 @@ impl Review {
created_at: Utc::now().naive_utc(),
source: ReviewSource::Local,
watch_medium,
spoilers,
})
}
@@ -82,6 +87,7 @@ impl Review {
created_at: row.created_at,
source: row.source,
watch_medium: row.watch_medium,
spoilers: row.spoilers,
}
}
@@ -102,6 +108,7 @@ impl Review {
Some(wm) => wm,
None => self.watch_medium,
},
spoilers: edit.spoilers.unwrap_or(self.spoilers),
}
}
@@ -132,6 +139,9 @@ impl Review {
pub fn watch_medium(&self) -> Option<&WatchMedium> {
self.watch_medium.as_ref()
}
pub fn spoilers(&self) -> bool {
self.spoilers
}
pub fn stars(&self) -> [bool; 5] {
let r = self.rating.value();
[r >= 1, r >= 2, r >= 3, r >= 4, r >= 5]

View File

@@ -79,6 +79,7 @@ fn make_review() -> Review {
Some(Comment::new("great".into()).unwrap()),
chrono::Utc::now().naive_utc(),
None,
false,
)
.unwrap()
}
@@ -114,10 +115,12 @@ fn review_from_persistence() {
actor_url: "https://example.com/actor".into(),
},
watch_medium: None,
spoilers: true,
});
assert_eq!(*r.id(), id);
assert!(r.is_remote());
assert_eq!(r.comment(), None);
assert!(r.spoilers());
}
// ── User ─────────────────────────────────────────────────────────────────────

View File

@@ -12,6 +12,7 @@ fn make_review(watch_medium: Option<WatchMedium>) -> Review {
Some(Comment::new("great film".into()).unwrap()),
NaiveDateTime::parse_from_str("2024-06-15 20:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
watch_medium,
false,
)
.unwrap()
}
@@ -71,6 +72,23 @@ fn apply_edit_clears_watch_medium() {
assert_eq!(edited.watch_medium(), None);
}
#[test]
fn apply_edit_toggles_spoilers() {
let original = make_review(None);
let flag_on = original.apply_edit(ReviewEdit {
spoilers: Some(true),
..Default::default()
});
assert!(flag_on.spoilers());
let flag_off = flag_on.apply_edit(ReviewEdit {
spoilers: Some(false),
..Default::default()
});
assert!(!flag_off.spoilers());
let untouched = original.apply_edit(ReviewEdit::default());
assert!(!untouched.spoilers());
}
#[test]
fn apply_edit_no_changes_preserves_all() {
let original = make_review(Some(WatchMedium::TV));

View File

@@ -29,6 +29,7 @@ fn review_with_rating(movie_id: &MovieId, rating: u8, watched_at: NaiveDateTime)
None,
watched_at,
None,
false,
)
.unwrap()
}